carlos / platform Public

plan: self-serve moves 1, rev 15 (Astra review 14: the purge protocol removed; purge is today's sweep with conditional record deletes, absorbed by the orphan rule and a never-replay-for-trashed-apps recovery rule)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev c119b92b6e672a3566fad0dfc1af4dd26a136baa parent 718926e
1 file changed, +14 −16
  • docs/superpowers/plans/2026-10-22-self-serve-moves-1-lifetimes-and-order.md +14 −16

CI passed — run details

CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (0 s)
ok  	amadan.net/carlos/platform.git/launchctx	0.015s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
=== vet (1 s)
=== gofmt (0 s)
gofmt -l .: clean
=== rest (143 s)
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	4.117s
ok  	github.com/carlosframework/platform/cmd/moveprobe	11.949s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.761s
ok  	github.com/carlosframework/platform/internal/activator	60.295s
ok  	github.com/carlosframework/platform/internal/agentsock	0.027s
ok  	github.com/carlosframework/platform/internal/alerting	0.008s
ok  	github.com/carlosframework/platform/internal/analytics	0.040s
ok  	github.com/carlosframework/platform/internal/appconfig	3.218s
ok  	github.com/carlosframework/platform/internal/apperr	0.165s
ok  	github.com/carlosframework/platform/internal/applog	0.303s
ok  	github.com/carlosframework/platform/internal/awsinv	0.008s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.011s
ok  	github.com/carlosframework/platform/internal/boxid	0.714s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.009s
ok  	github.com/carlosframework/platform/internal/caps	0.022s
ok  	github.com/carlosframework/platform/internal/certwarm	0.008s
ok  	github.com/carlosframework/platform/internal/cgroup	0.848s
ok  	github.com/carlosframework/platform/internal/changelog	0.628s
ok  	github.com/carlosframework/platform/internal/claimactivation	1.618s
ok  	github.com/carlosframework/platform/internal/clamd	0.059s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	5.698s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.010s
ok  	github.com/carlosframework/platform/internal/dbverify	0.005s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.003s
ok  	github.com/carlosframework/platform/internal/docsite	0.007s
ok  	github.com/carlosframework/platform/internal/domains	3.216s
ok  	github.com/carlosframework/platform/internal/doorkey	0.007s
ok  	github.com/carlosframework/platform/internal/economics	0.015s
ok  	github.com/carlosframework/platform/internal/edge	45.644s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.009s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.190s
ok  	github.com/carlosframework/platform/internal/gate	1.934s
ok  	github.com/carlosframework/platform/internal/genselect	0.024s
ok  	github.com/carlosframework/platform/internal/geo	0.017s
ok  	github.com/carlosframework/platform/internal/geoip	0.088s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.313s
ok  	github.com/carlosframework/platform/internal/host	140.016s
ok  	github.com/carlosframework/platform/internal/hostactors	5.996s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.014s
ok  	github.com/carlosframework/platform/internal/hostbox	3.410s
ok  	github.com/carlosframework/platform/internal/hostclaim	1.038s
ok  	github.com/carlosframework/platform/internal/hostenv	0.014s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.007s
ok  	github.com/carlosframework/platform/internal/hostexec	6.004s
ok  	github.com/carlosframework/platform/internal/hostfence	0.036s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.285s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.031s
ok  	github.com/carlosframework/platform/internal/hostguard	1.135s
ok  	github.com/carlosframework/platform/internal/hostops	2.612s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.003s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.004s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.839s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.236s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.008s
ok  	github.com/carlosframework/platform/internal/infraview	0.004s
ok  	github.com/carlosframework/platform/internal/instancectl	5.247s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.846s
ok  	github.com/carlosframework/platform/internal/instancefs	0.010s
ok  	github.com/carlosframework/platform/internal/instancekey	0.004s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.011s
ok  	github.com/carlosframework/platform/internal/layout	2.093s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.009s
ok  	github.com/carlosframework/platform/internal/lease	10.166s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.626s
ok  	github.com/carlosframework/platform/internal/ledger	0.015s
ok  	github.com/carlosframework/platform/internal/mail	0.255s
ok  	github.com/carlosframework/platform/internal/maintenance	0.101s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.675s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.097s
ok  	github.com/carlosframework/platform/internal/metrics	0.010s
ok  	github.com/carlosframework/platform/internal/moves	21.671s
ok  	github.com/carlosframework/platform/internal/moves/archive	1.226s
ok  	github.com/carlosframework/platform/internal/objstore	0.385s
ok  	github.com/carlosframework/platform/internal/ops	0.273s
ok  	github.com/carlosframework/platform/internal/park	0.326s
ok  	github.com/carlosframework/platform/internal/plans	0.006s
ok  	github.com/carlosframework/platform/internal/privatefile	0.004s
ok  	github.com/carlosframework/platform/internal/provision	14.240s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.013s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.754s
ok  	github.com/carlosframework/platform/internal/relay	7.257s
ok  	github.com/carlosframework/platform/internal/release	25.280s
ok  	github.com/carlosframework/platform/internal/rollout	4.003s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.018s
ok  	github.com/carlosframework/platform/internal/rootaws	0.010s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.065s
ok  	github.com/carlosframework/platform/internal/rootstate	0.029s
ok  	github.com/carlosframework/platform/internal/runs	0.580s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.913s
ok  	github.com/carlosframework/platform/internal/seeds	0.014s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	11.976s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.506s
ok  	github.com/carlosframework/platform/internal/status	0.084s
ok  	github.com/carlosframework/platform/internal/steering	0.012s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.203s
ok  	github.com/carlosframework/platform/internal/svcrole	0.009s
ok  	github.com/carlosframework/platform/internal/telemetry	0.007s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.004s
ok  	github.com/carlosframework/platform/internal/updatewords	0.007s
ok  	github.com/carlosframework/platform/internal/usage	0.007s
ok  	github.com/carlosframework/platform/internal/version	0.004s
ok  	github.com/carlosframework/platform/internal/vet	1.720s
=== internal_console@0 (80 s)
ok  	github.com/carlosframework/platform/internal/console	77.678s
=== internal_console@1 (38 s)
ok  	github.com/carlosframework/platform/internal/console	35.367s
=== internal_console@2 (43 s)
ok  	github.com/carlosframework/platform/internal/console	40.730s
=== internal_console@3 (38 s)
ok  	github.com/carlosframework/platform/internal/console	35.204s
=== internal_console@4 (38 s)
ok  	github.com/carlosframework/platform/internal/console	35.714s
=== internal_console@5 (36 s)
ok  	github.com/carlosframework/platform/internal/console	33.317s
=== internal_console@6 (38 s)
ok  	github.com/carlosframework/platform/internal/console	35.255s
=== internal_console@7 (44 s)
ok  	github.com/carlosframework/platform/internal/console	41.118s
=== root@0 (33 s)
ok  	github.com/carlosframework/platform	31.143s
=== root@1 (44 s)
ok  	github.com/carlosframework/platform	42.715s
=== root@2 (41 s)
ok  	github.com/carlosframework/platform	39.891s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 50069666 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.025s
=== RUN   TestT4StorageProbeMinio
2026/10/22 10:12:35 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E0D25EB06A2EE8, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.023s
minio contract: PASS
diff --git a/docs/superpowers/plans/2026-10-22-self-serve-moves-1-lifetimes-and-order.md b/docs/superpowers/plans/2026-10-22-self-serve-moves-1-lifetimes-and-order.md
index 06eb3ad7e..1efef6e81 100644
--- a/docs/superpowers/plans/2026-10-22-self-serve-moves-1-lifetimes-and-order.md
+++ b/docs/superpowers/plans/2026-10-22-self-serve-moves-1-lifetimes-and-order.md
@@ -2,7 +2,7 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-Rev 14, 2026-10-22. Rev 13's review (`selfserve-plan1-review13.md`: findings 1 and 8 closed; 5, 9, 10, 11, 12 the purge) is answered by **taking boxes out of purging once lifetimes are on**: a box that has seen `lifetimes-on.json` never purges an app again (the console's expiry pass gains the store-driven trash sweep the box runs today), so no purger exists that cannot see the console's pending allocations; before the on-marker a box purge deletes an instance record only by `DeleteIfMatch` on an ETag it captured **before** its final marker check. The console's purge runs under the app mutation lock, records the **generation** it observed for every host (slot incarnation or record ETag) before any destructive step, and every retirement states that expectation (never `retireAny`), so a delayed callback cannot touch a successor; a pending allocation is terminalised by **fencing its slot** (a CAS write of a retired slot at its incarnation, against absence or the predecessor's ETag) so an outstanding allocation PUT can never land, then `void`; the purge calls the **locked** retirement under its own `withHostname`; the box has no retirement code at all; the witnesses are split into console and box expectations with their real terminal states. Rev 12's review (`selfserve-plan1-review12.md`: findings 2, 3, 4, 6, 7 closed; 1 a witness contradiction; 5, 8, 9 the purge) is answered: the restart witness no longer activates "at once"; the console writes a durable **`control/lifetimes-on.json`** as the first act of `on`, and that marker, not judge availability, is a box's authority for any slot mutation; purge **excludes instance-record keys from its sweep** whenever a retire hook is installed; the console's purge **voids the app's pending allocations durably** (`purged_at`) and retires their slots before touching records, and recovery treats a purged pending row as `void`; the box's purge needs **no migration, no journal and no cross-process lock**: it retires by CAS alone (slot CAS to `retired`, then `DeleteIfMatch` on the observed record ETag; a marked record is skipped; any CAS loss returns an error and the next pass retries idempotently), and before the on-marker it deletes only by `DeleteIfMatch` on the listed ETag after re-reading the marker. Rev 12's review (`selfserve-plan1-review11.md`, findings 1 to 7) is answered: the storage settle is anchored **after the last `before` unit released admission** (and after the process start on a restart), so a legacy request that was fully received before its client timed out has completed before activation; activation requires a **validated marker read** (`markerSeen`), never uncertainty alone; app purge is **lifetime-aware** on every path (browser, expiry pass, box auto-purge) through a retirement core in `provision` that `release.PurgeApp` calls for every instance record before its sweep; an in-scope valid own record defeats only the record-gone reason and still reaches the app-trash decision; the dispatch of create, delete, detach and `readDeletedRecord` is stated per admitted unit; both detached continuations (`runImportUpload`, `createFrom → runFrom`) are wired; the Task 3 witness no longer says draining reads are legacy; `claimAliasUnbacked` is named in the audit. Rev 11, 2026-10-22. Rev 10's review (`selfserve-plan1-review10.md`, findings 1 to 4) is answered: the cutover gains an **admission gate**: every claims-off unit of record work (a request's handler, a detached continuation, a worker iteration) is admitted under a counted gate while the state is `before`; the cutover door closes admission synchronously, refuses new work with 503, **waits for the admitted count to reach zero**, then starts the storage settle, then activates; so no unit admitted before the marker ever runs beside the lifetime protocol, and a unit never changes mode mid-flight. `on` is **latched**: once observed it is never left; at startup the state is `unknown` until the first successful marker read and every claims-off record door (reads and mutations) answers 503 until then, so a marker-read failure never restores legacy interpretation. The removal rule separates lifetime validity from placement eligibility: a valid own record that is **in this pass's scope** protects the legacy collision; a valid own record **outside** the scope follows today's removal rules (`KeepParticipant`, the app-trash rule), so offline finishing removes the source row as today; absence or staleness authorises the new path. One worker lifecycle (start once, loop, check the state each iteration), one migration interval, one context contract (`recordCtx`), the CLI command, the retry error, the three root constructions and the copy inventory are stated. Rev 10, 2026-10-22. Rev 9's review (`selfserve-plan1-review9.md`, findings 1 to 5) is answered: the cutover is a **mode** with a drain: from the marker's time until the window ends the console refuses every claims-off record mutation (503), so no legacy write can be admitted into the window, and the gate, the judge's installation, the recovery pass and the marker's failure modes are one contract with an implementation task for the operator door; the removal loop decides from the row's **own** record (observed by its own key), so a valid legacy owner in a hostname collision is kept as today and only an established absent or stale own record retires the row, with the keep view bypassed on that evidence and replacement stated as two passes; `fenceRecord` returns a classification so an own tombstone or marker that won resumes the operation instead of terminating it; the audit's allowlist names the two remaining claims-on callers of `hostops.ReadRecord` and is pinned both ways; and the remaining contracts are settled (the detach sentence, cleanup recovery through C2, corrupt index candidates, the root restart readers' judge). Rev 8's review (`selfserve-plan1-review8.md`, findings 1 to 5) is answered: the rollout guarantee becomes an **enforced cutover** (an operator marker written after the old console has stopped, plus the settle window, with the storage assumption stated) rather than a timer alone; quarantine copies the corrupt bytes and **never deletes under a live slot**: the retirement converts durably to the cleanup kind and retires the slot first, and a conditional-delete conflict re-classifies instead of counting as absence; a provisioned row owned by another record key than the hostname's current record is retired as "record gone" so a cross-owner recreation cannot leave a lagging box serving the predecessor; `RecordIndex.Find` judges its candidate keys before deciding ambiguity; and the remaining contradictions are resolved (the root scope sentence, one detach chain, the locked retirement's callers, reconcile's one sequence, the audit's enumerated list, the fence witness split). Rev 7's review (`selfserve-plan1-review7.md`, findings 1 to 6) is answered: a stated rollout guarantee closes the late legacy delete (one console per deployment, a settle window before any termination, old consoles never reintroduced over lifetimes records); the first-route attach, its rollback and detach run **inside** the hostname exclusion from the claim write to the record write, through locked variants of the create and retirement helpers; root's proof and restart record readers take the box's judge (the scope statement is corrected: only `ClaimRootHTTP`'s claims-on readers are slot-free); a corrupt body is quarantined (its bytes kept under `control/quarantine/`, the key removed conditionally) so the retirement or migration proceeds as for absence instead of promising a fence that cannot parse it; `provision.WithLifetimes` is the one context helper that carries the judge and enables sequencing, the runs worker wraps its detached execution context, the audit distinguishes approved judged wrappers from raw readers, the fence witnesses state their timing, exhaustion is witnessed at every transition, and the sibling undo has one dispatch path. Earlier reviews: rev 1 (`after236/selfserve-plan1-review.md`, 16 findings), rev 2 (F1 to F12), rev 3 (R1 to R10), rev 4 (1 to 9), rev 5 (1 to 6), rev 6 (`selfserve-plan1-review6.md`, findings 1 to 5). Rev 7 answers rev 6's five: every claims-off record write runs with `WithRecordSequence`, so `record_seq` exists after a record's first write and an ETag never recurs, and recovery writes a **fence** (a sequencing rewrite) before it terminates an operation on evidence of a changed record; one migration dispatcher classifies the observation at every entry, so a resume can never supersede its own marker; the attach door's claim rollback runs inside the hostname exclusion and never releases a claim a pending operation or a valid record needs; the judge reaches every mutation context (the console's top-level handler wraps every request, browser and API alike; the seed callback wraps its own context; the full-record updater's re-read is judged; `RecordIndex.Find` and `FindRecordByHost` take the judge from the context), and the audit is by AST; the remaining contracts (the running N1 table, a slot-retirement helper, one expectation type, listed-key absence, exhaustion at read, the import witness's state, the producer-state publication tests) are answered. Rev 5 removed the slot cache (every judgement reads the slot fresh, after the record, in bounded rounds) and made a successor lifetime's hold one row write. Rev 6 answers rev 5's six: a retirement persists its **kind** (normal, orphan, cleanup) and each kind has its own steps and recovery table; a migration's marker-CAS conflict is classified by a re-read for both the bound and the unbound form, so an own marker that landed is resumed, never superseded; every `retireInstanceRecord` caller states its expectation (the sibling undo names the lifetime it created; detach deletes what is current; the attach door never asks for a record rollback over a lifetime failure); a judge carried on the context makes `updateRecordSeeding`, and so every `SetRecord*` writer, refuse a stale record, and every box reader (Phase 1's included) and the import journal's readers go through the judge; the provenance mark is dropped (the fold compares pairs between unstamped entries, a pair-less entry is `(0, 0)` and equal pairs fall to the existing rule, exactly §6.4); the remaining interface gaps are answered.
+Rev 15, 2026-10-22. Rev 14's review (`selfserve-plan1-review14.md`: 9, 11, 12 closed; 5, 10, 13, 14, 15, 16 the purge protocol) is answered by **removing the purge protocol**: four rounds of a lifetime-aware purge each found the next race (snapshot vs recovery, in-flight box sweeps, legacy incarnation 0, the app lock, guard ordering, the schema), so purge goes back to **today's sweep** with one conditional step (an instance record is deleted by `DeleteIfMatch` on an ETag a `Get` captured just before), on the console under the existing `s.lockApp` mutex its callers already hold, on the box as today; and the lifetime protocol **absorbs the consequences** with two rules it already nearly had: a live slot whose record is absent and for which this console holds no pending operation is an **orphan**, retired as kind `orphan` by the next create that meets it (so a purged app's slots never block a hostname); and allocation recovery **never replays a create for a trashed or purged app** (it retires the slot, and anything that landed at its incarnation, as `orphan`, and voids the row), so a late record or slot PUT of a purged app's allocation is cleaned by the same recovery. No generation table, no slot fence, no on-marker, no console trash sweep. Rev 13's review (`selfserve-plan1-review13.md`: findings 1 and 8 closed; 5, 9, 10, 11, 12 the purge) is answered by **taking boxes out of purging once lifetimes are on**: a box that has seen `lifetimes-on.json` never purges an app again (the console's expiry pass gains the store-driven trash sweep the box runs today), so no purger exists that cannot see the console's pending allocations; before the on-marker a box purge deletes an instance record only by `DeleteIfMatch` on an ETag it captured **before** its final marker check. The console's purge runs under the app mutation lock, records the **generation** it observed for every host (slot incarnation or record ETag) before any destructive step, and every retirement states that expectation (never `retireAny`), so a delayed callback cannot touch a successor; a pending allocation is terminalised by **fencing its slot** (a CAS write of a retired slot at its incarnation, against absence or the predecessor's ETag) so an outstanding allocation PUT can never land, then `void`; the purge calls the **locked** retirement under its own `withHostname`; the box has no retirement code at all; the witnesses are split into console and box expectations with their real terminal states. Rev 12's review (`selfserve-plan1-review12.md`: findings 2, 3, 4, 6, 7 closed; 1 a witness contradiction; 5, 8, 9 the purge) is answered: the restart witness no longer activates "at once"; the console writes a durable **`control/lifetimes-on.json`** as the first act of `on`, and that marker, not judge availability, is a box's authority for any slot mutation; purge **excludes instance-record keys from its sweep** whenever a retire hook is installed; the console's purge **voids the app's pending allocations durably** (`purged_at`) and retires their slots before touching records, and recovery treats a purged pending row as `void`; the box's purge needs **no migration, no journal and no cross-process lock**: it retires by CAS alone (slot CAS to `retired`, then `DeleteIfMatch` on the observed record ETag; a marked record is skipped; any CAS loss returns an error and the next pass retries idempotently), and before the on-marker it deletes only by `DeleteIfMatch` on the listed ETag after re-reading the marker. Rev 12's review (`selfserve-plan1-review11.md`, findings 1 to 7) is answered: the storage settle is anchored **after the last `before` unit released admission** (and after the process start on a restart), so a legacy request that was fully received before its client timed out has completed before activation; activation requires a **validated marker read** (`markerSeen`), never uncertainty alone; app purge is **lifetime-aware** on every path (browser, expiry pass, box auto-purge) through a retirement core in `provision` that `release.PurgeApp` calls for every instance record before its sweep; an in-scope valid own record defeats only the record-gone reason and still reaches the app-trash decision; the dispatch of create, delete, detach and `readDeletedRecord` is stated per admitted unit; both detached continuations (`runImportUpload`, `createFrom → runFrom`) are wired; the Task 3 witness no longer says draining reads are legacy; `claimAliasUnbacked` is named in the audit. Rev 11, 2026-10-22. Rev 10's review (`selfserve-plan1-review10.md`, findings 1 to 4) is answered: the cutover gains an **admission gate**: every claims-off unit of record work (a request's handler, a detached continuation, a worker iteration) is admitted under a counted gate while the state is `before`; the cutover door closes admission synchronously, refuses new work with 503, **waits for the admitted count to reach zero**, then starts the storage settle, then activates; so no unit admitted before the marker ever runs beside the lifetime protocol, and a unit never changes mode mid-flight. `on` is **latched**: once observed it is never left; at startup the state is `unknown` until the first successful marker read and every claims-off record door (reads and mutations) answers 503 until then, so a marker-read failure never restores legacy interpretation. The removal rule separates lifetime validity from placement eligibility: a valid own record that is **in this pass's scope** protects the legacy collision; a valid own record **outside** the scope follows today's removal rules (`KeepParticipant`, the app-trash rule), so offline finishing removes the source row as today; absence or staleness authorises the new path. One worker lifecycle (start once, loop, check the state each iteration), one migration interval, one context contract (`recordCtx`), the CLI command, the retry error, the three root constructions and the copy inventory are stated. Rev 10, 2026-10-22. Rev 9's review (`selfserve-plan1-review9.md`, findings 1 to 5) is answered: the cutover is a **mode** with a drain: from the marker's time until the window ends the console refuses every claims-off record mutation (503), so no legacy write can be admitted into the window, and the gate, the judge's installation, the recovery pass and the marker's failure modes are one contract with an implementation task for the operator door; the removal loop decides from the row's **own** record (observed by its own key), so a valid legacy owner in a hostname collision is kept as today and only an established absent or stale own record retires the row, with the keep view bypassed on that evidence and replacement stated as two passes; `fenceRecord` returns a classification so an own tombstone or marker that won resumes the operation instead of terminating it; the audit's allowlist names the two remaining claims-on callers of `hostops.ReadRecord` and is pinned both ways; and the remaining contracts are settled (the detach sentence, cleanup recovery through C2, corrupt index candidates, the root restart readers' judge). Rev 8's review (`selfserve-plan1-review8.md`, findings 1 to 5) is answered: the rollout guarantee becomes an **enforced cutover** (an operator marker written after the old console has stopped, plus the settle window, with the storage assumption stated) rather than a timer alone; quarantine copies the corrupt bytes and **never deletes under a live slot**: the retirement converts durably to the cleanup kind and retires the slot first, and a conditional-delete conflict re-classifies instead of counting as absence; a provisioned row owned by another record key than the hostname's current record is retired as "record gone" so a cross-owner recreation cannot leave a lagging box serving the predecessor; `RecordIndex.Find` judges its candidate keys before deciding ambiguity; and the remaining contradictions are resolved (the root scope sentence, one detach chain, the locked retirement's callers, reconcile's one sequence, the audit's enumerated list, the fence witness split). Rev 7's review (`selfserve-plan1-review7.md`, findings 1 to 6) is answered: a stated rollout guarantee closes the late legacy delete (one console per deployment, a settle window before any termination, old consoles never reintroduced over lifetimes records); the first-route attach, its rollback and detach run **inside** the hostname exclusion from the claim write to the record write, through locked variants of the create and retirement helpers; root's proof and restart record readers take the box's judge (the scope statement is corrected: only `ClaimRootHTTP`'s claims-on readers are slot-free); a corrupt body is quarantined (its bytes kept under `control/quarantine/`, the key removed conditionally) so the retirement or migration proceeds as for absence instead of promising a fence that cannot parse it; `provision.WithLifetimes` is the one context helper that carries the judge and enables sequencing, the runs worker wraps its detached execution context, the audit distinguishes approved judged wrappers from raw readers, the fence witnesses state their timing, exhaustion is witnessed at every transition, and the sibling undo has one dispatch path. Earlier reviews: rev 1 (`after236/selfserve-plan1-review.md`, 16 findings), rev 2 (F1 to F12), rev 3 (R1 to R10), rev 4 (1 to 9), rev 5 (1 to 6), rev 6 (`selfserve-plan1-review6.md`, findings 1 to 5). Rev 7 answers rev 6's five: every claims-off record write runs with `WithRecordSequence`, so `record_seq` exists after a record's first write and an ETag never recurs, and recovery writes a **fence** (a sequencing rewrite) before it terminates an operation on evidence of a changed record; one migration dispatcher classifies the observation at every entry, so a resume can never supersede its own marker; the attach door's claim rollback runs inside the hostname exclusion and never releases a claim a pending operation or a valid record needs; the judge reaches every mutation context (the console's top-level handler wraps every request, browser and API alike; the seed callback wraps its own context; the full-record updater's re-read is judged; `RecordIndex.Find` and `FindRecordByHost` take the judge from the context), and the audit is by AST; the remaining contracts (the running N1 table, a slot-retirement helper, one expectation type, listed-key absence, exhaustion at read, the import witness's state, the producer-state publication tests) are answered. Rev 5 removed the slot cache (every judgement reads the slot fresh, after the record, in bounded rounds) and made a successor lifetime's hold one row write. Rev 6 answers rev 5's six: a retirement persists its **kind** (normal, orphan, cleanup) and each kind has its own steps and recovery table; a migration's marker-CAS conflict is classified by a re-read for both the bound and the unbound form, so an own marker that landed is resumed, never superseded; every `retireInstanceRecord` caller states its expectation (the sibling undo names the lifetime it created; detach deletes what is current; the attach door never asks for a record rollback over a lifetime failure); a judge carried on the context makes `updateRecordSeeding`, and so every `SetRecord*` writer, refuse a stale record, and every box reader (Phase 1's included) and the import journal's readers go through the judge; the provenance mark is dropped (the fold compares pairs between unstamped entries, a pair-less entry is `(0, 0)` and equal pairs fall to the existing rule, exactly §6.4); the remaining interface gaps are answered.
**Goal:** Give every claims-off instance record a durable lifetime (the host incarnation slot) and every served row a serving order `(incarnation, serving_seq)` that boxes publish and edges honour, with crash-safe create, retirement and migration, so a later plan's holding pages and moves can never be out-ordered by a stale publication.
@@ -18,7 +18,7 @@ Rev 14, 2026-10-22. Rev 13's review (`selfserve-plan1-review13.md`: findings 1 a
- Every claims-off consumer acts on an **observation** (Task 6): a record and the slot read after it, judged `valid`, `stale`, `corrupt` or `unreadable`, with `Present` saying whether a body was there. `stale` is absent; `corrupt` is today's unreadable body (reconcile protects; the delete rule may remove it conditionally); `unreadable` cannot be judged this pass (reconcile protects the row, a door answers 502, a listing reports the host unreadable).
- The slot is never deleted and never advanced to abandon a create (spec §6.4). It never moves backwards: every slot write names the exact prior state it replaces. A malformed slot body is `unreadable`, never `stale`.
- One per-hostname exclusion (`withHostname`, Task 3) around create, retirement, migration and their recovery in the console process. A persisted pending operation for a hostname refuses any create or retirement of that hostname by another operation (409 "another change to this instance is still settling. try again in a minute") until recovery resolves it; unreadable evidence leaves it pending and keeps refusing. After an allocation, retirement or migration row is durable, no failure returns an ordinary error to the caller: the outcome is `errLifetimePending` (the intent stays pending; recovery owns it), a persisted terminal state, or success. In every recovery table a re-read that fails is `unreadable` and leaves the row pending; it never selects a terminal branch.
-- Every record write stays on `provision.WriteRecord` with the exact bytes read and the sequence discipline of `record_sequence.go`; no new raw `Put` of a record key. Every claims-off record **update** runs with a judge on its context (`provision.WithJudge`, Task 6), so `updateRecordSeeding` refuses a stale record before it mutates anything. Every record removal on a claims-off console is `retireRecordWithLifetime` (Task 4) with an explicit expectation; `provision.DeleteRecord` gains no new caller and loses its claims-off instance callers.
+- Every record write stays on `provision.WriteRecord` with the exact bytes read and the sequence discipline of `record_sequence.go`; no new raw `Put` of a record key. Every claims-off record **update** runs with a judge on its context (`provision.WithJudge`, Task 6), so `updateRecordSeeding` refuses a stale record before it mutates anything. Every record removal on a claims-off console is `retireRecordWithLifetime` (Task 4) with an explicit expectation, **except app purge**, which stays today's sweep with its instance-record deletes made conditional (`DeleteIfMatch` on a captured ETag) and whose lifetime consequences the orphan rule and the trashed-app recovery rule absorb (Task 4); `provision.DeleteRecord` gains no new caller and loses its other claims-off instance callers.
- `release.DeleteIfMatch` never falls back to a plain delete; absence counts as success.
- No console write to a bucket outside the deployment bucket. On the box, `ClaimRootHTTP`'s claims-on readers are untouched; root's proof and restart record readers take the judge (Task 6).
- Census: `TestPlan14RootReadCensus` and `TestPlan9B8RecordWriteCensus` are the authority on what they cover. The root-read overlay is `internal/provision/testdata/plan14-root-reads-self-serve-moves-1.tsv`, loaded last (before the comparison) by a new block at the end of the test, `rev3=PROPOSED-self-serve-moves-1`, with an `@fields` line naming the new record fields and registry columns, rows for every changed unit and envelope the test names, reviewed dispositions for changed frozen units, and the note `docs/superpowers/notes/census-proposal-self-serve-moves-1.md`. The record-write inventory is `plan14SelfServeMoves1WriteCensusInventory()`, wrapping `plan14ClaimRosterBindingWriteCensusInventory()` with every added, changed and removed site the scanner reports, each reviewed. Both tests green.
@@ -51,13 +51,13 @@ Rev 14, 2026-10-22. Rev 13's review (`selfserve-plan1-review13.md`: findings 1 a
**Interfaces:**
- `type cutoverMarker struct { At time.Time \`json:"at"\`; By string \`json:"by"\` }`; `const lifetimesCutoverKey = "control/lifetimes-cutover.json"`; `const lifetimeSettle = 60 * time.Second`; `type cutoverState int` (`cutoverUnknown`, `cutoverBefore`, `cutoverDraining`, `cutoverOn`).
-- `type cutoverGate struct { mu sync.Mutex; state cutoverState; markerSeen bool; at time.Time; started time.Time; lastDone time.Time; admitted int; drained chan struct{}; pageAt time.Time }`: `func (g *cutoverGate) admit(now time.Time, mutating bool) (unit *admittedUnit, ok bool, refusal string)` (`unknown` → false, the starting sentence; `before` → a unit with `mode: before`, `admitted++`; `draining` → `mutating` → false, the draining sentence; a plain read → a unit with `mode: on` (judged; reads never write); `on` → a unit with `mode: on`); `func (u *admittedUnit) done()` (`admitted--` for a `before` unit; the last one records `lastDone = now` and closes `drained`; exactly one `done()` per unit, enforced by a `sync.Once`); `func (g *cutoverGate) observe(m *cutoverMarker, readErr error, now time.Time)` (the watcher's and the door's input: the transitions of the Global Constraint; `on` is never left; `before`→`draining` closes admission synchronously); `func (g *cutoverGate) activateWhenDrained(now time.Time) bool` (`draining` and `markerSeen` and `admitted == 0` and `now >= max(at, started, lastDone) + lifetimeSettle` → `on`; a read failure sets `draining` without `markerSeen`, which never activates); **the on-marker**: the first act of `on`, before any lifetime write, is `Put(control/lifetimes-on.json, {"at", "console"}, create-only)` (`lifetimesOnKey`); an existing one is left; a failed write keeps the state `draining` and retries each watcher tick. The on-marker is the **boxes'** authority (Task 4's purge): a box performs a slot mutation only after it has read `lifetimes-on.json` (latched once seen; read each pass until then); the cutover marker alone never authorises a box. `func (g *cutoverGate) status() cutoverStatus` (`{State string; At time.Time; Pending int}`).
+- `type cutoverGate struct { mu sync.Mutex; state cutoverState; markerSeen bool; at time.Time; started time.Time; lastDone time.Time; admitted int; drained chan struct{}; pageAt time.Time }`: `func (g *cutoverGate) admit(now time.Time, mutating bool) (unit *admittedUnit, ok bool, refusal string)` (`unknown` → false, the starting sentence; `before` → a unit with `mode: before`, `admitted++`; `draining` → `mutating` → false, the draining sentence; a plain read → a unit with `mode: on` (judged; reads never write); `on` → a unit with `mode: on`); `func (u *admittedUnit) done()` (`admitted--` for a `before` unit; the last one records `lastDone = now` and closes `drained`; exactly one `done()` per unit, enforced by a `sync.Once`); `func (g *cutoverGate) observe(m *cutoverMarker, readErr error, now time.Time)` (the watcher's and the door's input: the transitions of the Global Constraint; `on` is never left; `before`→`draining` closes admission synchronously); `func (g *cutoverGate) activateWhenDrained(now time.Time) bool` (`draining` and `markerSeen` and `admitted == 0` and `now >= max(at, started, lastDone) + lifetimeSettle` → `on`; a read failure sets `draining` without `markerSeen`, which never activates); Boxes perform no slot mutation of their own on any path (Task 4), so the console's cutover is the only cutover. `func (g *cutoverGate) status() cutoverStatus` (`{State string; At time.Time; Pending int}`).
- `func (s *Server) lifetimesAdmit(ctx context.Context, mutating bool) (*admittedUnit, bool, string)`; `func (s *Server) recordCtx(ctx context.Context, u *admittedUnit) context.Context` (`u.mode == on` → `provision.WithLifetimes(ctx, s.judge)`, else `ctx` unchanged); `func (s *Server) lifetimesOn() bool` (`!cfg.HostClaim && cfg.Releases != nil && s.cutover.state == cutoverOn`). Every claims-off record door: `u, ok, why := s.lifetimesAdmit(ctx, mutating); if !ok { 503 why }; defer u.done(); ctx = s.recordCtx(ctx, u)`. **Dispatch by unit:** every claims-off create, delete, detach, purge and `readDeletedRecord` site selects its path from `u.mode`: `before` → today's legacy call (the plain create, the unconditional `DeleteRecord`, the raw read) with no slot and no judge; `on` → the lifetime helper of Tasks 3 to 5; `!cfg.HostClaim` alone never selects the lifetime path. **Detached continuations** (`runImportUpload` and `createFrom → runFrom`): the handler transfers `u` to the job **before** launching the goroutine, the handler does not call `done()`, and the job calls it exactly once after its deferred abort or cleanup writes have returned. Every worker iteration: admit (non-mutating workers as reads; the passes that write as mutating), skip the iteration when refused, `done()` at its end.
- `func (s *Server) handleAPILifetimesCutover(w, r)`: an operator's own CLI token (else 404, as the move family); `cfg.HostClaim` → 409 "this console runs host claims; lifetimes are not for it"; writes the marker create-only with `At: now`, `By: addr`, then `s.cutover.observe(&m, nil, now)` **synchronously** (admission closes before the response); `ErrCASConflict` → read the existing marker: readable → `observe` it and answer with the status; unreadable or malformed → 502 "the cutover marker exists but cannot be read. repair control/lifetimes-cutover.json by hand" (never a success); the audit line "switched instance lifetimes on (cli)"; the response is `cutoverStatus` (200). The CLI prints "instance lifetimes: switching on; N unit(s) of earlier work still finishing" while `Pending > 0` or the settle has not passed, and "instance lifetimes: on since <time>" when `on`; `carlos system lifetimes cutover` polls the door every 5 s until `on` (the door is idempotent) or `--no-wait`.
- `StartLifetimesCutoverWatch`: reads the marker at start and every 10 s and calls `observe`, then `activateWhenDrained`; the door's synchronous `observe` is what makes refusal immediate at `At`; the watcher covers a restart and a marker written by hand (documented: a hand-written marker closes admission within 10 s).
- **Worker lifecycle (one rule):** every worker starts once at process start and loops for the life of the process; each iteration admits, does its work only when admitted in the mode it requires (`on` for the recovery and migration passes), and ends its unit. No worker returns early because the state was `before` or `unknown` at start.
-- [ ] **Step 1: Write the failing tests**: `TestCutoverDoorIsOperatorOnlyAndIdempotent` (second call → the status; an unreadable existing marker → 502, not a success); `TestCutoverStates` (unknown at start → 503 on every door incl. GET; a read marker absent → before; a marker written → draining at once; `on` after drain and settle; `on` is kept across a later read failure and a corrupt marker; a restart into a marker older than 60 s → draining then on 60 s after the process start; a restart into a marker 10 s old → the same); `TestDrainingRefusesEveryClaimsOffMutation` (one subtest per door → 503, nothing written; GETs answer, judged); `TestActivationWaitsForAdmittedWork` (a real creation handler paused before its fresh record-write context, and a detached import continuation paused mid-body, both admitted before the marker; the marker written; 60 s pass; the state is still draining with `Pending == 2`; each finishes → its legacy write lands under `before` rules; the count reaches zero → `on` after the settle; a creation arriving at `At + 1 s` → 503); `TestNoUnitChangesModeMidFlight` (a unit admitted in `before` observes no judge after activation would otherwise have happened; one admitted in `on` has it); `TestWorkersLoopAcrossTheCutover` (the recovery and migration passes started before the marker do nothing, then run after activation without a restart; the seed sweep's iteration admitted before the marker finishes under legacy rules and the next iteration is judged); `TestMarkerFailureAfterActivationKeepsJudgedReads` (a retired slot and an obsolete body; the marker read fails; GET and listings refuse the body); `TestCutoverCLI` (the dispatch, the client call, the two output lines, `--no-wait`); `TestSettleStartsAfterTheLastLegacyUnit` (a `before` unit whose final store call returns an ambiguous error while the server-side effect is still outstanding (a fake store that applies the delete 5 s after returning a timeout); the unit calls `done()` at `T`; the marker is 10 min old; activation happens no earlier than `T + 60 s`; the late delete lands before activation and after it no legacy write exists); `TestActivationRequiresAValidatedMarker` (absent marker; a read failure; no units; 60 s pass → still draining, not on; a successful absent read → before; a successful present read → draining then on after the settle; a restart into a 10 min old marker → draining, then on 60 s after the process start, not at once; on activation `control/lifetimes-on.json` is written create-only before any lifetime write); `TestDispatchFollowsTheUnit` (a `before` unit's create, delete, detach and `readDeletedRecord` take the legacy path even after the marker; an `on` unit's take the lifetime helpers); `TestDetachedContinuationsOwnTheirUnit` (import upload and `runFrom`: the handler returns, the unit stays admitted until the job's last write, exactly one `done()`).
+- [ ] **Step 1: Write the failing tests**: `TestCutoverDoorIsOperatorOnlyAndIdempotent` (second call → the status; an unreadable existing marker → 502, not a success); `TestCutoverStates` (unknown at start → 503 on every door incl. GET; a read marker absent → before; a marker written → draining at once; `on` after drain and settle; `on` is kept across a later read failure and a corrupt marker; a restart into a marker older than 60 s → draining then on 60 s after the process start; a restart into a marker 10 s old → the same); `TestDrainingRefusesEveryClaimsOffMutation` (one subtest per door → 503, nothing written; GETs answer, judged); `TestActivationWaitsForAdmittedWork` (a real creation handler paused before its fresh record-write context, and a detached import continuation paused mid-body, both admitted before the marker; the marker written; 60 s pass; the state is still draining with `Pending == 2`; each finishes → its legacy write lands under `before` rules; the count reaches zero → `on` after the settle; a creation arriving at `At + 1 s` → 503); `TestNoUnitChangesModeMidFlight` (a unit admitted in `before` observes no judge after activation would otherwise have happened; one admitted in `on` has it); `TestWorkersLoopAcrossTheCutover` (the recovery and migration passes started before the marker do nothing, then run after activation without a restart; the seed sweep's iteration admitted before the marker finishes under legacy rules and the next iteration is judged); `TestMarkerFailureAfterActivationKeepsJudgedReads` (a retired slot and an obsolete body; the marker read fails; GET and listings refuse the body); `TestCutoverCLI` (the dispatch, the client call, the two output lines, `--no-wait`); `TestSettleStartsAfterTheLastLegacyUnit` (a `before` unit whose final store call returns an ambiguous error while the server-side effect is still outstanding (a fake store that applies the delete 5 s after returning a timeout); the unit calls `done()` at `T`; the marker is 10 min old; activation happens no earlier than `T + 60 s`; the late delete lands before activation and after it no legacy write exists); `TestActivationRequiresAValidatedMarker` (absent marker; a read failure; no units; 60 s pass → still draining, not on; a successful absent read → before; a successful present read → draining then on after the settle; a restart into a 10 min old marker → draining, then on 60 s after the process start, not at once); `TestDispatchFollowsTheUnit` (a `before` unit's create, delete, detach and `readDeletedRecord` take the legacy path even after the marker; an `on` unit's take the lifetime helpers); `TestDetachedContinuationsOwnTheirUnit` (import upload and `runFrom`: the handler returns, the unit stays admitted until the job's last write, exactly one `done()`).
- [ ] **Step 2: Run to verify they fail**: `go test ./internal/console -run 'Cutover|Draining|Activation|MidFlight|WorkersLoop|MarkerFailure' -count=1`, `go test . -run 'SystemLifetimes' -count=1`.
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run to verify they pass.**
@@ -104,15 +104,15 @@ The verdict, exactly, with `owned := slot != nil && slot.Account == rec.Account
**Interfaces:**
- Tables (`account` is the sqid):
- - `host_allocations (op_id TEXT PRIMARY KEY, account TEXT, app TEXT, host TEXT, incarnation INTEGER, state TEXT, body BLOB, predecessor_etag TEXT, stale_etag TEXT, purged_at INTEGER, fenced_at INTEGER, created_at INTEGER)`; `app_purges (op_id TEXT PRIMARY KEY, account TEXT, app TEXT, state TEXT, created_at INTEGER)` with a unique index on `(account, app) WHERE state <> 'done'`; `app_purge_hosts (op_id TEXT, host TEXT, kind TEXT, incarnation INTEGER, record_etag TEXT, slot_etag TEXT, PRIMARY KEY (op_id, host))` (`purged_at` and `fenced_at` are Task 4's purge intent: `purged_at` set durably before the purge touches any slot or record, `fenced_at` once its slot fence has landed; `PendingLifetimesForApp(account, app)` lists the non-terminal rows the purge records; `PurgePendingLifetime(op_id, now)` sets `purged_at`)
+ - `host_allocations (op_id TEXT PRIMARY KEY, account TEXT, app TEXT, host TEXT, incarnation INTEGER, state TEXT, body BLOB, predecessor_etag TEXT, stale_etag TEXT, created_at INTEGER)`
- `record_retirements (op_id TEXT PRIMARY KEY, kind TEXT, account TEXT, app TEXT, host TEXT, incarnation INTEGER, record_etag TEXT, slot_etag TEXT, slot_intent TEXT, state TEXT, created_at INTEGER)` (`kind` is `normal`, `orphan` or `cleanup`)
- `record_migrations (op_id TEXT PRIMARY KEY, account TEXT, app TEXT, host TEXT, expect_etag TEXT, marked_etag TEXT, state TEXT, created_at INTEGER)`
- - Classification in `claimSQLTables`: `{Sqid: "account", App: "app"}` for all five.
+ - Classification in `claimSQLTables`: `{Sqid: "account", App: "app"}` for all three.
- States: `pending`, `committed`, `superseded`, `void`, `refused`.
- On `*Store`: `BeginAllocation(a Allocation) error`, `SetAllocationState(opID, state string) error`, `PendingAllocations() ([]Allocation, error)`, `LastIncarnation(host string) (uint64, error)` (the highest `incarnation` over every state but `void`; 0 when none); `BeginRetirement(r Retirement) error`, `SetRetirementState(opID, state string) error`, `PendingRetirements() ([]Retirement, error)`; `BeginMigration(m Migration) error`, `SetMigrationMarkedETag(opID, etag string) error`, `SetMigrationState(opID, state string) error`, `PendingMigrations() ([]Migration, error)`; `PendingForHost(host, exceptOpID string) (int, error)`.
- `type Allocation struct { OpID, Account, App, Host string; Incarnation uint64; State string; Body []byte; PredecessorETag, StaleETag string }`, `type Retirement struct { OpID, Kind, Account, App, Host string; Incarnation uint64; RecordETag, SlotETag, SlotIntent, State string }`, `type Migration struct { OpID, Account, App, Host, ExpectETag, MarkedETag, State string }`.
-- [ ] **Step 1: Write the failing tests**: `TestLifetimeTablesRoundTrip`; `TestLastIncarnationSkipsVoid` (pending 2 and void 3 → 2); `TestPendingForHostCountsAllThreeExceptOwn`; `TestPurgedAtMarksEveryPendingRow`; `TestClaimStoreOpensWithLifetimeTables`.
+- [ ] **Step 1: Write the failing tests**: `TestLifetimeTablesRoundTrip`; `TestLastIncarnationSkipsVoid` (pending 2 and void 3 → 2); `TestPendingForHostCountsAllThreeExceptOwn`; `TestClaimStoreOpensWithLifetimeTables`.
- [ ] **Step 2: Run to verify they fail.**
- [ ] **Step 3: Implement.**
- [ ] **Step 4: Run** `go test ./internal/console -run 'LifetimeTables|LastIncarnation|PendingForHost|ClaimStoreOpensWithLifetimeTables' -count=1`.
@@ -133,12 +133,12 @@ The verdict, exactly, with `owned := slot != nil && slot.Account == rec.Account
- `func (s *Server) admit(host, ownOpID string) error`: `errLifetimeSettling` when `PendingForHost(host, ownOpID) > 0`.
- `func (s *Server) classifyLocked(ctx context.Context, acct, app, host string) (provision.Observation, error)`: `s.judge.Observe(ctx, s.cfg.Releases, acct, app, host)` (Task 6: record first, slot after, bounded rounds); the error is non-nil only with `Verdict == LifetimeUnreadable`.
- `func (s *Server) createRecordWithLifetime(ctx context.Context, rec provision.Record) (*provision.Record, error)` (takes `withHostname`) and `func (s *Server) createRecordWithLifetimeLocked(ctx context.Context, rec provision.Record) (*provision.Record, error)` (assumes it): the one protocol every claims-off creation path runs; the create, seed and import doors call the locking form; the attach door calls the locked form inside its own exclusion (below). Returns the record as written. Errors: `release.ErrCASConflict` (a valid record under this key), `*hostHeldError` (`errors.As`; carries `*apexHold` for `hostHeldSentence`: a live slot owned by another key gives `&apexHold{Holder: slot.App, OtherAccount: slot.Account if ≠ rec.Account}`), `errLifetimeSettling`, `errLifetimePending`, or a read error from step 2 (nothing persisted).
- - `func (s *Server) fenceRecord(ctx context.Context, acct, app, host string, op fenceOwner) (res fenceResult, obs provision.Observation, err error)` with `type fenceOwner struct { Tombstone bool; MarkerID string }` (what this operation's own outstanding write would look like) and `fenceResult` one of `fenced` (a body that is neither this operation's tombstone nor its marker was rewritten with its sequence introduced or advanced: the caller terminates on `obs`), `ownWriteWon` (the re-read found this operation's tombstone, or a record marked with `MarkerID`: the caller does **not** terminate; it dispatches again from `obs` and the operation resumes, retirement at N2, migration through its dispatcher), `absent` (nothing to fence: the slot is the fence for an absent key), or an error after 3 CAS retries (the caller leaves its operation pending). Every recovery branch below and in Tasks 4 and 5 that would mark an operation `refused`, `void` or `superseded` because the record is not what the operation captured calls `fenceRecord` first and acts on its result. **A purged allocation** (`purged_at` set, Task 4) is classified before any other rule: whatever the slot and record say, the row is **never replayed**; recovery does not touch its slot either (the purge's slot fence owns that: a retired slot at the row's incarnation, written by CAS against what the purge recorded) and marks the row `void` once the purge has fenced it (`fenced_at` set by the purge) or immediately when the purge's generation record says the slot was already at a higher incarnation; `TestPurgedAllocationNeverReplays` pins it (a live slot and no record; a slot and a record; no slot; an outstanding slot PUT landing after the fence fails).
+ - `func (s *Server) fenceRecord(ctx context.Context, acct, app, host string, op fenceOwner) (res fenceResult, obs provision.Observation, err error)` with `type fenceOwner struct { Tombstone bool; MarkerID string }` (what this operation's own outstanding write would look like) and `fenceResult` one of `fenced` (a body that is neither this operation's tombstone nor its marker was rewritten with its sequence introduced or advanced: the caller terminates on `obs`), `ownWriteWon` (the re-read found this operation's tombstone, or a record marked with `MarkerID`: the caller does **not** terminate; it dispatches again from `obs` and the operation resumes, retirement at N2, migration through its dispatcher), `absent` (nothing to fence: the slot is the fence for an absent key), or an error after 3 CAS retries (the caller leaves its operation pending). Every recovery branch below and in Tasks 4 and 5 that would mark an operation `refused`, `void` or `superseded` because the record is not what the operation captured calls `fenceRecord` first and acts on its result. **A trashed or purged app never gets a replayed create** (Task 4's purge rule): before any replay branch, recovery checks the app (`s.store.AppLifecycle(acct, app)` says trashed or absent, or the store's app tombstone marker exists, or the app prefix no longer holds the marker because a purge finished); if so the row's slot, when it is `{N, live, this key, "create " + OpID}`, is retired as kind `orphan` (O1: the slot by CAS, then any body at N under the key deleted on its ETag), and the row becomes `void`; a slot already superseded → `void`; `TestTrashedAppAllocationNeverReplays` pins it (a live slot and no record; a slot and a record that landed late; no slot; the app purged between the allocation and the recovery; a record PUT landing after the void is deleted by the next recovery pass through the same branch, since the slot is retired and the body is stale).
- `func (s *Server) recoverLifetimes(ctx context.Context) error`, `func (s *Server) StartLifetimeRecoveryPass(ctx context.Context)` (Task 0's worker lifecycle: started once, loops every 60 s for the life of the process, each iteration admitted as a mutating unit and doing work only in `on`).
The protocol, inside `withHostname(host)`:
1. `admit(host, "")`.
-2. `classifyLocked`. Unreadable → the error. Valid record under this key → `ErrCASConflict`; the slot is untouched. A live slot owned by another key → `*hostHeldError`. Then `acct, err := s.store.AccountBySqid(rec.Account)`: an error, or `acct == nil`, is a read error (502, nothing persisted); then `s.hostHolder(ctx, acct, host, app)` under the lock (the pre-lock call at `apiinstances.go:1164` stays): a holder → `*hostHeldError`. A stale or corrupt present record under this key is remembered: `StaleETag` (its ETag, never empty for a present body).
+2. `classifyLocked`. Unreadable → the error. Valid record under this key → `ErrCASConflict`; the slot is untouched. **The orphan rule**: a live slot (this key or another) with **no record under its key** and `PendingForHost(slot's host, "") == 0` for the slot's owner key is an orphan (a purge deleted the record, or a crashed create's allocation row was lost): `retireRecordWithLifetimeLocked(ctx, slot.Account, slot.App, host, orphan, RetireExpectation{Incarnation: slot.Incarnation})` runs first (O1), and the create continues from the retired slot; a live slot owned by another key **with** a record, or with a pending operation, → `*hostHeldError`. Then `acct, err := s.store.AccountBySqid(rec.Account)`: an error, or `acct == nil`, is a read error (502, nothing persisted); then `s.hostHolder(ctx, acct, host, app)` under the lock (the pre-lock call at `apiinstances.go:1164` stays): a holder → `*hostHeldError`. A stale or corrupt present record under this key is remembered: `StaleETag` (its ETag, never empty for a present body).
3. `N = max(LastIncarnation(host), slot.Incarnation) + 1`; `rec.Incarnation = N`; `body = json.Marshal(rec)`; `BeginAllocation({OpID, Account, App, Host, N, pending, body, PredecessorETag: slot's ETag or "", StaleETag})`. A SQLite failure here → the error (nothing durable).
4. `WriteHostIncarnation({N, live, rec.Account, rec.App, "create " + OpID}, PredecessorETag)`. On `ErrCASConflict`: re-read the slot (unreadable → `pending`, `errLifetimePending`); `{N, live, this key, "create " + OpID}` → continue at 5; a slot at an incarnation > N, or at N owned by another key, or at N and this key with another intent → `superseded`, `errLifetimeSettling`; the predecessor again → `pending`, `errLifetimePending`. Any other error → `pending`, `errLifetimePending`.
5. If `StaleETag != ""`: `DeleteIfMatch` the record on it; any error (conflict included) → `pending`, `errLifetimePending`.
@@ -148,6 +148,7 @@ The protocol, inside `withHostname(host)`:
Recovery (`recoverLifetimes`), per pending allocation, inside `withHostname` with `admit(host, OpID)`, after `classifyLocked`, the first matching branch:
- unreadable → leave pending;
- slot at an incarnation > N, or at N owned by another key, or at N and this key with an intent other than `"create " + OpID` → `superseded`;
+- the app is trashed, purged or absent (the Global Constraint's rule above) → O1 on the slot when it is this allocation's, then `void`;
- slot `{N, live, this key, "create " + OpID}` and a record at N under this key → `committed`;
- slot as above and a stale or corrupt present record → step 5 with the current ETag, then 6, 7;
- slot as above and no record → step 6, 7;
@@ -176,11 +177,8 @@ Door mappings: the create door: `*hostHeldError` → 409 `hostHeldSentence(host,
### Task 4: Retirement through a tombstone, from every claims-off removal
**Files:**
-- Create: `internal/console/lifetimes_retire.go`, `internal/console/purge_lifetimes.go` (the console's lifetime-aware purge, below)
-- Modify: `internal/release/trash.go` (`purgeApp` gains `retire func(ctx context.Context, acct, app, host string) error`; when `retire != nil` every key of the instance-record shape (`provision.RecordKey`) under the app prefix is **excluded from the `s.Delete` sweep** and handed to `retire` instead, in sorted order, **before** the sweep of the remaining keys; a `retire` error aborts the purge before any sweep (the next attempt re-runs it; every step is idempotent); nil → today's unconditional sweep; `PurgeApp` and `PurgeAppIfTrashedAt` take the parameter; `release.ExpiredTrashApps(ctx, s, now) ([]AppRef, error)` is the trash scan `domains.Reconcile` runs today, extracted so the console can run it), `internal/console/ownership.go` and `internal/console/expirypass.go` (`handlePurgeApp` and `purgeOne`: for an `on` unit the purge is `s.purgeAppWithLifetimes` (below); for a `before` unit today's `PurgeApp` with nil; `StartExpiryPass` gains the store sweep: every hour, `ExpiredTrashApps` → each through the same purge, so apps no console row knows are still purged once boxes stop), `internal/domains/reconcile.go` (`Config.LifetimesOn func(ctx) (bool, error)`: the box's auto-purge at line 271 is **skipped entirely** once the box has read `lifetimes-on.json` (latched; an unreadable marker → skip this pass, log once an hour: fail closed); before that, the purge is today's with a `retire` that deletes conditionally: `Get` the record → capture its ETag → re-read the on-marker → present → return `errPurgeDeferred` (nothing deleted; the console now owns it) → absent → `DeleteIfMatch(key, captured ETag)` (a record created or migrated after the capture has another ETag and survives; a conflict → error, retried next pass). The capture precedes the marker check, so an activation between the two cannot put a numbered record under the delete)
-- **The console's purge** `func (s *Server) purgeAppWithLifetimes(ctx, acct, app string, now time.Time) error`, inside `release.WithAppMutation(ctx, s, acct, app, "purge", ...)` (the lock `TrashApp` uses, so two console purges of one app never interleave): (0) **the generation record**: list the app's instance keys and `s.store.PendingLifetimesForApp(acct, app)`; for every host observe once (judged) and persist `app_purge_hosts(op_id, account, app, host, incarnation INTEGER, record_etag TEXT, slot_etag TEXT, kind TEXT)` with `kind` `record` (a present record: its incarnation and ETag) or `allocation` (a pending row: its incarnation and the slot's ETag or `""` for absence) under one `app_purges(op_id, account, app, state, created_at)` row, **before any destructive step**; a re-run of the purge reuses the existing `app_purges` row for `(account, app)` and never re-observes (the generation is bound once); (1) **pending allocations**: for each `allocation` row: `PurgePendingLifetime(op_id)` sets `purged_at` (recovery treats it as `void`: no replay, ever); then **the slot fence**: `WriteHostIncarnation(host, {incarnation, retired}, against the recorded slot ETag, or create-only when it recorded absence)` so the allocation's own outstanding slot PUT (conditioned on absence or the predecessor) can never land; a CAS conflict → re-observe: a retired slot at that incarnation → done; a live slot at that incarnation → retire it by CAS; a slot at a **higher** incarnation → this allocation is already superseded, done; then the row is terminal `void`; (2) **records**: for each `record` row, under `withHostname(host)`: `retireRecordWithLifetimeLocked(ctx, acct, app, host, normal, RetireExpectation{Incarnation: recorded})` (the locked variant: this function already holds the exclusion); a record now at another incarnation or absent → the expectation refuses → done (a successor is never touched); (3) `release.PurgeAppIfTrashedAt(..., retire: the no-op marker)` with instance keys excluded (the records are gone or refused by then); (4) `app_purges.state = done`. `admit(host, "")` treats a pending row with `purged_at` as not blocking (the purge owns it). The hostname is reusable by any app at incarnation N+1 as after any retirement.
-- **Why no box retirement exists:** a box cannot see the console's pending allocations, so any box purge after activation could resurrect or strand one; after the on-marker the box's trash sweep is the console's job. Before the marker no slot exists, so the conditional delete above is today's behaviour minus the race with activation.
-- **Witness of the purge rules:** a console purge interrupted after k steps resumes from its generation record and completes; a console callback delayed past a recreation of the same app and host at N+1 (another purge is impossible under the app lock, so the recreation is the operator's trash-restore-recreate) refuses on its recorded incarnation; a pending allocation with a live slot and no record → `purged_at`, the slot retired by CAS, the row `void`, recovery never replays; a pending allocation whose slot PUT is still outstanding (a fake store that applies it 5 s after an ambiguous error) → the fence lands first and the late PUT fails its precondition, or the PUT landed first and the fence's CAS conflict re-observes and retires it: either way a retired slot and no record; one interrupted before its slot → `void`; a box that has seen the on-marker purges nothing (the trash marker stays; the console's hourly sweep purges it); a pre-on box purge whose captured record the console migrated after the capture fails its CAS and leaves it; a pre-on box purge that sees the marker appear between capture and delete deletes nothing; the hostname is reusable by another app at incarnation N+1,
+- Create: `internal/console/lifetimes_retire.go`
+- **App purge is today's sweep, made conditional for instance records, and the lifetime protocol absorbs it.** `internal/release/trash.go`: `purgeApp`, for every key of the instance-record shape (`provision.RecordKey`) under the app prefix, does `Get` → `DeleteIfMatch(key, etag)` instead of `Delete` (absent → fine; a conflict → the purge returns an error before deleting anything else and the next attempt re-runs: a record that was migrated, recreated or written after the capture survives; every other key is swept as today; the marker last, as today). No retire hook, no generation, no slot writes, no marker reads: the console's `handlePurgeApp` and `purgeOne` keep their existing `s.lockApp` and call it as today; the box's `domains.Reconcile` auto-purge is unchanged apart from the conditional deletes and keeps running after the cutover (it mutates no slot). **What a purge leaves behind, and who cleans it:** (a) a live slot whose record was deleted: the **orphan rule** (Task 3 step 2) retires it as kind `orphan` on the next create of that hostname by any app, so a purged app never blocks a hostname; until then consumers judge the host absent (§6.4); (b) a pending allocation of the purged app: recovery's **trashed-app rule** (Task 3) voids it and retires its slot and anything at its incarnation as `orphan`, never replaying; a slot or record PUT of that allocation landing later is a stale body under a retired slot (§6.4) and the next recovery pass through the same branch deletes it; (c) a record deleted under a live slot while a console retirement or migration of it is in flight: those operations observe the absence on their next step and finish by their own tables (absence is `committed` for a retirement; a migration voids). The pre-existing two-purger race on `main` (a box's sweep deleting a recreated app's configuration keys from a stale listing) is not widened: instance records are now conditional, every other key behaves as on `main`.
- Modify: `internal/console/hostclaimops.go` (claims-off `ref.Family == "instance"` → `retireRecordWithLifetime(ctx, ref.Account, ref.App, ref.Host, ref.Expect)`; `provision.RetireRef` gains `Expect retireExpectation`-shaped fields through a new `provision.RetireExpectation{Absent bool; ETag string; Incarnation uint64}` so each caller states its own), `internal/console/claim_retire.go` (`retireInstanceRecord(ctx, account, app, host, by, incarnation string, expect provision.RetireExpectation)`; its claims-off callers: `apiinstances.go:1684` (the sibling undo) with `{Incarnation: created.Incarnation}` through the locking `retireRecordWithLifetime`; **detach does not go through it on the claims-off path**: `apidomains.go:989` and `domainattach.go:349` call Task 3's `detachFirstRouteLocked`, which calls `retireRecordWithLifetimeLocked(..., retireAny)` inside the exclusion it already holds; the claims-on detach keeps today's `retireInstanceRecord` chain; `domainattach.go:557` (`rollbackAttach`) never retires on the claims-off path; `apiinstances.go:2245` is not reached on claims-off: `importDeleteRule` answers every claims-off delete), `internal/console/apiinstanceimport.go` (`readDeletedRecord` returns `(provision.Observation, error)` through `s.judge.Observe`; `importDeleteRule`: `!obs.Present` → `retireRecordWithLifetime(..., retireExpectAbsent)`; `obs.Verdict` stale or corrupt → `deleteRead(..., retireExpectETag(obs.ETag))`; valid → the import logic as today, and its ordinary delete `deleteRead(..., retireExpectETag(obs.ETag))`; `deleteRead` passes the expectation through: `ErrCASConflict` keeps the `again` re-decision; `errLifetimeSettling` → 409 settling; `errRetireRefused` → 409 its sentence), `internal/console/seedfinish.go` (`finishSeedOnce`'s final delete → `retireRecordWithLifetime(ctx, sqid, app, host, retireExpectETag(etag))`), `internal/console/apiinstances.go` (`handleAPIInstanceDelete`: `errRetireRefused`, `errLifetimeSettling` → 409), `internal/console/domainattach.go` and `internal/console/apidomains.go` (detach: `errLifetimeSettling` → 409 `claimDetachInProgressMsg`; `errRetireRefused` → 409 its sentence), `internal/console/lifetimes_recovery.go`
- Test: `internal/console/lifetimes_retire_test.go`
@@ -210,7 +208,7 @@ Inside `withHostname(host)`: `admit(host, "")`; `obs := classifyLocked` (unreada
- kind **orphan**, `pred`: absent or any present body → O1 (the slot, then the body if present), `committed`.
- kind **cleanup**, `pred`: any observation (absent, corrupt, stale, or a valid body at N: the DELETE this operation answers was for this lifetime, and under the retired slot every body is stale) → C1 then C2 on the current body's ETag when present, `committed`.
-- [ ] **Step 1: Write the failing tests** through the real doors and the function: `TestConsolePurgeWithLifetimes` (browser purge and the expiry pass, incl. the hourly store sweep for an app with no console row: every record under the app ends **absent with a retired slot** (the tombstone is transient and gone at the end), no instance key is in the sweep; the generation record is written before the first destructive step and reused on a re-run; interruption after k steps resumes; a callback delayed past a recreation at N+1 refuses; a pending allocation with a live slot and no record → `purged_at`, retired slot, `void`, no replay; an outstanding slot PUT loses to the fence or is retired after it; one interrupted before its slot → `void`; a `before` unit's purge is today's sweep); `TestBoxPurgeStopsAtTheOnMarker` (a box that has read `lifetimes-on.json` leaves every trashed app alone; an unreadable marker → skip and log; before the marker: a captured record migrated by the console after the capture survives the conditional delete; the marker appearing between capture and delete → nothing deleted; an untouched legacy record is deleted as today);
+- [ ] **Step 1: Write the failing tests** through the real doors and the function: `TestPurgeIsTodaysSweepAndLifetimesAbsorbIt` (browser purge, the expiry pass and the box auto-purge, before and after the cutover: instance records are deleted by `DeleteIfMatch` and every other key as on `main`; a record migrated or recreated after the capture survives and the purge retries; **the orphan rule**: after a purge the hostname's live slot is retired as `orphan` by the next create from another app, which then allocates N+1; **the trashed-app rule**: a pending allocation of the purged app with a live slot and no record is voided and its slot retired, never replayed; its record PUT landing after the void is a stale body under a retired slot and the next recovery pass deletes it; a console retirement in flight when the purge deleted the record finishes as `committed`; a migration in flight voids);
- `TestDeleteDoorRetiresThroughTheTombstone`; `TestSlotRetiresBeforeTheKeyIsAbsent`.
- `TestDeleteDoorAbsentPathAdmitsAndRetiresOrphans` (initial absence; absence found on the judge's re-read; a record created since is untouched and the door still answers "deleted").
- `TestStaleCleanupRetiresALiveOwnedSlotFirst` (a fieldless late body beside `{1, live, this key}`: the slot is `{1, retired}` before the body is removed; a blocked incarnation-1 create-only write released afterwards lands stale; the same for a corrupt body).
@@ -360,6 +358,6 @@ These are §11's witnesses plan 1 owns. Every witness that needs a holding publi
## Self-review notes
- §6.4 coverage: slot, owner key and tombstone form (T1, T4), create and recovery (T3), every claims-off removal with an explicit expectation and its recovery (T4), migration, its fence and recovery (T5), the observation protocol in every claims-off consumer (T1, T6), `move_seq`, the setters that install a lifetime with its hold and the existing-row rules (T7), publication by the lifetimes writer and §6.4's fold order (T8), witnesses (T9). Deferred by name: holding entries and their five-minute validity, the probe mode, the different-target alias rule and every holding-dependent witness (plan 3); the door, token, job, reservations and reports (plan 2); cleanup (plan 4). The stated additions to §6.4's text are the uncached slot read and the cutover (an operator step with an admission gate, a rollout prerequisite); the fold rule is §6.4's own.
-- Names used across tasks: `LifetimeOf`, `LifetimeVerdict`, `HostIncarnation.Valid`, `ErrSlotMalformed`, `ErrRecordCorrupt`, `ReadHostIncarnation`, `WriteHostIncarnation`, `TombstoneBody`, `GetRecordRaw`, `Observation{Present, Rec, Raw, ETag, Slot, SlotETag, Verdict, Err}`, `LifetimeJudge.Observe/ObserveWith`, `ReadRecordJudged`, `List*Judged`, `withHostname`, `admit`, `classifyLocked`, `createRecordWithLifetime`, `retireRecordWithLifetime`, `provision.RetireExpectation` (`retireExpectAbsent`, `retireAny`, `retireExpectETag`, `retireExpectIncarnation`), `Retirement.Kind` (`normal`, `orphan`, `cleanup`), `WithJudge`, `JudgeFrom`, `ErrLifetimeExhausted`, `fenceRecord`/`fenceResult`/`fenceOwner`, `purgeApp(..., retire)`, `release.ExpiredTrashApps`, `(*console.Server).purgeAppWithLifetimes`, `app_purges`/`app_purge_hosts`, `PendingLifetimesForApp`, `PurgePendingLifetime`, `purged_at`/`fenced_at`, `errPurgeDeferred`, `lifetimesOnKey`, `domains.Config.LifetimesOn`, `quarantineCopy`, `ConvertRetirementKind`, `cutoverMarker`, `cutoverState` (`cutoverUnknown/Before/Draining/On`), `cutoverGate` (`markerSeen`, `lastDone`), `admittedUnit`, `lifetimesAdmit`, `recordCtx`, `activateWhenDrained`, `cutoverStatus`, `StartLifetimesCutoverWatch`, `handleAPILifetimesCutover`, `SystemLifetimesCutover`, `retireSlotLocked`, `slotDisposition`, `migrationDispatch`, `WithLifetimes`, `WithRecordSequence` (existing), `createRecordWithLifetimeLocked`, `retireRecordWithLifetimeLocked`, `attachFirstRouteLocked`, `rollbackAttachLocked`, `detachFirstRouteLocked`, `lifetimeSettle`, `migrateHostLifetimeLocked`, `errLifetimeSettling`, `errLifetimePending`, `*hostHeldError`, `errRetireRefused`, `ErrRecordMigrating`, `WithMigration`, `ReconcileConfig.Judge/PairOf/DeleteIfHold`, `moveCfg.Judge/ReleaseHoldSeq`, `releaseHeldSeq`, `registry.Pair`, `SetHoldIfSeq`, `ReleaseHoldSeq`, `InstallLifetimeIf`, `Route.Incarnation/ServingSeq`, `MoveState.Seq`, `edgeHost.Incarnation/ServingSeq/TargetHost`.
+- Names used across tasks: `LifetimeOf`, `LifetimeVerdict`, `HostIncarnation.Valid`, `ErrSlotMalformed`, `ErrRecordCorrupt`, `ReadHostIncarnation`, `WriteHostIncarnation`, `TombstoneBody`, `GetRecordRaw`, `Observation{Present, Rec, Raw, ETag, Slot, SlotETag, Verdict, Err}`, `LifetimeJudge.Observe/ObserveWith`, `ReadRecordJudged`, `List*Judged`, `withHostname`, `admit`, `classifyLocked`, `createRecordWithLifetime`, `retireRecordWithLifetime`, `provision.RetireExpectation` (`retireExpectAbsent`, `retireAny`, `retireExpectETag`, `retireExpectIncarnation`), `Retirement.Kind` (`normal`, `orphan`, `cleanup`), `WithJudge`, `JudgeFrom`, `ErrLifetimeExhausted`, `fenceRecord`/`fenceResult`/`fenceOwner`, `AppLifecycle`, the orphan rule, the trashed-app rule, `quarantineCopy`, `ConvertRetirementKind`, `cutoverMarker`, `cutoverState` (`cutoverUnknown/Before/Draining/On`), `cutoverGate` (`markerSeen`, `lastDone`), `admittedUnit`, `lifetimesAdmit`, `recordCtx`, `activateWhenDrained`, `cutoverStatus`, `StartLifetimesCutoverWatch`, `handleAPILifetimesCutover`, `SystemLifetimesCutover`, `retireSlotLocked`, `slotDisposition`, `migrationDispatch`, `WithLifetimes`, `WithRecordSequence` (existing), `createRecordWithLifetimeLocked`, `retireRecordWithLifetimeLocked`, `attachFirstRouteLocked`, `rollbackAttachLocked`, `detachFirstRouteLocked`, `lifetimeSettle`, `migrateHostLifetimeLocked`, `errLifetimeSettling`, `errLifetimePending`, `*hostHeldError`, `errRetireRefused`, `ErrRecordMigrating`, `WithMigration`, `ReconcileConfig.Judge/PairOf/DeleteIfHold`, `moveCfg.Judge/ReleaseHoldSeq`, `releaseHeldSeq`, `registry.Pair`, `SetHoldIfSeq`, `ReleaseHoldSeq`, `InstallLifetimeIf`, `Route.Incarnation/ServingSeq`, `MoveState.Seq`, `edgeHost.Incarnation/ServingSeq/TargetHost`.
- Review Focus 1 to 5 are pinned by T3+T5, T6, T6+T3, T7, T7+T9.
- Store primitives, as verified in the reviews: `Store.Put(..., "")` is create-only on both backends; `WriteRecord` with `before={}` and `etag=""` is the create and needs the real CAS base otherwise; `DeleteIfMatch` treats absence as success and has no unconditional fallback; `OfflineAbort` on a reverting record returns `errRecordUnchanged`, a success without a write; `AccountBySqid` returns `(nil, nil)` for an unknown sqid.