carlos / platform Public

plan: self-serve moves 1 rev 21 (Astra review 20 READY WITH CHANGES: status-based liveness for the capability check, the Task 0 purge sentence removed) and 2 rev 13 (Astra review 12: request deadlines only for abort-expiry writes, abandonment rolls the deadline, maintenance idle has no step deadline)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 194a6a4f27080dc0b55ec041af4b052b86856a58 parent 435c894
2 files changed, +9 −9
  • docs/superpowers/plans/2026-10-22-self-serve-moves-1-lifetimes-and-order.md +3 −3
  • docs/superpowers/plans/2026-10-22-self-serve-moves-2-door-token-job.md +6 −6

CI passed — run details

CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (0 s)
ok  	amadan.net/carlos/platform.git/launchctx	0.013s
?   	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 (138 s)
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.583s
ok  	github.com/carlosframework/platform/cmd/moveprobe	11.966s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.766s
ok  	github.com/carlosframework/platform/internal/activator	58.354s
ok  	github.com/carlosframework/platform/internal/agentsock	0.030s
ok  	github.com/carlosframework/platform/internal/alerting	0.004s
ok  	github.com/carlosframework/platform/internal/analytics	0.027s
ok  	github.com/carlosframework/platform/internal/appconfig	2.965s
ok  	github.com/carlosframework/platform/internal/apperr	0.108s
ok  	github.com/carlosframework/platform/internal/applog	0.212s
ok  	github.com/carlosframework/platform/internal/awsinv	0.006s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.005s
ok  	github.com/carlosframework/platform/internal/boxid	0.937s
?   	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.008s
ok  	github.com/carlosframework/platform/internal/caps	0.013s
ok  	github.com/carlosframework/platform/internal/certwarm	0.007s
ok  	github.com/carlosframework/platform/internal/cgroup	0.783s
ok  	github.com/carlosframework/platform/internal/changelog	0.550s
ok  	github.com/carlosframework/platform/internal/claimactivation	1.260s
ok  	github.com/carlosframework/platform/internal/clamd	0.062s
?   	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.741s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.012s
ok  	github.com/carlosframework/platform/internal/dbverify	0.009s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.003s
ok  	github.com/carlosframework/platform/internal/docsite	0.010s
ok  	github.com/carlosframework/platform/internal/domains	2.934s
ok  	github.com/carlosframework/platform/internal/doorkey	0.012s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	45.246s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.011s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.203s
ok  	github.com/carlosframework/platform/internal/gate	1.879s
ok  	github.com/carlosframework/platform/internal/genselect	0.040s
ok  	github.com/carlosframework/platform/internal/geo	0.015s
ok  	github.com/carlosframework/platform/internal/geoip	0.042s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.298s
ok  	github.com/carlosframework/platform/internal/host	134.209s
ok  	github.com/carlosframework/platform/internal/hostactors	5.953s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.005s
ok  	github.com/carlosframework/platform/internal/hostbox	3.047s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.749s
ok  	github.com/carlosframework/platform/internal/hostenv	0.007s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.008s
ok  	github.com/carlosframework/platform/internal/hostexec	5.429s
ok  	github.com/carlosframework/platform/internal/hostfence	0.044s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.220s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.032s
ok  	github.com/carlosframework/platform/internal/hostguard	1.051s
ok  	github.com/carlosframework/platform/internal/hostops	2.177s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.006s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.007s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.583s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.227s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.008s
ok  	github.com/carlosframework/platform/internal/infraview	0.003s
ok  	github.com/carlosframework/platform/internal/instancectl	5.235s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.796s
ok  	github.com/carlosframework/platform/internal/instancefs	0.009s
ok  	github.com/carlosframework/platform/internal/instancekey	0.005s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.008s
ok  	github.com/carlosframework/platform/internal/layout	1.897s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.004s
ok  	github.com/carlosframework/platform/internal/lease	10.143s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.511s
ok  	github.com/carlosframework/platform/internal/ledger	0.029s
ok  	github.com/carlosframework/platform/internal/mail	0.263s
ok  	github.com/carlosframework/platform/internal/maintenance	0.107s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.625s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.109s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	21.260s
ok  	github.com/carlosframework/platform/internal/moves/archive	1.080s
ok  	github.com/carlosframework/platform/internal/objstore	0.282s
ok  	github.com/carlosframework/platform/internal/ops	0.174s
ok  	github.com/carlosframework/platform/internal/park	0.271s
ok  	github.com/carlosframework/platform/internal/plans	0.006s
ok  	github.com/carlosframework/platform/internal/privatefile	0.003s
ok  	github.com/carlosframework/platform/internal/provision	13.074s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.007s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.601s
ok  	github.com/carlosframework/platform/internal/relay	7.233s
ok  	github.com/carlosframework/platform/internal/release	24.944s
ok  	github.com/carlosframework/platform/internal/rollout	3.786s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.008s
ok  	github.com/carlosframework/platform/internal/rootaws	0.009s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.062s
ok  	github.com/carlosframework/platform/internal/rootstate	0.028s
ok  	github.com/carlosframework/platform/internal/runs	0.541s
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.715s
ok  	github.com/carlosframework/platform/internal/seeds	0.014s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	10.453s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.792s
ok  	github.com/carlosframework/platform/internal/status	0.074s
ok  	github.com/carlosframework/platform/internal/steering	0.009s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.156s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.006s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.005s
ok  	github.com/carlosframework/platform/internal/updatewords	0.009s
ok  	github.com/carlosframework/platform/internal/usage	0.005s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.648s
=== internal_console@0 (78 s)
ok  	github.com/carlosframework/platform/internal/console	75.647s
=== internal_console@1 (37 s)
ok  	github.com/carlosframework/platform/internal/console	34.899s
=== internal_console@2 (42 s)
ok  	github.com/carlosframework/platform/internal/console	39.468s
=== internal_console@3 (38 s)
ok  	github.com/carlosframework/platform/internal/console	35.402s
=== internal_console@4 (37 s)
ok  	github.com/carlosframework/platform/internal/console	34.749s
=== internal_console@5 (35 s)
ok  	github.com/carlosframework/platform/internal/console	32.802s
=== internal_console@6 (36 s)
ok  	github.com/carlosframework/platform/internal/console	34.211s
=== internal_console@7 (43 s)
ok  	github.com/carlosframework/platform/internal/console	41.021s
=== root@0 (32 s)
ok  	github.com/carlosframework/platform	30.024s
=== root@1 (44 s)
ok  	github.com/carlosframework/platform	42.084s
=== root@2 (41 s)
ok  	github.com/carlosframework/platform	38.953s
=== 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.03s)
    --- 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.02s)
        --- 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.033s
=== RUN   TestT4StorageProbeMinio
2026/10/22 10:59:08 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E0D4E9041D46B5, 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.029s
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 87a7d2670..3f484b4d1 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 20, 2026-10-22. Rev 19's review (`selfserve-plan1-review19.md`: 18, 20, 25 closed; 23, 26, 27, 28 open) is answered: the box floor is a **capability in the edges file** (`lifetimes-1`, the inventory the console already reads for every fleet decision), the cutover door refuses while any edges file lacks it or is stale, and a box whose file later loses it pages "box <label> regressed below the lifetimes floor" (23); **the return of an older box binary after the cutover, and retention's prune-versus-recreate race, are declared out of scope**: neither is introduced or widened by this plan, both are today's exposure on `main`, and a fleet-wide binary floor is its own piece of work, filed as an amadan discussion (23, 26); restore evidence keeps today's value for every ordinary app object and applies the three-valued lifetime judgement to instance-record keys alone (27); the cancellation plumbing is declared (`record_retirements.cancels`, `Retirement.Cancels`, `BeginCancellingRetirement`) (28); the surviving statements about boxes purging before the cutover are removed. Rev 18's review (`selfserve-plan1-review18.md`: 5, 19, 22, 24 closed; 18, 20 declarations; 23, 25 P1) is answered: **claims-off boxes no longer auto-purge at all** from this release (a check-then-delete can always pause between the two; no fence is possible on a box), the console's expiry pass owns the store trash sweep, and the cutover door **refuses while any box of the fleet reports a version older than this release or no fresh status**, so by the time a marker can exist no box purge code is running (23); the materialisation worker takes `s.lockApp` per app **before** collecting evidence, re-reads the row and the marker under it and holds it through the SQLite transition, candidates being hints only (25); the lifetime the door read travels to the create helpers as an argument, the schema, the `Allocation` struct, the real `AppLife(accountID, app)` and `release.ReadAppTombstone` names, and the SQL audit's patterns are reconciled (18); the lock table no longer lists `retireSlotOrphanLocked` as assuming the app lock (20). Rev 17's review (`selfserve-plan1-review17.md`: 10, 17, 21 closed; 5, 18, 19, 20, 22 narrowed; 23, 24 new) is answered: a box purge is fenced at **every** destructive step (`purgeApp` gains a `before func(ctx) error` hook the box fills with the marker re-read and the 30 s clock, called before each `Delete`, the final marker delete included) (23); the app lifetime is minted by **one insertion helper** every app-row insert uses (`CreateAppInRegion`, `CreateTemporaryAppInRegion`, `BackfillOpsMemberships`, the migration's `apps_new` copy, the open-time backfill), pinned by a test that scans every `INSERT INTO apps`, and the declarations agree (`TEXT`, `Allocation.AppLifetime`, the recovery wording) (18); the create's step 0 also requires a successful read of the store's trash marker as absent (5); the cross-key stale-body cleanup is **best effort within the running create** and the promise is narrowed to that (19); the initial migration pass takes app → hostname and the cross-owner orphan retirement needs only the hostname lock and its captured slot ETag, stated with its reason (20); startup materialisation runs as an admitted worker iteration, never before the gate has a state (22); restore evidence has an explicit **unknown** outcome that defers the decision (24). Rev 16's review (`selfserve-plan1-review16.md`: 5, 10, 17, 18, 19 narrowed; 20, 21, 22 new) is answered: the create protocol checks the app's lifecycle **under the app lock** (present, not trashed, the same `lifetime` the door read) before allocating (5); a box purge re-reads the cutover marker **immediately before every conditional delete** and abandons the purge when more than 30 s passed since that check, so a sweep paused across the cutover deletes nothing numbered (10); cancellation writes the orphan retirement row and the allocation's `void` in **one SQLite transaction** and the retirement names the allocation it cancels, so no two pending rows block each other (17); the app lifetime is a non-repeating `apps.lifetime` id captured by `BeginAllocation` (18); the stale-body promise is narrowed to the same key and the create's step 5 also deletes a stale body under the retired slot's owner key (19); a **lock table** states what every wrapper acquires and every `Locked` helper assumes, and the attach door takes the app lock outside its hostname exclusion (20); the purge's absent-record branch acts only on a slot **owned by the purged app** (21); `settleApp` and `MaterializeApps` count only judged-valid instance records as restore evidence (22). Rev 15's review (`selfserve-plan1-review15.md`: 13, 14, 15, 16 closed; 5, 10, 17, 18, 19 open) is answered with **one lock order** instead of a snapshot: the create protocol, allocation recovery and the console's purge all take `s.lockApp(acct, app)` and then `withHostname(host)`, so a purge and a create or recovery of the same app never interleave, and the purge observes and retires each instance **inside** the same locked step (no delayed callback can see a successor; a legacy record is retired by **ETag**); an allocation is bound to the **app lifetime** that authorised it (`app_lifetime` = the apps row's `created_at`), so a reused name never replays an old allocation; **boxes stop auto-purging once the cutover marker exists** and the console's expiry pass runs the trash sweep under the app lock, so after the cutover the only purger is the one that can see pending allocations; a slot-bound orphan entry point (`retireSlotOrphanLocked`) exists and the void path's cleanup is stated honestly (a late stale body stays until the next create of the hostname removes it in step 5; a late slot PUT leaves a live orphan the orphan rule reclaims). 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.
+Rev 21, 2026-10-22. Rev 20's review (`selfserve-plan1-review20.md`) was **READY WITH CHANGES**: the two changes are made: the cutover door's box check takes the capability from the edges file and **liveness from the box's status report** (`status/boxes/<label>.json` within 10 minutes, `Metrics.BoxID` equal to the file's box), never from the edges file's age, because an unchanged edges file is not rewritten; and Task 0's surviving sentence about boxes purging until they read the marker is removed (boxes purge nothing from this release on). Rev 19's review (`selfserve-plan1-review19.md`: 18, 20, 25 closed; 23, 26, 27, 28 open) is answered: the box floor is a **capability in the edges file** (`lifetimes-1`, the inventory the console already reads for every fleet decision), the cutover door refuses while any edges file lacks it or is stale, and a box whose file later loses it pages "box <label> regressed below the lifetimes floor" (23); **the return of an older box binary after the cutover, and retention's prune-versus-recreate race, are declared out of scope**: neither is introduced or widened by this plan, both are today's exposure on `main`, and a fleet-wide binary floor is its own piece of work, filed as an amadan discussion (23, 26); restore evidence keeps today's value for every ordinary app object and applies the three-valued lifetime judgement to instance-record keys alone (27); the cancellation plumbing is declared (`record_retirements.cancels`, `Retirement.Cancels`, `BeginCancellingRetirement`) (28); the surviving statements about boxes purging before the cutover are removed. Rev 18's review (`selfserve-plan1-review18.md`: 5, 19, 22, 24 closed; 18, 20 declarations; 23, 25 P1) is answered: **claims-off boxes no longer auto-purge at all** from this release (a check-then-delete can always pause between the two; no fence is possible on a box), the console's expiry pass owns the store trash sweep, and the cutover door **refuses while any box of the fleet reports a version older than this release or no fresh status**, so by the time a marker can exist no box purge code is running (23); the materialisation worker takes `s.lockApp` per app **before** collecting evidence, re-reads the row and the marker under it and holds it through the SQLite transition, candidates being hints only (25); the lifetime the door read travels to the create helpers as an argument, the schema, the `Allocation` struct, the real `AppLife(accountID, app)` and `release.ReadAppTombstone` names, and the SQL audit's patterns are reconciled (18); the lock table no longer lists `retireSlotOrphanLocked` as assuming the app lock (20). Rev 17's review (`selfserve-plan1-review17.md`: 10, 17, 21 closed; 5, 18, 19, 20, 22 narrowed; 23, 24 new) is answered: a box purge is fenced at **every** destructive step (`purgeApp` gains a `before func(ctx) error` hook the box fills with the marker re-read and the 30 s clock, called before each `Delete`, the final marker delete included) (23); the app lifetime is minted by **one insertion helper** every app-row insert uses (`CreateAppInRegion`, `CreateTemporaryAppInRegion`, `BackfillOpsMemberships`, the migration's `apps_new` copy, the open-time backfill), pinned by a test that scans every `INSERT INTO apps`, and the declarations agree (`TEXT`, `Allocation.AppLifetime`, the recovery wording) (18); the create's step 0 also requires a successful read of the store's trash marker as absent (5); the cross-key stale-body cleanup is **best effort within the running create** and the promise is narrowed to that (19); the initial migration pass takes app → hostname and the cross-owner orphan retirement needs only the hostname lock and its captured slot ETag, stated with its reason (20); startup materialisation runs as an admitted worker iteration, never before the gate has a state (22); restore evidence has an explicit **unknown** outcome that defers the decision (24). Rev 16's review (`selfserve-plan1-review16.md`: 5, 10, 17, 18, 19 narrowed; 20, 21, 22 new) is answered: the create protocol checks the app's lifecycle **under the app lock** (present, not trashed, the same `lifetime` the door read) before allocating (5); a box purge re-reads the cutover marker **immediately before every conditional delete** and abandons the purge when more than 30 s passed since that check, so a sweep paused across the cutover deletes nothing numbered (10); cancellation writes the orphan retirement row and the allocation's `void` in **one SQLite transaction** and the retirement names the allocation it cancels, so no two pending rows block each other (17); the app lifetime is a non-repeating `apps.lifetime` id captured by `BeginAllocation` (18); the stale-body promise is narrowed to the same key and the create's step 5 also deletes a stale body under the retired slot's owner key (19); a **lock table** states what every wrapper acquires and every `Locked` helper assumes, and the attach door takes the app lock outside its hostname exclusion (20); the purge's absent-record branch acts only on a slot **owned by the purged app** (21); `settleApp` and `MaterializeApps` count only judged-valid instance records as restore evidence (22). Rev 15's review (`selfserve-plan1-review15.md`: 13, 14, 15, 16 closed; 5, 10, 17, 18, 19 open) is answered with **one lock order** instead of a snapshot: the create protocol, allocation recovery and the console's purge all take `s.lockApp(acct, app)` and then `withHostname(host)`, so a purge and a create or recovery of the same app never interleave, and the purge observes and retires each instance **inside** the same locked step (no delayed callback can see a successor; a legacy record is retired by **ETag**); an allocation is bound to the **app lifetime** that authorised it (`app_lifetime` = the apps row's `created_at`), so a reused name never replays an old allocation; **boxes stop auto-purging once the cutover marker exists** and the console's expiry pass runs the trash sweep under the app lock, so after the cutover the only purger is the one that can see pending allocations; a slot-bound orphan entry point (`retireSlotOrphanLocked`) exists and the void path's cleanup is stated honestly (a late stale body stays until the next create of the hostname removes it in step 5; a late slot PUT leaves a live orphan the orphan rule reclaims). 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.
@@ -51,7 +51,7 @@ Rev 20, 2026-10-22. Rev 19's review (`selfserve-plan1-review19.md`: 18, 20, 25 c
**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); Boxes perform no slot mutation of their own on any path (Task 4), so the console's cutover is the only cutover; **boxes stop auto-purging** once they have read the cutover marker (latched; Task 4). `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 and a claims-off box auto-purges nothing from this release on (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).
@@ -179,7 +179,7 @@ Door mappings: the create door: `*hostHeldError` → 409 `hostHeldSentence(host,
**Files:**
- Create: `internal/console/lifetimes_retire.go`
-- **App purge.** Two regimes, split by the cutover marker. **A claims-off box auto-purges nothing from this release on**: `domains.Reconcile` 1d is removed for `!cfg.HostClaim` (the `HostClaim` branch's `RequestClaimAutoPurge` is unchanged); a box's check-then-delete can always pause between the check and the delete, so no marker or clock on a box can fence a sweep against a successor, and the only safe fence is for the code not to run. The console's expiry pass owns the store-driven trash sweep (below). Because boxes roll before the console (the roll order rule), the sweep pauses between the box roll and the console roll (a purge is a three-day-cooloff event; the pause is bounded by the roll); **the box floor is an edges-file capability**: a box running this release publishes `lifetimes-1` in its edges file's `capabilities` (`edgesFileCapabilities`, beside `offline-stage-1` and `region-ha-1`); the cutover door reads every edges file of the deployment (`readOfflineFleet`'s inventory, the same files every fleet decision uses; a box with no edges file is not a box of the fleet) and refuses (409 "box <label> does not publish lifetimes-1; every box must run this release before the cutover") while any file lacks the capability or is older than two edges-file refresh intervals; a decommissioned box's file is removed by the operator as today. After the cutover the console's edges-file reader pages once per hour "box <label> regressed below the lifetimes floor" when a file without the capability appears. **Declared out of scope, with the reason**: an older box binary that starts or returns after the cutover (a rollback, a cloned image) is not excluded by this plan: its legacy purge and retention deletes are exactly today's exposure on `main` (two uncoordinated deleters of app objects), this plan neither introduces nor widens it, and the enforced fleet-wide binary floor it would need (a joining gate for claims-off boxes) is its own piece of work, filed as an amadan discussion on the branch; the operator rule (c) already forbids reintroducing an older console, and the same rule is stated for boxes in the rollout note. **The console** (`handlePurgeApp`, `purgeOne`, and a new hourly `release.ExpiredTrashApps(ctx, s, now)` sweep in `StartExpiryPass` that runs whether or not the pass has rows, so headless apps are purged once boxes stop; `HostClaim` deployments keep `RequestClaimAutoPurge` unchanged) purges through `s.purgeAppWithLifetimes(ctx, acct, app, want deletedAt, now)`, every caller already holding or taking **`s.lockApp(acct, app)`**: (1) **the guards first**: `release.PurgeAppGuards(ctx, s, pub, acct, app, want, now)` (the marker, its clock, the production flag and the store-estate guard exactly as `PurgeAppIfTrashedAt` applies them today, factored out so they run before any lifetime step; a refusal ends the purge with today's sentence); (2) **per instance key** under the app prefix, and per pending allocation of the app (`PendingLifetimesForApp(acct, app)`), under `withHostname(host)`: observe (`classifyLocked`); a pending allocation of this app → the lifetime check of Task 3 fails by construction (the app is trashed) → `retireSlotOrphanLocked` when the slot is the allocation's, `void`; a present record → `retireRecordWithLifetimeLocked(ctx, acct, app, host, normal, RetireExpectation{Incarnation: obs.Rec.Incarnation})` when numbered, `RetireExpectation{ETag: obs.ETag}` when legacy (never `retireAny`; the observation and the retirement are one locked step, so nothing can be observed stale); absent with a live slot **owned by this app** (`slot.Account == acct && slot.App == app`) → `retireSlotOrphanLocked`; absent with a live slot owned by **another** key → nothing (a foreign slot is never purge's; whether it is an orphan is the create rule's question, asked against the slot owner's own key); absent with no slot → nothing; any `errLifetimePending` → the purge stops here and re-runs later (every step idempotent); (3) `purgeApp`'s sweep with instance keys **excluded** (the retire hook is the no-op marker that the keys were handled), then the trash marker. Because create, recovery and purge all take the app lock before the hostname lock, no create or recovery of this app runs while the purge does, and the app name is released (the marker deleted, the apps row removed) only after every instance of this app lifetime is retired, so a successor under the same name starts with no slot or record of its predecessor to collide with. **Restore evidence**: `settleApp` and `MaterializeApps` today count any object under the app prefix as evidence the app lives; in lifetimes mode **ordinary app objects (releases, channels, configuration, ledgers, promotion records) keep today's evidence value unchanged**, and the lifetime judgement applies to **instance-record keys alone**, where the evidence is **three-valued**: `ListRecordsComplete`'s judged sibling returns the valid records **and** whether any key was unreadable, corrupt or truncated; a valid record is evidence the app lives; an established stale or absent body contributes nothing; any unreadable, corrupt or truncated observation makes the outcome **unknown**, and `settleApp`/`MaterializeApps` then keep the row and defer (no `PurgeAppRow`, no `FinishRestoreRow`, logged once per hour); **as a worker, materialisation takes `s.lockApp(acct, app)` per candidate before collecting any evidence**, re-reads the row and the marker under the lock, and holds it through `PurgeAppRow`/`FinishRestoreRow` (its initial listing is a hint, as the expiry pass's rows are), so a settle, purge, recreate or re-trash by another operation never has its predecessor's evidence applied to it (`TestMaterializationHoldsTheAppLock`: a candidate settled and recreated as a new lifetime while materialisation waits for the lock → re-read, nothing applied; a re-trash → not restored), so a stale body never turns a finished purge into a restore and a failed read never releases a name whose records still exist (`TestRestoreEvidenceIsThreeValued`); **startup**: `NewServer` no longer calls `materializeQuietly` directly; materialisation is the first iteration of a worker that admits like every other (Task 0's lifecycle: `before` → legacy as today; `on` → judged; `unknown` → wait), so no nil-judge listing runs before the gate has a state, and claims-on keeps today's call; `TestStaleBodyIsNotRestoreEvidence` pins it (a purge whose `PurgeAppRow` write failed, a stale body under the prefix: `settleApp` releases the name instead of restoring).
+- **App purge.** Two regimes, split by the cutover marker. **A claims-off box auto-purges nothing from this release on**: `domains.Reconcile` 1d is removed for `!cfg.HostClaim` (the `HostClaim` branch's `RequestClaimAutoPurge` is unchanged); a box's check-then-delete can always pause between the check and the delete, so no marker or clock on a box can fence a sweep against a successor, and the only safe fence is for the code not to run. The console's expiry pass owns the store-driven trash sweep (below). Because boxes roll before the console (the roll order rule), the sweep pauses between the box roll and the console roll (a purge is a three-day-cooloff event; the pause is bounded by the roll); **the box floor is an edges-file capability**: a box running this release publishes `lifetimes-1` in its edges file's `capabilities` (`edgesFileCapabilities`, beside `offline-stage-1` and `region-ha-1`); the cutover door reads every edges file of the deployment (`readOfflineFleet`'s inventory, the same files every fleet decision uses; a box with no edges file is not a box of the fleet) and, for each, the box's status report `status/boxes/<label>.json`; it refuses (409 "box <label> does not publish lifetimes-1; every box must run this release before the cutover") while any file lacks the capability, and (409 "box <label> has not reported in 10 minutes") while any box's status report is missing, older than 10 minutes, or carries a `Metrics.BoxID` other than the file's box; **freshness comes from the status report, never from the edges file's age** (an unchanged edges file is not rewritten: `edgesFileWriteClaims` skips it, so its `generation` can stand for days on a healthy box); a decommissioned box's file is removed by the operator as today (`TestCutoverRefusesBoxesBelowTheFloor` uses the real publisher with region HA off and unchanged routes, so the edges file is days old and the status report is fresh → accepted). After the cutover the console's edges-file reader pages once per hour "box <label> regressed below the lifetimes floor" when a file without the capability appears. **Declared out of scope, with the reason**: an older box binary that starts or returns after the cutover (a rollback, a cloned image) is not excluded by this plan: its legacy purge and retention deletes are exactly today's exposure on `main` (two uncoordinated deleters of app objects), this plan neither introduces nor widens it, and the enforced fleet-wide binary floor it would need (a joining gate for claims-off boxes) is its own piece of work, filed as an amadan discussion on the branch; the operator rule (c) already forbids reintroducing an older console, and the same rule is stated for boxes in the rollout note. **The console** (`handlePurgeApp`, `purgeOne`, and a new hourly `release.ExpiredTrashApps(ctx, s, now)` sweep in `StartExpiryPass` that runs whether or not the pass has rows, so headless apps are purged once boxes stop; `HostClaim` deployments keep `RequestClaimAutoPurge` unchanged) purges through `s.purgeAppWithLifetimes(ctx, acct, app, want deletedAt, now)`, every caller already holding or taking **`s.lockApp(acct, app)`**: (1) **the guards first**: `release.PurgeAppGuards(ctx, s, pub, acct, app, want, now)` (the marker, its clock, the production flag and the store-estate guard exactly as `PurgeAppIfTrashedAt` applies them today, factored out so they run before any lifetime step; a refusal ends the purge with today's sentence); (2) **per instance key** under the app prefix, and per pending allocation of the app (`PendingLifetimesForApp(acct, app)`), under `withHostname(host)`: observe (`classifyLocked`); a pending allocation of this app → the lifetime check of Task 3 fails by construction (the app is trashed) → `retireSlotOrphanLocked` when the slot is the allocation's, `void`; a present record → `retireRecordWithLifetimeLocked(ctx, acct, app, host, normal, RetireExpectation{Incarnation: obs.Rec.Incarnation})` when numbered, `RetireExpectation{ETag: obs.ETag}` when legacy (never `retireAny`; the observation and the retirement are one locked step, so nothing can be observed stale); absent with a live slot **owned by this app** (`slot.Account == acct && slot.App == app`) → `retireSlotOrphanLocked`; absent with a live slot owned by **another** key → nothing (a foreign slot is never purge's; whether it is an orphan is the create rule's question, asked against the slot owner's own key); absent with no slot → nothing; any `errLifetimePending` → the purge stops here and re-runs later (every step idempotent); (3) `purgeApp`'s sweep with instance keys **excluded** (the retire hook is the no-op marker that the keys were handled), then the trash marker. Because create, recovery and purge all take the app lock before the hostname lock, no create or recovery of this app runs while the purge does, and the app name is released (the marker deleted, the apps row removed) only after every instance of this app lifetime is retired, so a successor under the same name starts with no slot or record of its predecessor to collide with. **Restore evidence**: `settleApp` and `MaterializeApps` today count any object under the app prefix as evidence the app lives; in lifetimes mode **ordinary app objects (releases, channels, configuration, ledgers, promotion records) keep today's evidence value unchanged**, and the lifetime judgement applies to **instance-record keys alone**, where the evidence is **three-valued**: `ListRecordsComplete`'s judged sibling returns the valid records **and** whether any key was unreadable, corrupt or truncated; a valid record is evidence the app lives; an established stale or absent body contributes nothing; any unreadable, corrupt or truncated observation makes the outcome **unknown**, and `settleApp`/`MaterializeApps` then keep the row and defer (no `PurgeAppRow`, no `FinishRestoreRow`, logged once per hour); **as a worker, materialisation takes `s.lockApp(acct, app)` per candidate before collecting any evidence**, re-reads the row and the marker under the lock, and holds it through `PurgeAppRow`/`FinishRestoreRow` (its initial listing is a hint, as the expiry pass's rows are), so a settle, purge, recreate or re-trash by another operation never has its predecessor's evidence applied to it (`TestMaterializationHoldsTheAppLock`: a candidate settled and recreated as a new lifetime while materialisation waits for the lock → re-read, nothing applied; a re-trash → not restored), so a stale body never turns a finished purge into a restore and a failed read never releases a name whose records still exist (`TestRestoreEvidenceIsThreeValued`); **startup**: `NewServer` no longer calls `materializeQuietly` directly; materialisation is the first iteration of a worker that admits like every other (Task 0's lifecycle: `before` → legacy as today; `on` → judged; `unknown` → wait), so no nil-judge listing runs before the gate has a state, and claims-on keeps today's call; `TestStaleBodyIsNotRestoreEvidence` pins it (a purge whose `PurgeAppRow` write failed, a stale body under the prefix: `settleApp` releases the name instead of restoring).
- 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`
diff --git a/docs/superpowers/plans/2026-10-22-self-serve-moves-2-door-token-job.md b/docs/superpowers/plans/2026-10-22-self-serve-moves-2-door-token-job.md
index 674459fee..26761dbeb 100644
--- a/docs/superpowers/plans/2026-10-22-self-serve-moves-2-door-token-job.md
+++ b/docs/superpowers/plans/2026-10-22-self-serve-moves-2-door-token-job.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 12, 2026-10-22. Rev 11's review (`selfserve-plan2-review11.md`: N8 closed; N9 narrowed; N10 new) is answered: `selfServeApply` checks the request's deadline **before every forward-write attempt, replays included**, and an expired request leaves the loop as `errRequestExpired` for the fence-and-classify path, so no retry can open after the deadline inside one invocation (N9); a same-phase consume **rolls the next wait's deadline** in the same UPDATE (`ConsumeSelfServeRequest(id, nonce, nextStep, nextDeadline)`), issuance copies that fresh deadline into `request_deadline`, and the phase-wide limits stay on `staging_started_at` and `window_deadline` (N10). Rev 10's review (`selfserve-plan2-review10.md`: N6 closed; N8 narrowed; N9 new) is answered: **every** writer, the operator doors included, proposes its stamp as `max(now, the observed record's previous stamp + 1 ns)` and the mutation stores it verbatim, so stamps are monotonic under a backward clock and supersession by stamp is sound (N8); every request persists **its own deadline** (`request_deadline`, the issuing phase's step deadline), and an unconfirmed request whose deadline has passed while the record is at or before `offline-landed` is never replayed from **any** row phase, `open-uncertain` included: fence, classify, confirm if the write won, else void and abort (N9). Rev 9's review (`selfserve-plan2-review9.md`: 8, 10, 13, 20, N7 closed) is answered: **a committed confirmation expiry wins over a later cut** (`MarkSelfServeCut` also requires `expiry_pending = 0`; the cut door answers 409 "the move expired. prepare again"), and the persisted expiry carries its kind and deadline so execution revalidates its identity (N6); **an operator's newer staging stamp supersedes the job's outstanding wait**: when the record's stamp for the awaited field has moved past `request_proposed` without the receipt changing, the job abandons its request and issues a fresh bound round, with the crash between the two stated (N8). Rev 8's review (`selfserve-plan2-review8.md`: 21, N4 closed) is answered: reconciliation maps the record to a **phase and substep** (the first substep whose transition the record has not made), so an operator's progress never makes the job repeat a refused transition (item 10); abandonment is only for **leaving** a wait (abort, cancel, operator supersession), never for a page-and-retain expiry, with the phase table stated (item 13); the confirmation-deadline expiry commits only against `cut_requested_at = 0` and the deadline it saw, and the cut's copy expiry is a different decision (N6); the cut handoff persists which continuation it chose as a `restage` substep (`adopted` or `issue`) in the advancing transaction (N7); acceptance and discovery share one participant predicate (item 8); the superseded tag arrangement is deleted (item 20). Rev 7's review (`selfserve-plan2-review7.md`: N1, N2, N3, N5 closed) is answered: a phase with several writes has a durable **substep** (`phase_step`), advanced atomically with the consumption that ends the previous substep, and the phase function dispatches on it (item 10); confirmation is the **receipt alone** (no state predicate), phase progress is reconciliation's business (item 10); issuance refused with no outstanding request is its own result (`errRequestRefused`) (item 10); a confirmed request whose wait will never be satisfied is **abandoned** (consumed without its wait) by abort, expiry and operator supersession, and the finaliser consumes a confirmed clearing request in the transaction that records the outcome (item 13); `maintain` updates the stage identity like `stage` (N4); the cut's `MarkSelfServeCut` retires the ready deadline in the same UPDATE and ready expiry is conditional on no accepted cut (N6); return reports are **immutable per request key** (item 21); an id-less forward keeps `BoxID` membership and drops only the instance comparison (item 8); one untagged bridge file with the complete helper closure (item 20). Rev 6's review (`selfserve-plan2-review6.md`: items 1, 6, 17 closed) is answered: the request lifecycle is a complete state machine (`issued → confirmed → consumed`, or `voided`) with a **resume** path for a confirmed-unconsumed request, a standalone consume for same-phase sequences (start then seal, finish gate 1 then finish, round after round, estimate), issuance that returns the existing request so a caller of another kind or phase stops instead of interpreting it, and no-op-free receipts (a request-bearing mutation always writes its receipt) (item 10); the bound write checks **incarnation and sequence** (`provision.Expect{Incarnation, Seq}`) for the job and the operator doors (N5, item 13); every finalisation requires no outstanding request after a fence (item 13); the expiry branch classifies the fenced observation (N3); the cut is a **handoff**: the door marks `cut_requested_at` and the job turns the outstanding maintenance round into the first cut round (N4); event delivery marks `emitted` inside the transaction before delivering and the terminal event is inserted with the outcome (N1); per-box FIFO is stated (N2); the open intent is prepared before the request is issued so adoption and the persisted proposal agree, return requests carry a full identity ordered deterministically, reports name the request key, and acceptance binds the report to the reverse move's selected target (item 21); `Expect`'s real fields, `Load`'s absence distinction and one id-less rule (item 8); the fixtures move to an untagged `linux` file so today's tests keep compiling, `CopyEvent` carries its completion fields, the saved book is validated before seeding, `DiskFreeBytes` is numeric (item 20). Rev 5's review (`selfserve-plan2-review5.md`: items 2, 3, 4, 5, 7, 9, 11, 12, 14, 15, 16, 18, 19 closed) is answered: every job and operator record write is **predecessor-bound** (`provision.SetRecordMoveAt(ctx, s, acct, app, host, expectSeq, mut)`: one CAS write against the record whose `record_seq` equals the persisted expectation, no fresh-read retry), so a voided request can never land and an operator door never rebuilds a write after a reclaim fence (items 10, 13); every self-serve mutation stamps a **nonce receipt** on the record (`SelfServe.LastRequest`, or `LastMove.Request` for a clearing write), so confirmation is exact for every transition and distinct from an operator's reconciliation (item 10); confirm and advance require an unconsumed, unvoided request, so a stale worker cannot confirm a voided one (item 10); "pending" means **no confirmed request for the phase's write**, so the interval before issuance is compatible with the predecessor state (item 10); an expired uncommitted open is fenced and aborted, never replayed (N1); the stage book is reconciled against each complete successful round so deleted keys leave it (item 6); identity records are loaded by `boxid.Key(BoxID)` and `Verified` is narrowed to what `Enroll` proves (item 8); the gate's exception for an authorised malformed hostname and plan 1's outer 503 are declared (item 1); the view joins row id, move id, self-serve id and incarnation (item 17); the open record adopts an existing complete intent, return discovery and acceptance compare `InstanceID`, incarnation is re-checked at the door and in the guarded write, and `Layout` comes from the source's `stopped` report so every operator move has it (item 21); a maintenance restage in `ready` has its own kind and keeps the confirmation deadline (N2); the gate types, `StagedRequest`, failed diagnostics, the request argument to `confirmed`, and the bridge's compile rule are stated (item 20). Reviews: rev 1 (`after236/selfserve-plan2-review.md`, 20 findings), rev 2 (`selfserve-plan2-review2.md`, 21), rev 3 (`selfserve-plan2-review3.md`, 21 items), rev 4 (`selfserve-plan2-review4.md`, 21 items, 9 closed). Rev 5 is **self-contained** (nothing is "as rev N"; every schema, phase, predicate, sentence and test list is in this document) and answers rev 4's open items: the gate orders authentication, host shape and the capability check before the limiter and the no-bucket rail (item 1); operator moves keep today's admission (nil or stale metrics, no identity record) and only the self-serve path requires fresh identity; `BootID` is dropped (items 4, 8); the evidence reader takes the move and a freshness table is stated (item 5); the staged inventory is a **move-bound book** built from every copy event the mirror reports, including same-ETag and deferred copies, through a new observer hook that leaves ordinary mirroring unchanged (item 6); a participant is identified by `BoxID` **and** `InstanceID` in the resolver, in report validation and at every gate, and abort never requires the target (item 8); every request persists its **nonce, expected sequence and proposed stamp before the write**, confirmation is conditional on the record carrying that exact stamp, replay is idempotent, and consumption and the phase advance are one UPDATE predicated on the nonce (item 10); compatible sets include the predecessor state while a phase's write is pending (item 10); expiry is bound to its phase and cleared on reconciliation into another phase, with the health rule taking precedence (item 12); abandonment is conditional on the fenced observation and persisted before release, and every reclaim fences first, operator reservations included (item 13); own retries are accepted only for a complete expected selection after the whole table validates (item 14); the status view joins rows regardless of the mode (item 17); events carry a per-row sequence and are delivered inside the caller's transaction, the harness is an exported bridge **inside package host** behind the `e2e` tag with the CI step named, the Go declarations are complete, and queue ordering is stated (item 20); the open record is written **before** the open transition with the proposed open time, return requests have unique durable identities, the report path is named, acceptance is bound to the lifetime and the forward's participants, and leftovers are judged by the forward's layout (item 21).
+Rev 13, 2026-10-22. Rev 12's review (`selfserve-plan2-review12.md`: N9 closed; N10 narrowed; N11 new) is answered: **a request deadline exists only for writes whose expiry action is abort** (the pre-open writes: queue, stage, maintain, precheck, start, seal, flip, open); the post-open and clearing writes (gate 1, gate 2, finish, the abort path's abort and finish, estimate) carry `request_deadline = 0`, which `selfServeApply` never checks, so a page-and-retain phase whose evidence arrives late still clears the move (N11); abandonment rolls the next wait's deadline in its own UPDATE (`AbandonSelfServeRequest(id, nonce, nextDeadline)`) (N10a); a maintenance round's allowance starts at its issue and `ready` has **no step deadline while idle** (`next_maintenance_at` schedules the next round; `step_deadline = 0` between rounds) (N10b). Rev 11's review (`selfserve-plan2-review11.md`: N8 closed; N9 narrowed; N10 new) is answered: `selfServeApply` checks the request's deadline **before every forward-write attempt, replays included**, and an expired request leaves the loop as `errRequestExpired` for the fence-and-classify path, so no retry can open after the deadline inside one invocation (N9); a same-phase consume **rolls the next wait's deadline** in the same UPDATE (`ConsumeSelfServeRequest(id, nonce, nextStep, nextDeadline)`), issuance copies that fresh deadline into `request_deadline`, and the phase-wide limits stay on `staging_started_at` and `window_deadline` (N10). Rev 10's review (`selfserve-plan2-review10.md`: N6 closed; N8 narrowed; N9 new) is answered: **every** writer, the operator doors included, proposes its stamp as `max(now, the observed record's previous stamp + 1 ns)` and the mutation stores it verbatim, so stamps are monotonic under a backward clock and supersession by stamp is sound (N8); every request persists **its own deadline** (`request_deadline`, the issuing phase's step deadline), and an unconfirmed request whose deadline has passed while the record is at or before `offline-landed` is never replayed from **any** row phase, `open-uncertain` included: fence, classify, confirm if the write won, else void and abort (N9). Rev 9's review (`selfserve-plan2-review9.md`: 8, 10, 13, 20, N7 closed) is answered: **a committed confirmation expiry wins over a later cut** (`MarkSelfServeCut` also requires `expiry_pending = 0`; the cut door answers 409 "the move expired. prepare again"), and the persisted expiry carries its kind and deadline so execution revalidates its identity (N6); **an operator's newer staging stamp supersedes the job's outstanding wait**: when the record's stamp for the awaited field has moved past `request_proposed` without the receipt changing, the job abandons its request and issues a fresh bound round, with the crash between the two stated (N8). Rev 8's review (`selfserve-plan2-review8.md`: 21, N4 closed) is answered: reconciliation maps the record to a **phase and substep** (the first substep whose transition the record has not made), so an operator's progress never makes the job repeat a refused transition (item 10); abandonment is only for **leaving** a wait (abort, cancel, operator supersession), never for a page-and-retain expiry, with the phase table stated (item 13); the confirmation-deadline expiry commits only against `cut_requested_at = 0` and the deadline it saw, and the cut's copy expiry is a different decision (N6); the cut handoff persists which continuation it chose as a `restage` substep (`adopted` or `issue`) in the advancing transaction (N7); acceptance and discovery share one participant predicate (item 8); the superseded tag arrangement is deleted (item 20). Rev 7's review (`selfserve-plan2-review7.md`: N1, N2, N3, N5 closed) is answered: a phase with several writes has a durable **substep** (`phase_step`), advanced atomically with the consumption that ends the previous substep, and the phase function dispatches on it (item 10); confirmation is the **receipt alone** (no state predicate), phase progress is reconciliation's business (item 10); issuance refused with no outstanding request is its own result (`errRequestRefused`) (item 10); a confirmed request whose wait will never be satisfied is **abandoned** (consumed without its wait) by abort, expiry and operator supersession, and the finaliser consumes a confirmed clearing request in the transaction that records the outcome (item 13); `maintain` updates the stage identity like `stage` (N4); the cut's `MarkSelfServeCut` retires the ready deadline in the same UPDATE and ready expiry is conditional on no accepted cut (N6); return reports are **immutable per request key** (item 21); an id-less forward keeps `BoxID` membership and drops only the instance comparison (item 8); one untagged bridge file with the complete helper closure (item 20). Rev 6's review (`selfserve-plan2-review6.md`: items 1, 6, 17 closed) is answered: the request lifecycle is a complete state machine (`issued → confirmed → consumed`, or `voided`) with a **resume** path for a confirmed-unconsumed request, a standalone consume for same-phase sequences (start then seal, finish gate 1 then finish, round after round, estimate), issuance that returns the existing request so a caller of another kind or phase stops instead of interpreting it, and no-op-free receipts (a request-bearing mutation always writes its receipt) (item 10); the bound write checks **incarnation and sequence** (`provision.Expect{Incarnation, Seq}`) for the job and the operator doors (N5, item 13); every finalisation requires no outstanding request after a fence (item 13); the expiry branch classifies the fenced observation (N3); the cut is a **handoff**: the door marks `cut_requested_at` and the job turns the outstanding maintenance round into the first cut round (N4); event delivery marks `emitted` inside the transaction before delivering and the terminal event is inserted with the outcome (N1); per-box FIFO is stated (N2); the open intent is prepared before the request is issued so adoption and the persisted proposal agree, return requests carry a full identity ordered deterministically, reports name the request key, and acceptance binds the report to the reverse move's selected target (item 21); `Expect`'s real fields, `Load`'s absence distinction and one id-less rule (item 8); the fixtures move to an untagged `linux` file so today's tests keep compiling, `CopyEvent` carries its completion fields, the saved book is validated before seeding, `DiskFreeBytes` is numeric (item 20). Rev 5's review (`selfserve-plan2-review5.md`: items 2, 3, 4, 5, 7, 9, 11, 12, 14, 15, 16, 18, 19 closed) is answered: every job and operator record write is **predecessor-bound** (`provision.SetRecordMoveAt(ctx, s, acct, app, host, expectSeq, mut)`: one CAS write against the record whose `record_seq` equals the persisted expectation, no fresh-read retry), so a voided request can never land and an operator door never rebuilds a write after a reclaim fence (items 10, 13); every self-serve mutation stamps a **nonce receipt** on the record (`SelfServe.LastRequest`, or `LastMove.Request` for a clearing write), so confirmation is exact for every transition and distinct from an operator's reconciliation (item 10); confirm and advance require an unconsumed, unvoided request, so a stale worker cannot confirm a voided one (item 10); "pending" means **no confirmed request for the phase's write**, so the interval before issuance is compatible with the predecessor state (item 10); an expired uncommitted open is fenced and aborted, never replayed (N1); the stage book is reconciled against each complete successful round so deleted keys leave it (item 6); identity records are loaded by `boxid.Key(BoxID)` and `Verified` is narrowed to what `Enroll` proves (item 8); the gate's exception for an authorised malformed hostname and plan 1's outer 503 are declared (item 1); the view joins row id, move id, self-serve id and incarnation (item 17); the open record adopts an existing complete intent, return discovery and acceptance compare `InstanceID`, incarnation is re-checked at the door and in the guarded write, and `Layout` comes from the source's `stopped` report so every operator move has it (item 21); a maintenance restage in `ready` has its own kind and keeps the confirmation deadline (N2); the gate types, `StagedRequest`, failed diagnostics, the request argument to `confirmed`, and the bridge's compile rule are stated (item 20). Reviews: rev 1 (`after236/selfserve-plan2-review.md`, 20 findings), rev 2 (`selfserve-plan2-review2.md`, 21), rev 3 (`selfserve-plan2-review3.md`, 21 items), rev 4 (`selfserve-plan2-review4.md`, 21 items, 9 closed). Rev 5 is **self-contained** (nothing is "as rev N"; every schema, phase, predicate, sentence and test list is in this document) and answers rev 4's open items: the gate orders authentication, host shape and the capability check before the limiter and the no-bucket rail (item 1); operator moves keep today's admission (nil or stale metrics, no identity record) and only the self-serve path requires fresh identity; `BootID` is dropped (items 4, 8); the evidence reader takes the move and a freshness table is stated (item 5); the staged inventory is a **move-bound book** built from every copy event the mirror reports, including same-ETag and deferred copies, through a new observer hook that leaves ordinary mirroring unchanged (item 6); a participant is identified by `BoxID` **and** `InstanceID` in the resolver, in report validation and at every gate, and abort never requires the target (item 8); every request persists its **nonce, expected sequence and proposed stamp before the write**, confirmation is conditional on the record carrying that exact stamp, replay is idempotent, and consumption and the phase advance are one UPDATE predicated on the nonce (item 10); compatible sets include the predecessor state while a phase's write is pending (item 10); expiry is bound to its phase and cleared on reconciliation into another phase, with the health rule taking precedence (item 12); abandonment is conditional on the fenced observation and persisted before release, and every reclaim fences first, operator reservations included (item 13); own retries are accepted only for a complete expected selection after the whole table validates (item 14); the status view joins rows regardless of the mode (item 17); events carry a per-row sequence and are delivered inside the caller's transaction, the harness is an exported bridge **inside package host** behind the `e2e` tag with the CI step named, the Go declarations are complete, and queue ordering is stated (item 20); the open record is written **before** the open transition with the proposed open time, return requests have unique durable identities, the report path is named, acceptance is bound to the lifetime and the forward's participants, and leftovers are judged by the forward's layout (item 21).
Builds on plan 1 (`2026-10-22-self-serve-moves-1-lifetimes-and-order.md`, rev 13): records carry a lifetime, `record_seq` and `move_seq`, rows carry the pair, readers and writers are judged, `fenceRecord(ctx, acct, app, host, op fenceOwner) (fenceResult, Observation, error)` exists, the console runs behind the lifetimes cutover. This plan adds everything a Tito customer's request needs up to a finished move except the holding page, the edge probe mode and cleanup (plans 3 and 4).
@@ -102,8 +102,8 @@ Builds on plan 1 (`2026-10-22-self-serve-moves-1-lifetimes-and-order.md`, rev 13
- Test: `internal/console/selfserve_store_test.go`, `internal/console/selfserve_pass_test.go`
**Interfaces:**
-- Table `self_serve_moves (id TEXT PRIMARY KEY, account TEXT, app TEXT, host TEXT, incarnation INTEGER, from_region TEXT, to_region TEXT, participants TEXT, display_name TEXT, requested_by TEXT, phase TEXT, phase_started_at INTEGER, step_deadline INTEGER, window_deadline INTEGER, expiry_pending INTEGER, expiry_phase TEXT, expiry_kind TEXT, expiry_deadline INTEGER, staging_started_at INTEGER, request_issued INTEGER, request_kind TEXT, request_phase TEXT, request_nonce TEXT, request_expect_seq INTEGER, request_proposed INTEGER, request_confirmed INTEGER, request_consumed INTEGER, request_voided INTEGER, request_abandoned INTEGER, request_expect_inc INTEGER, request_deadline INTEGER, cut_requested_at INTEGER, phase_step TEXT, stage_request INTEGER, precheck_request INTEGER, rounds INTEGER, estimate_seconds INTEGER, estimate_terms TEXT, reason TEXT, health_attempts INTEGER, health_consecutive_ok INTEGER, health_first_ok_at INTEGER, health_last_probe_at INTEGER, health_last_error TEXT, finish_gate TEXT, notified_seq INTEGER, event_seq INTEGER, outcome TEXT, outcome_reason TEXT, warning TEXT, abort_requested_at INTEGER, source_reopened_at INTEGER, abort_finished_at INTEGER, effects_done INTEGER, release_pending INTEGER, open_record_pending INTEGER, created_at INTEGER, updated_at INTEGER)`; `CREATE UNIQUE INDEX IF NOT EXISTS self_serve_moves_open ON self_serve_moves(account, app, host) WHERE outcome = ''`.
-- **The request lifecycle** (one request at a time per row): `IssueSelfServeRequest(id, kind, phase string, pr persistedRequest, now time.Time) (issued bool, existing *persistedRequest, err error)` (`WHERE phase = ? AND outcome = '' AND (request_consumed <> 0 OR request_issued = 0)`: sets `request_issued = now, request_kind, request_phase, request_nonce, request_expect_inc, request_expect_seq, request_proposed, request_deadline = the issuing phase's step deadline, request_confirmed = 0, request_consumed = 0, request_voided = 0`; not issued → `existing` is the outstanding request, whatever its kind and phase); `ConsumeSelfServeRequest(id, nonce, nextStep string, nextDeadline time.Time) (bool, error)` (`WHERE request_nonce = ? AND request_confirmed <> 0 AND request_consumed = 0`: `request_consumed = 1, phase_step = nextStep, step_deadline = nextDeadline` in one statement, so **each wait's deadline starts when the previous request is consumed**: a staging round's `max(10 min, 3 × est.Copy)` runs from its own issue, each finishing gate gets its own 10 min, each maintenance round its own allowance; the phase-wide limits (`now - staging_started_at > 30 min`, `WindowDeadline`) are separate columns and unaffected; `AdvanceSelfServe` sets `step_deadline` for the new phase's first wait likewise; a crash between a consume and the next issue finds the fresh deadline already persisted; the standalone consume for a same-phase sequence: the substep advances exactly when the previous request is consumed, so a crash between them cannot lose the position); `AbandonSelfServeRequest(id, nonce string) (bool, error)` (`WHERE request_nonce = ? AND request_confirmed <> 0 AND request_consumed = 0`: `request_consumed = 1, request_abandoned = 1`; only abort, an expiry action and operator supersession call it: the request's write committed but its wait will never be satisfied, so the phase gives it up and proceeds); `ConfirmSelfServeRequest(id, nonce string, now time.Time) (bool, error)` (`WHERE request_nonce = ? AND request_consumed = 0 AND request_voided = 0 AND request_confirmed = 0`: `request_confirmed = now`; false → the caller re-reads the row: confirmed already → **resume** (success), voided or consumed → stop; for kinds `stage` **and `maintain`** also `stage_request = request_proposed`, for `precheck` also `precheck_request = request_proposed`); `VoidSelfServeRequest(id, nonce string) error` (`WHERE request_nonce = ? AND request_consumed = 0 AND request_confirmed = 0`: `request_consumed = 1, request_voided = 1`; a confirmed request is never voided; the phase function decides again); `AdvanceSelfServe(id, from, to, reason string, step, window time.Time, nonce string, now time.Time) (bool, error)`: one UPDATE with the predicate `phase = ? AND (abort_requested_at = 0 OR ? IN ('reverting','done','opened','health','finishing')) AND (? = '' OR request_nonce = ?)` that sets the phase, `phase_started_at`, the deadlines, `expiry_pending = 0, expiry_phase = ''` when `to <> expiry_phase`, and, when a nonce is given, additionally requires `request_nonce = ? AND request_confirmed <> 0 AND request_consumed = 0` and sets `request_consumed = 1` (**consumption and the advance are one statement**), and inserts the phase event in the same transaction (`event_seq = event_seq + 1`); a stale worker holding another nonce, or the same nonce after a void, cannot confirm or consume.
+- Table `self_serve_moves (id TEXT PRIMARY KEY, account TEXT, app TEXT, host TEXT, incarnation INTEGER, from_region TEXT, to_region TEXT, participants TEXT, display_name TEXT, requested_by TEXT, phase TEXT, phase_started_at INTEGER, step_deadline INTEGER, window_deadline INTEGER, expiry_pending INTEGER, expiry_phase TEXT, expiry_kind TEXT, expiry_deadline INTEGER, staging_started_at INTEGER, request_issued INTEGER, request_kind TEXT, request_phase TEXT, request_nonce TEXT, request_expect_seq INTEGER, request_proposed INTEGER, request_confirmed INTEGER, request_consumed INTEGER, request_voided INTEGER, request_abandoned INTEGER, request_expect_inc INTEGER, request_deadline INTEGER, next_maintenance_at INTEGER, cut_requested_at INTEGER, phase_step TEXT, stage_request INTEGER, precheck_request INTEGER, rounds INTEGER, estimate_seconds INTEGER, estimate_terms TEXT, reason TEXT, health_attempts INTEGER, health_consecutive_ok INTEGER, health_first_ok_at INTEGER, health_last_probe_at INTEGER, health_last_error TEXT, finish_gate TEXT, notified_seq INTEGER, event_seq INTEGER, outcome TEXT, outcome_reason TEXT, warning TEXT, abort_requested_at INTEGER, source_reopened_at INTEGER, abort_finished_at INTEGER, effects_done INTEGER, release_pending INTEGER, open_record_pending INTEGER, created_at INTEGER, updated_at INTEGER)`; `CREATE UNIQUE INDEX IF NOT EXISTS self_serve_moves_open ON self_serve_moves(account, app, host) WHERE outcome = ''`.
+- **The request lifecycle** (one request at a time per row): `IssueSelfServeRequest(id, kind, phase string, pr persistedRequest, now time.Time) (issued bool, existing *persistedRequest, err error)` (`WHERE phase = ? AND outcome = '' AND (request_consumed <> 0 OR request_issued = 0)`: sets `request_issued = now, request_kind, request_phase, request_nonce, request_expect_inc, request_expect_seq, request_proposed, request_deadline = the issuing phase's step deadline **for a request whose expiry action is abort** (queue, stage, maintain, precheck, start, seal, flip, open) and **0 for every other kind** (gate 1, gate 2, finish, abort, the abort path's finish, estimate: their phases page and retain, and their writes must always be allowed to complete), request_confirmed = 0, request_consumed = 0, request_voided = 0`; not issued → `existing` is the outstanding request, whatever its kind and phase); `ConsumeSelfServeRequest(id, nonce, nextStep string, nextDeadline time.Time) (bool, error)` (`WHERE request_nonce = ? AND request_confirmed <> 0 AND request_consumed = 0`: `request_consumed = 1, phase_step = nextStep, step_deadline = nextDeadline` in one statement, so **each wait's deadline starts when the previous request is consumed**: a staging round's `max(10 min, 3 × est.Copy)` runs from its own issue, each finishing gate gets its own 10 min, each maintenance round its own allowance; the phase-wide limits (`now - staging_started_at > 30 min`, `WindowDeadline`) are separate columns and unaffected; `AdvanceSelfServe` sets `step_deadline` for the new phase's first wait likewise; a crash between a consume and the next issue finds the fresh deadline already persisted; the standalone consume for a same-phase sequence: the substep advances exactly when the previous request is consumed, so a crash between them cannot lose the position); `AbandonSelfServeRequest(id, nonce string, nextDeadline time.Time) (bool, error)` (`WHERE request_nonce = ? AND request_confirmed <> 0 AND request_consumed = 0`: `request_consumed = 1, request_abandoned = 1, step_deadline = nextDeadline` in one statement, so the replacement wait gets a fresh allowance and a crash between the abandon and the next issue finds it persisted; only abort, an expiry action and operator supersession call it: the request's write committed but its wait will never be satisfied, so the phase gives it up and proceeds); `ConfirmSelfServeRequest(id, nonce string, now time.Time) (bool, error)` (`WHERE request_nonce = ? AND request_consumed = 0 AND request_voided = 0 AND request_confirmed = 0`: `request_confirmed = now`; false → the caller re-reads the row: confirmed already → **resume** (success), voided or consumed → stop; for kinds `stage` **and `maintain`** also `stage_request = request_proposed`, for `precheck` also `precheck_request = request_proposed`); `VoidSelfServeRequest(id, nonce string) error` (`WHERE request_nonce = ? AND request_consumed = 0 AND request_confirmed = 0`: `request_consumed = 1, request_voided = 1`; a confirmed request is never voided; the phase function decides again); `AdvanceSelfServe(id, from, to, reason string, step, window time.Time, nonce string, now time.Time) (bool, error)`: one UPDATE with the predicate `phase = ? AND (abort_requested_at = 0 OR ? IN ('reverting','done','opened','health','finishing')) AND (? = '' OR request_nonce = ?)` that sets the phase, `phase_started_at`, the deadlines, `expiry_pending = 0, expiry_phase = ''` when `to <> expiry_phase`, and, when a nonce is given, additionally requires `request_nonce = ? AND request_confirmed <> 0 AND request_consumed = 0` and sets `request_consumed = 1` (**consumption and the advance are one statement**), and inserts the phase event in the same transaction (`event_seq = event_seq + 1`); a stale worker holding another nonce, or the same nonce after a void, cannot confirm or consume.
- Other store methods: `AdmitSelfServeMove(m SelfServeMove) (admitted bool, open *SelfServeMove, err error)`; `SelfServeMove(id string) (*SelfServeMove, error)`; `SelfServeMoveForHost(acct, app, host string) (open, terminal *SelfServeMove, err error)` (the open row, and the newest terminal row); `OpenSelfServeMoves() ([]SelfServeMove, error)` (`outcome = '' OR release_pending = 1 OR effects_done = 0 OR open_record_pending = 1`, **ordered by `created_at, id`**: the pass steps rows in that order; **per-box FIFO**: a row in `reserving` may reserve only when no older open row (by `created_at, id`) that shares any participant `BoxID` is still in `requested` or `reserving`; otherwise it waits with `reason` "waiting for an earlier move on this box"; rows with disjoint participants never wait on each other); `MarkSelfServeCut(id string, now time.Time, copyDeadline time.Time) (bool, error)` (`WHERE phase = 'ready' AND abort_requested_at = 0 AND cut_requested_at = 0 AND expiry_pending = 0`: a confirmation expiry already committed wins, and the cut door re-reads and answers 409 "the move expired. prepare again"; sets `cut_requested_at = now`, **`window_deadline = 0`** (the confirmation deadline is retired by the cut that won) and `step_deadline = copyDeadline` (the cut's copy wait) in the same UPDATE; the cut door's only write); the `ready` expiry rule applies only while `cut_requested_at = 0`; `SetSelfServeEstimate`, `RecordSelfServeHealth(id string, probeAt time.Time, ok bool, errText string) error` (`health_attempts + 1`; `consecutive_ok = CASE ok THEN consecutive_ok + 1 ELSE 0`; `first_ok_at` kept once set), `SetSelfServeFinishGate`, `SetSelfServeWarning`, `SetSelfServeNotifiedSeq(id string, seq uint64) error`, `SetSelfServeExpiry(id, phase string, kind string, sawDeadline time.Time, pending bool) (bool, error)` (`kind` is `window` or `step`; the UPDATE also records `expiry_kind = kind, expiry_deadline = sawDeadline`, and the expiry **action** later re-reads the row and runs only when `expiry_pending`, `expiry_phase`, `expiry_kind` and `expiry_deadline` still match what it is acting on (an expiry whose identity changed is dropped); the UPDATE's predicate is `phase = ? AND (window_deadline = ? for window, step_deadline = ? for step) AND (kind <> 'window' OR cut_requested_at = 0)`, so a worker whose snapshot predates a successful cut cannot record the obsolete confirmation expiry (`TestStaleExpiryLosesToTheCut`: a worker paused across the cut at T-1 resumes at T+1 and its `window` expiry is refused; the copy's `step` expiry after the cut is accepted; **expiry first**: the window expiry commits at T+ε, then the cut door → 409 expired, and the abort runs; a restart between the committed expiry and the cut → the same); `SetSelfServeExpiry` returning false → the worker re-reads the row and decides again`, `RequestSelfServeAbort(id string, phases []string, now time.Time) (bool, error)` (`WHERE phase IN (...) AND abort_requested_at = 0`), `SetSelfServeReopened`, `SetSelfServeAbortFinished`, `FinishSelfServeMove(id, outcome, reason, warning string, now time.Time) error` (`release_pending = 1`, `effects_done = 0`), `SetSelfServeEffectsDone(id string) error`, `ClearSelfServeRelease(id string) error`, `SetSelfServeOpenRecordPending(id string, pending bool) error`.
- Table `self_serve_events (id TEXT, seq INTEGER, phase TEXT, account TEXT, app TEXT, host TEXT, at INTEGER, reason TEXT, detail TEXT, emitted INTEGER, PRIMARY KEY (id, seq))`: `InsertSelfServeEventTx(tx *sql.Tx, e SelfServeEvent) error` (allocates `seq` from the row's `event_seq` in the same transaction; used by `AdvanceSelfServe` and by every refusal, fact decision, lane probe, health probe and timing); `UnemittedSelfServeEvents(limit int) ([]SelfServeEvent, error)`; `DeliverSelfServeEvent(ctx context.Context, e SelfServeEvent, deliver func(tx *sql.Tx) error) error` (one transaction: first `UPDATE self_serve_events SET emitted = 1 WHERE id = ? AND seq = ? AND emitted = 0`; zero rows → commit nothing and return (another worker delivered it); else `deliver(tx)` inserts the audit row through `auditActivityTx(tx, ...)`; a failure rolls both back). The terminal event is inserted by `FinishSelfServeMove` in the transaction that sets the outcome, so a crash between finalisation steps never allocates a second terminal event. Table `self_serve_timings (id TEXT, mark TEXT, account TEXT, app TEXT, host TEXT, at INTEGER, bytes INTEGER, PRIMARY KEY (id, mark))`.
- Phases: `requested`, `reserving`, `queued`, `reports`, `staging`, `prechecks`, `ready`, `restage`, `barrier`, `stopping`, `sealing`, `landing`, `landed`, `open-uncertain`, `opened`, `health`, `finishing`, `reverting`, `done`.
@@ -161,7 +161,7 @@ Builds on plan 1 (`2026-10-22-self-serve-moves-1-lifetimes-and-order.md`, rev 13
- Test: `internal/e2e/selfserve/job_test.go`
**Interfaces:**
-- `type persistedRequest struct { Kind, Phase, Nonce string; ExpectInc, ExpectSeq uint64; Proposed time.Time; Confirmed, Consumed, Voided bool }`; `func (s *Server) selfServeApply(ctx context.Context, t *leaseToken, row *SelfServeMove, req request, proposed *time.Time, mut func(at time.Time, rc provision.Receipt) func(*provision.Record) error) (*provision.Record, error)` with `type request struct { Kind, Phase string }`: (1) the judged record → `pr.ExpectInc = rec.Incarnation`, `pr.ExpectSeq = rec.RecordSeq`; `pr.Proposed = *proposed` when given (the open intent) else `max(now, latest stamp on the record + 1 ns)`; `pr.Nonce = random`; `IssueSelfServeRequest(id, kind, phase, pr, now)`: not issued and `existing == nil` → `errRequestRefused` (the row left the phase or ended; the step ends); `existing.Kind != req.Kind || existing.Phase != req.Phase` → return `errRequestForeign` (this caller never interprets another request; the step ends and reconciliation decides next tick); `existing.Confirmed && !existing.Consumed` → **resume**: return the record (the caller continues its wait and the notification catch-up); otherwise `pr = *existing` and go to (3); (2) **first `now < pr.Deadline`** (`request_deadline`), else return `errRequestExpired` without writing (the step's fence-and-classify path takes over); then `provision.SetRecordMoveAt(ctx', s, acct, app, host, provision.Expect{Incarnation: pr.ExpectInc, Seq: pr.ExpectSeq}, mut(pr.Proposed, Receipt{pr.Nonce}))` with the lease-bounded context and the write guard: **one CAS attempt against the record at that incarnation and sequence; no fresh-read retry inside**; (3) **confirmation** from a fresh judged read, whatever (2) returned: `rec` carries the receipt (`SelfServe.LastRequest == pr.Nonce`, or `LastMove.Request == pr.Nonce` for a clearing write) → `ConfirmSelfServeRequest(id, nonce, now)` (**the receipt alone confirms**; a box that has already advanced the record past the written state (seal → sealed, flip → landed) does not change that; the `confirmed` callback is dropped and phase progress is reconciliation's business) (false → re-read: already confirmed → resume; voided → stop); else `rec.Incarnation == pr.ExpectInc && rec.RecordSeq == pr.ExpectSeq` (unchanged) → the write did not land: repeat (2), **which re-checks the deadline first** (idempotent replay of the same bound write; bounded by the lease and the deadline; `TestReplayStopsAtTheDeadlineInsideOneInvocation`: an open request whose PUT fails at T-1 s and whose confirmation read returns at T+1 s is not replayed; the step fences and classifies; the same with the PUT having committed → confirmed); else (the record advanced without the receipt: another writer, an operator) → `VoidSelfServeRequest(id, nonce)` and return `errRequestVoided`; the bound write makes this safe: a PUT transmitted against `ExpectSeq` cannot commit on a record that has moved past it; after a confirmed write: `bumpSerialErr` and, on success, `SetSelfServeNotifiedSeq(id, rec.RecordSeq)`; a failed bump leaves `notified_seq` behind and every later tick bumps again while `rec.RecordSeq != notified_seq`. A crash anywhere resumes at (3) on the next tick from the persisted request.
+- `type persistedRequest struct { Kind, Phase, Nonce string; ExpectInc, ExpectSeq uint64; Proposed time.Time; Confirmed, Consumed, Voided bool }`; `func (s *Server) selfServeApply(ctx context.Context, t *leaseToken, row *SelfServeMove, req request, proposed *time.Time, mut func(at time.Time, rc provision.Receipt) func(*provision.Record) error) (*provision.Record, error)` with `type request struct { Kind, Phase string }`: (1) the judged record → `pr.ExpectInc = rec.Incarnation`, `pr.ExpectSeq = rec.RecordSeq`; `pr.Proposed = *proposed` when given (the open intent) else `max(now, latest stamp on the record + 1 ns)`; `pr.Nonce = random`; `IssueSelfServeRequest(id, kind, phase, pr, now)`: not issued and `existing == nil` → `errRequestRefused` (the row left the phase or ended; the step ends); `existing.Kind != req.Kind || existing.Phase != req.Phase` → return `errRequestForeign` (this caller never interprets another request; the step ends and reconciliation decides next tick); `existing.Confirmed && !existing.Consumed` → **resume**: return the record (the caller continues its wait and the notification catch-up); otherwise `pr = *existing` and go to (3); (2) **first, when `pr.Deadline != 0`, `now < pr.Deadline`**, else return `errRequestExpired` without writing (the step's fence-and-classify path takes over); a request with `Deadline == 0` is never expired by this check (`TestLateEvidenceStillClearsTheMove`: a source's `gone` report arriving after `finishing`'s deadline → the finish is written and the reservations release; the same for `reverting`'s facts and for an already-issued uncommitted finish recovering after the deadline); then `provision.SetRecordMoveAt(ctx', s, acct, app, host, provision.Expect{Incarnation: pr.ExpectInc, Seq: pr.ExpectSeq}, mut(pr.Proposed, Receipt{pr.Nonce}))` with the lease-bounded context and the write guard: **one CAS attempt against the record at that incarnation and sequence; no fresh-read retry inside**; (3) **confirmation** from a fresh judged read, whatever (2) returned: `rec` carries the receipt (`SelfServe.LastRequest == pr.Nonce`, or `LastMove.Request == pr.Nonce` for a clearing write) → `ConfirmSelfServeRequest(id, nonce, now)` (**the receipt alone confirms**; a box that has already advanced the record past the written state (seal → sealed, flip → landed) does not change that; the `confirmed` callback is dropped and phase progress is reconciliation's business) (false → re-read: already confirmed → resume; voided → stop); else `rec.Incarnation == pr.ExpectInc && rec.RecordSeq == pr.ExpectSeq` (unchanged) → the write did not land: repeat (2), **which re-checks the deadline first** (idempotent replay of the same bound write; bounded by the lease and the deadline; `TestReplayStopsAtTheDeadlineInsideOneInvocation`: an open request whose PUT fails at T-1 s and whose confirmation read returns at T+1 s is not replayed; the step fences and classifies; the same with the PUT having committed → confirmed); else (the record advanced without the receipt: another writer, an operator) → `VoidSelfServeRequest(id, nonce)` and return `errRequestVoided`; the bound write makes this safe: a PUT transmitted against `ExpectSeq` cannot commit on a record that has moved past it; after a confirmed write: `bumpSerialErr` and, on success, `SetSelfServeNotifiedSeq(id, rec.RecordSeq)`; a failed bump leaves `notified_seq` behind and every later tick bumps again while `rec.RecordSeq != notified_seq`. A crash anywhere resumes at (3) on the next tick from the persisted request.
- `type provision.Expect struct { Incarnation, Seq uint64 }`; `provision.SetRecordMoveAt(ctx, s, acct, app, host string, expect Expect, mut) (*provision.Record, error)`: reads the judged record; `rec.Incarnation != expect.Incarnation || rec.RecordSeq != expect.Seq` → `ErrCASConflict` without writing; otherwise one `WriteRecord` against the observed ETag; a CAS conflict → `ErrCASConflict` (no retry). `SetRecordMove` (today's, with its retry) is no longer used by any self-serve or operator move writer.
- `selfServeStep`: (1) the row's deadlines: an expired `StepDeadline`, or an expired `WindowDeadline` while `cut_requested_at = 0`, → `SetSelfServeExpiry(id, phase, true)` (no action yet); (2) the judged record; (3) **reconcile forward** by the compatible sets (Task 4): an unconfirmed request → step (3) of `selfServeApply` first, **except** when the request's own `request_deadline` has passed (whatever the row's current phase: `landed`, `open-uncertain` or any other) and the record is at or before `offline-landed`: then the record is observed once; the receipt present → confirmed (the write won before the expiry, the expiry is post-write); absent → `fenceRecord` (plan 1; so the outstanding bound PUT can never land) and **the fenced observation is classified**: the receipt present in it → the write won during the fence: confirm and proceed forward (an open that committed is reconciled as opened, never aborted); absent → `VoidSelfServeRequest` and the expiry action runs (abort), **never a replay** (`TestExpiredOpenIsNeverReplayed`); then the row's phase not in the record's set → `AdvanceSelfServe` to the set's first phase (without the abort predicate for `opened`), which clears a foreign expiry; (4) the expiry action for an `expiry_pending` whose `expiry_phase == phase`: the record at or before `offline-landed` → abort; committed open → the blocked action (page); unreadable → `open-uncertain`; `health` → warning, page, `finishing`; (5) the abort step when requested and the record at or before `offline-landed`; (6) the phase function. Every wait for a report is for `Request`/`StagedRequest == request_proposed` of the **confirmed** request; **supersession by stamp**: each tick the waiting phase also reads the record's stamp for the awaited field (`StagedAt` for `stage`/`maintain`, `PrecheckAt` for `precheck`); when it is **later** than `request_proposed` while the receipt is unchanged, an operator round has superseded the job's (`OfflineRestage`/`OfflinePrecheck` from the operator doors move the stamp without a receipt), so the job `AbandonSelfServeRequest`s its request and, on the next step, issues a fresh bound round (the abandon is one UPDATE; a crash after it finds no outstanding request and issues; a crash before it repeats the comparison); the job never adopts an operator round as its own (`TestOperatorRoundSupersedesTheJobsWait`: a confirmed restage at S1, an operator stage to S2, the stager reports S2 only → the job abandons S1 and issues S3, the cut proceeds; the same in `staging/round` and `ready/round`; a crash between the abandon and the issue); the report is consumed by the `AdvanceSelfServe` that leaves the waiting phase, with the nonce, **or**, inside a phase that writes more than once (`stopping`: start then seal; `finishing`: gate 1 then gate 2; `staging`: round after round; `prechecks`: the precheck then the estimate; `ready`: each maintenance round), by `ConsumeSelfServeRequest(id, nonce)` once its wait is satisfied, immediately before the next request is issued, and **the phase function dispatches on `phase_step`**: `stopping` = `start` → `seal`; `finishing` = `gate1` → `gate2`; `prechecks` = `precheck` → `estimate`; `reverting` = `abort` → `finish`; `staging` and `ready` = `round` (repeated); a phase entered by `AdvanceSelfServe` from the **job's own** progression starts at its first substep (`phase_step` set by the advance); a phase entered by **reconciliation** starts at the substep the record dictates (below); a restart resumes the substep recorded, never the phase's first write (`TestSubstepsResumeAfterEveryCrash`: a crash after consuming start and before issuing seal re-enters at `seal`; a crash with the estimate request outstanding re-enters at `estimate` and resumes it; the same for gate 1 → gate 2 and abort → finish). **Abandonment** is for **leaving a wait**, never for retaining it: when abort is requested, when an expiry whose action is **abort** fires, or when an operator transition supersedes the phase, a confirmed request whose wait is unsatisfied is `AbandonSelfServeRequest`ed before the abort or the reconciliation step issues its own request; an expiry whose action is page-and-retain (`opened`, `finishing`, `reverting`, `open-uncertain`) or warn-and-finish (`health`) abandons nothing and the wait continues (`TestPageOnlyExpiriesKeepTheWait`: the open request confirmed, `opened` missing at the deadline, the page sent, then the target reports opened and the nonce-bearing advance succeeds). By phase: `requested`/`reserving` resolve or fence an issued queue request before finalising or releasing; `queued`/`reports` abandon a superseded wait, a queued abort clears and finalises atomically; `staging`/`prechecks`/`ready` may abandon on cancel or aborting expiry; `restage`/`barrier`/`stopping`/`sealing`/`landing`/pre-open `landed` may abandon on aborting expiry after resolving the uncertain write; `open-uncertain` abandons nothing; a confirmed clearing write is consumed with the terminal outcome (`TestAbandonedRequestsNeverStrandAbort`: a confirmed stage request whose copy never reports, then cancel → the abort request issues; a confirmed precheck whose box died, then the deadline → abort issues). **Clearing requests**: `OfflineFinish`, `OfflineAbortAt` and the abort path's finish are confirmed from `LastMove.Request`; the finaliser's `FinishSelfServeMove` consumes that confirmed request in the same transaction as the outcome, so a crash between confirmation and finalisation resumes at the finaliser, not at a re-issue.
- **Reconciliation and recovery table** (the record is the truth):
@@ -186,7 +186,7 @@ Builds on plan 1 (`2026-10-22-self-serve-moves-1-lifetimes-and-order.md`, rev 13
- `reports`: the `lane` (`OK`), the source `precheck` and the target `ready` with `Request == PrecheckAt`, each from the pinned participant; `targetReadyDecision` and `sourcePrecheckDecision` (the console enumerates the required path set and fact set itself: a missing entry or `!Known` → "could not determine <fact> on <label>"; not OK → the fact's sentence: "the target box already has a route for <host>", "the target box has timers for <host>", "the target box has a stale live link for <host> naming <build>", "the target box's programs file names <build>", "the target box already holds data for <host>: <path>", "the target box's park directory is not clean", "the target box has too little free disk for <host>" (`!Known` or `< 2 × replica`), "the target bucket already holds objects for <host>", "the target bucket holds a lock for <host> (<owner>)", "<host> has a stray entry beside its files directory: <path>", "<host>'s files cannot be archived: <detail>"; before staging both prefixes must be `Known` and empty); both pass → `selfServeApply(stage, OfflineQueuedToStaging)`, `StagingStartedAt = now`, → `staging`; 5 min → abort.
- `staging`: the stager report with `StagedRequest == row.StageRequest`: the ready rule `Rounds >= 2 && RoundCopiedBytes + RoundDeferredBytes < 16 MiB` → `prechecks`; else `selfServeApply(stage, OfflineRestage)` → a new wait; a `failed` report → abort with its `Detail`; a round past `max(10 min, 3 × est.Copy)` → abort; `now - StagingStartedAt > 30 min` or `Rounds >= 10` → `prechecks` from the last completed round.
- `prechecks`: `selfServeApply(precheck, OfflinePrecheck)`; both participants' reports answering it; the staged-ownership comparison (Task 6); `InstancesPrefix` known and empty; both pass → `ready`, `WindowDeadline = now + 24 h`, the estimate refined and `OfflineSetEstimate` (a later disagreement resolves towards the row); 5 min → abort.
- - `ready`: every 10 min a **maintenance restage** (request kind `maintain`: `selfServeApply(maintain, OfflineRestage)`, the matching `staged` report consumed by `ConsumeSelfServeRequest`; the row stays `ready`, `WindowDeadline` is **not** touched, no prechecks run); `WindowDeadline` → abort "the move was not confirmed in time"; **the cut handoff**: when `cut_requested_at` is set: an outstanding `maintain` request → it becomes the first cut round: its report is awaited (deadline `max(5 min, 2 × est.Copy)` from `cut_requested_at`; expiry → abort) and consumed by `AdvanceSelfServe(ready, restage, ..., nonce)` **which sets `phase_step = adopted`**, after which `restage` issues nothing new and goes straight to `barrier`; no outstanding request → `AdvanceSelfServe(ready, restage, ..., "")` **which sets `phase_step = issue`** and `restage` issues its own round; the substep is written in the advancing transaction, so a crash after either advance resumes the continuation that was chosen (`TestCutHandoffSurvivesACrash`: a crash right after each branch and another during its recovery). The view shows `cutting` from `cut_requested_at`.
+ - `ready`: **idle between rounds** (`step_deadline = 0`; the only deadline is `WindowDeadline`); when `now >= next_maintenance_at` a **maintenance restage** is issued (request kind `maintain`: `selfServeApply(maintain, OfflineRestage)` with the round's allowance `max(5 min, 2 × est.Copy)` as its `step_deadline` and `request_deadline`; the matching `staged` report consumed by `ConsumeSelfServeRequest(..., nextDeadline: 0)`, which also sets `next_maintenance_at = now + 10 min`; the row stays `ready`, `WindowDeadline` is **not** touched, no prechecks run; `TestMaintenanceRoundsHaveTheirOwnAllowance`: a round completing at minute 9 does not shorten the next; the idle interval never expires anything); `WindowDeadline` → abort "the move was not confirmed in time"; **the cut handoff**: when `cut_requested_at` is set: an outstanding `maintain` request → it becomes the first cut round: its report is awaited (deadline `max(5 min, 2 × est.Copy)` from `cut_requested_at`; expiry → abort) and consumed by `AdvanceSelfServe(ready, restage, ..., nonce)` **which sets `phase_step = adopted`**, after which `restage` issues nothing new and goes straight to `barrier`; no outstanding request → `AdvanceSelfServe(ready, restage, ..., "")` **which sets `phase_step = issue`** and `restage` issues its own round; the substep is written in the advancing transaction, so a crash after either advance resumes the continuation that was chosen (`TestCutHandoffSurvivesACrash`: a crash right after each branch and another during its recovery). The view shows `cutting` from `cut_requested_at`.
- The pre-offline abort (requested, reserving, queued, staging): **no row is finalised while a request is outstanding**: an outstanding request is resolved first (confirmed → the record carries the move, so this is not the no-move branch; unconfirmed → `fenceRecord`, classify the fenced observation, void if the receipt is absent); then no record move → the finaliser (`aborted`; `source_reopened_at = abort_requested_at`); queued → `selfServeApply(abort, OfflineAbortAt, )`; staging → `OfflineAbort` → the stager's `reverted` → `OfflineFinish`; then the finaliser.
- [ ] **Step 1: Write the failing tests** on the bridge of Task 10: `TestEveryBoundaryRecovers` (Review Focus 1: for every write, a crash before the write with the request persisted, a lost response, a crash before confirmation, a crash before the bump, a second crash during recovery, SQLite reopened each time; a fake store that **commits after the context was cancelled**: the replay sees the proposed stamp and confirms, no second write; one request per wait, one bump per landed write; ordinary multi-tick progression through start and flip); `TestReconcileForwardNeverRegresses` (every row of the table; `stopping` with no request issued yet and the record still `offline-staging` stays; `stopping(pending)` likewise; `landing` with no request yet and `offline-sealed` stays; health and finishing survive ticks at `offline-open`); `TestRequestsAreNeverReissuedWhileUnconsumed`; `TestStaleEvidenceNeverSatisfiesANewRequest` (an issued-but-unwritten restage: the previous round's report does not satisfy it; after the crash the replay lands the proposed stamp and only a report carrying it does); `TestAbandonedAdmissionIsFencedAndConditional` (a queue PUT committing after a negative re-read: the fence observes the move and the job continues as `queued`; a PUT that never lands: the fence succeeds, the row is voided durably before the release); `TestReplacedParticipantAtEveryGate`; `TestReservingWaitsThenAborts`; `TestEligibilityRechecksBeforeQueue`; `TestReportsDecideFromFacts`; `TestStagingReadyRule`; `TestPrechecksCompareTheBook`; `TestReadyRestagesAndExpires` (the maintenance restage keeps the row in `ready`, keeps `WindowDeadline`, never enters `restage`/`barrier`, and the cut still needs its door); `TestExpiredOpenIsNeverReplayed` (an open request persisted, the PUT never sent, a restart after the landed deadline: the record is fenced, the request voided, the move aborts; the same with the PUT committed before the crash → confirmed, the move proceeds as opened; **the PUT committing between the observation and the fence** → the fenced observation carries the receipt, the move proceeds as opened); `TestConfirmedRequestResumesAfterACrash` (start confirmed, crash before consume → the next tick resumes the wait, no re-issue, one bump); `TestSamePhaseSequencesConsume` (start then seal, gate 1 then gate 2, two staging rounds, the estimate after the precheck); `TestForeignRequestStopsTheCaller`; `TestNoOpMutationsStillConfirm` (an equal estimate, a repeated abort on a reverting record); `TestCutHandoffWithAnOutstandingMaintenanceRound` (cut requested while a maintain request is outstanding: its report becomes the first cut round; cut requested with none outstanding: restage issues; cancel after cut → 409); `TestBoundWritesNeverLandAfterAVoid` (another writer advances the record after a request was issued: the bound write fails, the request is voided, no later commit appears; a delete and recreate whose new record has the same sequence → refused on incarnation); `TestDeadlinesEvaluateBeforeEvidence`; `TestAbortBeforeOfflineIsLossless`; `TestQueueOrderAndDisjointProgress` (three rows: two on one box in order, a third on disjoint boxes advances while the second waits).
@@ -241,5 +241,5 @@ Builds on plan 1 (`2026-10-22-self-serve-moves-1-lifetimes-and-order.md`, rev 13
- Spec coverage: §5 (T1), §6.1 (T5), §6.2 (T7), §6.3 (T9, T5, T6), §7 admission and recovery (T7's table), §7.1 (T3), §7.2 and the three facts with the sealer's durable lift (T6, T8), §7.3 (T6, T7, T8's barrier), §7.4 without the probe mode (T8), §7.5 the no-return boundary and the operator return (T2's open record, T8), §7.7 (T5), §8 pages, log and member stream (T8, T9), §9 (T3's rollout). Deferred: §6.4 holding page and the probe headers (plan 3); §7.6 cleanup (plan 4), whose status contract the reclaim rule names (`RetirementsComplete`, a terminal state, `RootRequest == ""`, `ReleasePending == false`, `Metrics.BoxID`).
- Dependencies for plan 3: `SelfServeMove{DisplayName, OfflineAt, EstimateSeconds}`, `Participants.Target.BoxID`, `moveCfg.BoxID`, `probeTarget`; for plan 4: the status `Retirements` contract and the open record.
-- Names used across tasks: `selfServeGate`, `instanceRails(w, r, acct, noun, c)`, `moveCapable`, `Principal.Moves/TokenID`, `selfServeMode`, `selfServeAdmits`, `recordEligibility`, `offlineRecordChecks`, `offlineRegionChecks`, `offlineSelection`, `OfflineQueued`, `OfflineQueue`, `OfflineQueuedToStaging`, `OfflinePrecheck`, `OfflineSetEstimate`, `OfflineAbortAt`, `provision.SelfServeMove`, `Participant{BoxID, Label, InstanceID, Verified}`, `Participants`, `moves.OpenRecord{..., Layout}`, `OpenRecordKey`, `moves.ParticipantRole`, `moves.ParticipantStillPresent`, `moves.OfflineReservation`, `reserveBoxes`, `releaseBoxes`, `selfServeTick`, `selfServeStep`, `selfServeApply`, `request`, `persistedRequest`, `provision.Receipt`, `provision.Expect`, `ConsumeSelfServeRequest(id, nonce, nextStep, nextDeadline)`, `errRequestExpired`, `AbandonSelfServeRequest`, `phase_step` (`adopted`/`issue` in `restage`), `participantMatches`, `SetSelfServeExpiry(id, phase, kind, sawDeadline, pending)`, `expiry_kind`/`expiry_deadline`, supersession by stamp, `errRequestRefused`, `MarkSelfServeCut`/`cut_requested_at`, `errRequestForeign`, `OfflineReport.RequestKey`, `DiskFreeBytes`, `OfflineReservation.ExpectIncarnation`, `SelfServeMove.LastRequest`, `LastMove.Request`, `provision.SetRecordMoveAt`, `OfflineReservation.ExpectSeq`, `moves.FailedDetail`, `OfflineReport.Layout`, `errRequestVoided`, `selfServeFinalise`, `leaseToken`, `provision.WithWriteGuard`, `IssueSelfServeRequest`, `ConfirmSelfServeRequest`, `VoidSelfServeRequest`, `request_voided`, `AdvanceSelfServe(..., nonce)`, `SetSelfServeExpiry`, `SetSelfServeNotifiedSeq`, `SetSelfServeOpenRecordPending`, `InsertSelfServeEventTx`, `DeliverSelfServeEvent`, `selfServeMoveView`, `selfServeAbortView`, `selfServeLast`, `moves.Fact`, `PrefixFacts`, `PrefixObject`, `StagedObject{Size, ETag, VersionID, Deferred}`, `LaneReport`, `TargetReadyReport`, `SourcePrecheck{..., Layout}`, `StrayEntry`, `StraysFact`, `ReturnLeftovers`, `ValidateOfflineReportFor`, `readOfflineEvidence(ctx, host, id, m)`, `readReturnReport`, `lease.CopyEvent`, `lease.MirrorPrefixKeepObserved`, `stageBook`, `status.HostFacts`, `BoxMetrics.Hosts/Lanes/BoxID/InstanceID`, `measureFiles`, `targetReadyDecision`, `sourcePrecheckDecision`, `probeTarget`, `estimateTerms`, `MarkSelfServeTiming`, `SelfServeEvent`, `host.E2E`/`NewE2E`, `console.TestHooks`.
+- Names used across tasks: `selfServeGate`, `instanceRails(w, r, acct, noun, c)`, `moveCapable`, `Principal.Moves/TokenID`, `selfServeMode`, `selfServeAdmits`, `recordEligibility`, `offlineRecordChecks`, `offlineRegionChecks`, `offlineSelection`, `OfflineQueued`, `OfflineQueue`, `OfflineQueuedToStaging`, `OfflinePrecheck`, `OfflineSetEstimate`, `OfflineAbortAt`, `provision.SelfServeMove`, `Participant{BoxID, Label, InstanceID, Verified}`, `Participants`, `moves.OpenRecord{..., Layout}`, `OpenRecordKey`, `moves.ParticipantRole`, `moves.ParticipantStillPresent`, `moves.OfflineReservation`, `reserveBoxes`, `releaseBoxes`, `selfServeTick`, `selfServeStep`, `selfServeApply`, `request`, `persistedRequest`, `provision.Receipt`, `provision.Expect`, `ConsumeSelfServeRequest(id, nonce, nextStep, nextDeadline)`, `errRequestExpired`, `AbandonSelfServeRequest(id, nonce, nextDeadline)`, `next_maintenance_at`, `phase_step` (`adopted`/`issue` in `restage`), `participantMatches`, `SetSelfServeExpiry(id, phase, kind, sawDeadline, pending)`, `expiry_kind`/`expiry_deadline`, supersession by stamp, `errRequestRefused`, `MarkSelfServeCut`/`cut_requested_at`, `errRequestForeign`, `OfflineReport.RequestKey`, `DiskFreeBytes`, `OfflineReservation.ExpectIncarnation`, `SelfServeMove.LastRequest`, `LastMove.Request`, `provision.SetRecordMoveAt`, `OfflineReservation.ExpectSeq`, `moves.FailedDetail`, `OfflineReport.Layout`, `errRequestVoided`, `selfServeFinalise`, `leaseToken`, `provision.WithWriteGuard`, `IssueSelfServeRequest`, `ConfirmSelfServeRequest`, `VoidSelfServeRequest`, `request_voided`, `AdvanceSelfServe(..., nonce)`, `SetSelfServeExpiry`, `SetSelfServeNotifiedSeq`, `SetSelfServeOpenRecordPending`, `InsertSelfServeEventTx`, `DeliverSelfServeEvent`, `selfServeMoveView`, `selfServeAbortView`, `selfServeLast`, `moves.Fact`, `PrefixFacts`, `PrefixObject`, `StagedObject{Size, ETag, VersionID, Deferred}`, `LaneReport`, `TargetReadyReport`, `SourcePrecheck{..., Layout}`, `StrayEntry`, `StraysFact`, `ReturnLeftovers`, `ValidateOfflineReportFor`, `readOfflineEvidence(ctx, host, id, m)`, `readReturnReport`, `lease.CopyEvent`, `lease.MirrorPrefixKeepObserved`, `stageBook`, `status.HostFacts`, `BoxMetrics.Hosts/Lanes/BoxID/InstanceID`, `measureFiles`, `targetReadyDecision`, `sourcePrecheckDecision`, `probeTarget`, `estimateTerms`, `MarkSelfServeTiming`, `SelfServeEvent`, `host.E2E`/`NewE2E`, `console.TestHooks`.
- Review Focus 1 to 5 are pinned by T7, T3, T8, T5, T1.