plan: self-serve moves 2, rev 2 (Astra plan 2 review 1: 20 findings)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+166
−141
docs/superpowers/plans/2026-10-22-self-serve-moves-2-door-token-job.md+166 −141
CI passed — run details
CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (0 s)
ok amadan.net/carlos/platform.git/launchctx 0.016s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
=== vet (1 s)
=== gofmt (0 s)
gofmt -l .: clean
=== rest (143 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.403s
ok github.com/carlosframework/platform/cmd/moveprobe 11.895s
ok github.com/carlosframework/platform/cmd/zddprobe 0.744s
ok github.com/carlosframework/platform/internal/activator 61.408s
ok github.com/carlosframework/platform/internal/agentsock 0.017s
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.919s
ok github.com/carlosframework/platform/internal/apperr 0.185s
ok github.com/carlosframework/platform/internal/applog 0.287s
ok github.com/carlosframework/platform/internal/awsinv 0.006s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.812s
? 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.015s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.751s
ok github.com/carlosframework/platform/internal/changelog 0.578s
ok github.com/carlosframework/platform/internal/claimactivation 1.209s
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 6.314s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.010s
ok github.com/carlosframework/platform/internal/dbverify 0.009s
ok github.com/carlosframework/platform/internal/dnsrec 0.019s
ok github.com/carlosframework/platform/internal/docsite 0.013s
ok github.com/carlosframework/platform/internal/domains 2.959s
ok github.com/carlosframework/platform/internal/doorkey 0.009s
ok github.com/carlosframework/platform/internal/economics 0.008s
ok github.com/carlosframework/platform/internal/edge 45.253s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.012s
ok github.com/carlosframework/platform/internal/fleetapi 0.187s
ok github.com/carlosframework/platform/internal/gate 1.904s
ok github.com/carlosframework/platform/internal/genselect 0.021s
ok github.com/carlosframework/platform/internal/geo 0.021s
ok github.com/carlosframework/platform/internal/geoip 0.034s
ok github.com/carlosframework/platform/internal/historyreadback 0.262s
ok github.com/carlosframework/platform/internal/host 140.600s
ok github.com/carlosframework/platform/internal/hostactors 6.157s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.006s
ok github.com/carlosframework/platform/internal/hostbox 3.115s
ok github.com/carlosframework/platform/internal/hostclaim 0.761s
ok github.com/carlosframework/platform/internal/hostenv 0.005s
ok github.com/carlosframework/platform/internal/hostevidence 0.005s
ok github.com/carlosframework/platform/internal/hostexec 5.515s
ok github.com/carlosframework/platform/internal/hostfence 0.041s
ok github.com/carlosframework/platform/internal/hostfiles 10.207s
ok github.com/carlosframework/platform/internal/hostfloor 0.016s
ok github.com/carlosframework/platform/internal/hostguard 1.046s
ok github.com/carlosframework/platform/internal/hostops 2.180s
? 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.004s
ok github.com/carlosframework/platform/internal/hostrelocate 0.657s
ok github.com/carlosframework/platform/internal/hostresolve 0.278s
ok github.com/carlosframework/platform/internal/hostwrite 0.005s
ok github.com/carlosframework/platform/internal/infraview 0.005s
ok github.com/carlosframework/platform/internal/instancectl 5.223s
ok github.com/carlosframework/platform/internal/instanceexec 2.805s
ok github.com/carlosframework/platform/internal/instancefs 0.007s
ok github.com/carlosframework/platform/internal/instancekey 0.003s
ok github.com/carlosframework/platform/internal/instanceupdates 0.007s
ok github.com/carlosframework/platform/internal/layout 1.929s
ok github.com/carlosframework/platform/internal/lbhealth 0.003s
ok github.com/carlosframework/platform/internal/lease 10.173s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.593s
ok github.com/carlosframework/platform/internal/ledger 0.018s
ok github.com/carlosframework/platform/internal/mail 0.259s
ok github.com/carlosframework/platform/internal/maintenance 0.085s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.601s
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.442s
ok github.com/carlosframework/platform/internal/moves/archive 1.080s
ok github.com/carlosframework/platform/internal/objstore 0.324s
ok github.com/carlosframework/platform/internal/ops 0.232s
ok github.com/carlosframework/platform/internal/park 0.280s
ok github.com/carlosframework/platform/internal/plans 0.010s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 13.194s
ok github.com/carlosframework/platform/internal/publicrelease 0.009s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.609s
ok github.com/carlosframework/platform/internal/relay 7.230s
ok github.com/carlosframework/platform/internal/release 24.675s
ok github.com/carlosframework/platform/internal/rollout 3.740s
ok github.com/carlosframework/platform/internal/rolloutwords 0.008s
ok github.com/carlosframework/platform/internal/rootaws 0.008s
ok github.com/carlosframework/platform/internal/rootparticipation 0.059s
ok github.com/carlosframework/platform/internal/rootstate 0.028s
ok github.com/carlosframework/platform/internal/runs 0.540s
ok github.com/carlosframework/platform/internal/s3compat 0.008s
ok github.com/carlosframework/platform/internal/safefile 0.005s
ok github.com/carlosframework/platform/internal/schedule 2.708s
ok github.com/carlosframework/platform/internal/seeds 0.015s
ok github.com/carlosframework/platform/internal/sim/hostlife 10.563s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.530s
ok github.com/carlosframework/platform/internal/status 0.075s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.214s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.006s
ok github.com/carlosframework/platform/internal/termfence 0.011s
ok github.com/carlosframework/platform/internal/tunnelclient 0.011s
ok github.com/carlosframework/platform/internal/unixdial 0.008s
ok github.com/carlosframework/platform/internal/updatewords 0.007s
ok github.com/carlosframework/platform/internal/usage 0.010s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.650s
=== internal_console@0 (78 s)
ok github.com/carlosframework/platform/internal/console 76.574s
=== internal_console@1 (37 s)
ok github.com/carlosframework/platform/internal/console 34.896s
=== internal_console@2 (42 s)
ok github.com/carlosframework/platform/internal/console 40.141s
=== internal_console@3 (37 s)
ok github.com/carlosframework/platform/internal/console 35.064s
=== internal_console@4 (36 s)
ok github.com/carlosframework/platform/internal/console 34.747s
=== internal_console@5 (35 s)
ok github.com/carlosframework/platform/internal/console 33.028s
=== internal_console@6 (36 s)
ok github.com/carlosframework/platform/internal/console 33.929s
=== internal_console@7 (43 s)
ok github.com/carlosframework/platform/internal/console 40.931s
=== root@0 (32 s)
ok github.com/carlosframework/platform 30.201s
=== root@1 (44 s)
ok github.com/carlosframework/platform 42.486s
=== root@2 (42 s)
ok github.com/carlosframework/platform 40.100s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 50069666 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.029s
=== RUN TestT4StorageProbeMinio
2026/10/22 07:52:22 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E0CAB7E175B0D5, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.023s
minio contract: PASS
diff --git a/docs/superpowers/plans/2026-10-22-self-serve-moves-2-door-token-job.md b/docs/superpowers/plans/2026-10-22-self-serve-moves-2-door-token-job.md| index 10381d5c7..9e0f2c460 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,244 +2,268 @@ |
| > **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 1, 2026-10-22. Builds on plan 1 (`2026-10-22-self-serve-moves-1-lifetimes-and-order.md`): records carry a lifetime and `move_seq`, rows carry the pair. 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). |
| +Rev 2, 2026-10-22. Rev 1 was reviewed by Astra (`after236/selfserve-plan2-review.md`, NOT READY, 20 findings). Rev 2 answers them: authorisation before any evidence read; one open move per host enforced by the store and every phase change a predicated update; the admitted lifetime pinned on the row and checked inside the queue mutation; one shared start validation with every existing check; explicit box dispatch for the pre-stop reports and a cut barrier that waits for them; initial emptiness and staged ownership as two checks; a pass/refuse/unknown rule for every fact; participants pinned by box id and enforced at every transition; the catch-up rule from copied plus deferred bytes; a journal that can express every wait, a recovery table per write boundary and a console lease; a serial bump on every transition; the no-return boundary at the committed record state; outcome before release with a pending-release state; a mixed-version protocol for `active.json`; paging armed in this mode with durable attempts and an orphan scan; the three abort facts in storage and the view; rendering and recovery independent of admission with a self-serve view type that does not collide with Phase 1; status facts with identity, age and error; the queued state's exact semantics; and every interface the review found missing. |
| -**Goal:** With `CARLOS_SELF_SERVE_MOVES=offline` on a claims-off, Phase-1-off console, Tito's existing residency calls (prepare, status, cut, cancel) run the offline move as a console job: an owner-delegated move-capable token asks, the door refuses what cannot be offered before anything is written, per-box reservations serialise moves per box, the boxes prove the lane and the target's cleanliness before any copy, staging runs until caught up, the cut runs start, seal, flip, open, health, finish with a deadline on every wait, every failure before `open` initiates a lossless abort, and the view tells Tito `preparing`, `ready`, `cutting`, `none` with `last`. |
| +Builds on plan 1 (`2026-10-22-self-serve-moves-1-lifetimes-and-order.md`): records carry a lifetime and `move_seq`, rows carry the pair, readers and writers are judged. 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). |
| -**Architecture:** One `self_serve_moves` SQLite row per move drives a console pass (`StartSelfServeMovesPass`, every 5 s) that advances each move by at most one step per tick from the evidence the operator doors already read (the record, the edges files, the boxes' status reports, the reports under `control/offline-moves/`), through the door bodies refactored into functions the doors and the job share. A new record state `offline-queued` lets the console reserve boxes and collect the lane and target-ready reports before a byte is copied. `control/offline-moves/active.json` becomes a per-box reservation table. The move-capable token is a `moves` column on `cli_tokens`, owner-minted, checked at the three self-serve mutations. |
| +**Goal:** With `CARLOS_SELF_SERVE_MOVES=offline` on a claims-off, Phase-1-off console, Tito's existing residency calls (prepare, status, cut, cancel) run the offline move as a console job: an owner-delegated move-capable token asks, the door refuses what cannot be offered before anything is written, per-box reservations serialise moves per box, the boxes prove the lane, the target's cleanliness and the source's eligibility before any copy and again before the stop, staging runs until caught up, the cut runs start, seal, flip, open, health, finish with a deadline on every wait, every failure before the committed `open` initiates a lossless abort, and the view tells Tito `preparing`, `ready`, `cutting`, `none` with `last` and the abort's three facts. |
| -**Tech Stack:** Go. `internal/console` (doors, store, passes, alerting), `internal/provision` (record transitions), `internal/moves` (reports, reservations), `internal/host` (the box's offline round, status report), `internal/moves/archive` (the eligibility dry run), `internal/alerting`. Tests: the console in-process with `release.NewDirStore`, and the two-box harness of `TestOfflineMoveE2E` (`internal/host/movesoffline_e2e_test.go`). |
| +**Architecture:** One `self_serve_moves` SQLite row per move drives a console pass (`StartSelfServeMovesPass`, every 5 s, under a console lease in the deployment bucket) that advances each move by at most one step per tick from the evidence the operator doors already read (the record, the edges files, the boxes' status reports, the reports under `control/offline-moves/`), through the door bodies refactored into functions the doors and the job share. A new record state `offline-queued` lets the console reserve boxes and collect the lane, source-precheck and target-ready reports before a byte is copied; a precheck request stamp on the record makes both participants report again before the cut. `control/offline-moves/active.json` becomes a per-box reservation table with a legacy projection. The move-capable token is a `moves` column on `cli_tokens`, owner-minted, checked at the three self-serve mutations before any record is read. |
| -**Spec:** `docs/superpowers/specs/2026-10-21-self-serve-offline-moves-design.md` §4, §5, §6.1 to §6.3, §7 (except §7.4's probe mode and §7.6), §8, §11. |
| +**Tech Stack:** Go. `internal/console` (doors, store, passes, alerting), `internal/provision` (record transitions), `internal/moves` (reports, reservations), `internal/host` (the box's offline round, status report), `internal/moves/archive` (the eligibility dry run), `internal/alerting`, `internal/status`. Tests: the console in-process with `release.NewDirStore`, and the two-box harness of `TestOfflineMoveE2E` (`internal/host/movesoffline_e2e_test.go`) driven through the real serial bump. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-10-21-self-serve-offline-moves-design.md` §4, §5, §6.1 to §6.3, §7 (except §7.4's probe mode and §7.6), §8, §11. Two stated deviations: the record's `self_serve` is an object, not a boolean (plan 3 reads its fields); the estimate's pre-request input is the replica size alone (the status report publishes no "last-day churn"; the rolling medians carry the rates). |
| ## Global Constraints |
| -- Behind `CARLOS_SELF_SERVE_MOVES` (`cfg.SelfServeMoves string`; only the value `offline` enables anything), and only when `!cfg.HostClaim && !cfg.MovesEnabled`. With the switch off every door behaves byte for byte as today (`TestSwitchOffIsByteIdentical`). |
| -- The operator doors (`.../move/offline*`) keep their behaviour byte for byte; their bodies move into functions the job calls (`offlineStepFor` is already that shape). A job tick never calls an HTTP door. |
| -- Every wait has a deadline (§7.2's table, Task 8); expiry before `open` initiates abort; after `open` it pages and retries. The job never calls `finish` without the gate's evidence (`offlineFinishCheck`). |
| -- A lost response to any record write is an uncertain commit: the next tick re-reads the record by the move's id before deciding. |
| -- Two console processes never run the pass at once (the existing single-console assumption). |
| +- Behind `CARLOS_SELF_SERVE_MOVES` (`cfg.SelfServeMoves string`; only `offline` enables **admission**), and only when `!cfg.HostClaim && !cfg.MovesEnabled`. **Rendering and recovery** of a self-serve move are enabled by the existence of its row, not by the switch: with the switch off, an open move runs to completion, its view keeps the self-serve shape until Tito has read its terminal `last`, and no new move is admitted. With no row for the host the four doors behave byte for byte as today (`TestSwitchOffIsByteIdentical`). |
| +- The operator doors (`.../move/offline*`) keep their answers byte for byte (fixtures snapshot every refusal of the start, stage, seal, flip, open, finish and abort doors before and after); their bodies move into functions the job calls. A job tick never calls an HTTP door. |
| +- Every wait has a deadline (§7.2's table, Task 8); expiry before the committed `open` initiates abort; after it pages and retries. The job never calls `finish` without the gate's evidence. |
| +- Every record transition the job writes goes through one helper (`selfServeApply`, Task 7) that re-reads the record by the move id on a lost response and bumps `control/serial` after every landed write, including one it discovers landed. |
| +- One console runs the pass: the pass holds `control/offline-moves/console.json` (a 60 s lease, conditional writes, the console's identity) and a second console refuses to run it (Task 4). |
| - Refusals come before writes: the door writes the `self_serve_moves` row only after every check in §6.1's refusal list has passed, and the record only after the reservation. |
| -- Boxes are named by box id (`edgesfile.PlacementView.BoxID`, `control/boxes/<box>.json` identity) in reservations and participants; a box whose `BoxID` is empty (an older writer) cannot participate ("<label> runs an older release", the existing offline-capability sentence). |
| -- Copy rows pending Paul's review (every new sentence, listed in Task 5 and Task 8); existing sentences reused where named. No tenant names: `shop.example.com`, account `bab`, app `shop`, display name "Shop's account". |
| -- Census: the box-side units (the lane probe, the target-ready report, the source report fields, the per-host status block) take rows in `internal/provision/testdata/plan14-root-reads-self-serve-moves-2.tsv`, loaded last, `rev3=PROPOSED-self-serve-moves-2`; the record-write census inventory gains the new transitions' digests; both census tests green. |
| +- Boxes are named by **box id** (`edgesfile.PlacementView.BoxID`) in reservations and participants, with the label beside it for reports and logs; a box whose `BoxID` is empty cannot participate ("<label> runs an older release", the existing sentence). Every box compares its own id; region membership alone never makes a box act for a self-serve move. |
| +- Copy rows pending Paul's review (listed in Tasks 5 and 8); existing sentences reused where named. No tenant names: `shop.example.com`, account `bab`, app `shop`, display name "Shop's account". |
| +- Census: the box-side units take rows in `internal/provision/testdata/plan14-root-reads-self-serve-moves-2.tsv`, loaded last by a new block at the end of `TestPlan14RootReadCensus` (`plan14_root_reads_test.go`), `rev3=PROPOSED-self-serve-moves-2`; `TestPlan9B8RecordWriteCensus` takes `plan14SelfServeMoves2WriteCensusInventory()` wrapping plan 1's inventory with the new transitions' digests and the box's new report writers; both green. |
| - Local tests: touched packages with `-run`; CI runs the full suite. |
| ## Review Focus |
| -1. A console crash between writing the `self_serve_moves` row and the reservation, between the reservation and the queued record, and between the queued record and its reports: each resumes from the row and the evidence without a second reservation or a second record write (Task 7, `TestAdmissionResumesFromEveryBoundary`). |
| -2. A reservation of a box by a move whose record write was lost and whose row says `requested`: reclaimable only after the grace period and only when the re-read record and row name no such move; unreadable evidence never reclaims (Task 3, `TestReclaimOnlyOnEvidence`). |
| -3. A deadline expiring while a box is mid-copy or mid-seal: abort is initiated, the box's own revert reports arrive later, and the view shows `abort requested`, `source reopened`, `abort finished` as three separate facts (Task 8, `TestAbortIsInitiatedNotCompleted`). |
| -4. A `cut` or `cancel` naming a stale or missing id, or arriving during `cutting`: 409 with the exact sentences, nothing written (Task 5, `TestMutationsNameTheMove`). |
| -5. A token minted by a member who is not an owner, an operator's admin token, a token of another app, and a revoked token at the self-serve mutations: the move family's byte-identical 404 after authentication; the status read stays open (Task 1, `TestMoveCapableTokenGate`). |
| +1. A console crash between the row insert and the reservation, between the reservation and the queued record (with the record write's response lost), and between the queued record and its reports: each resumes from the row and the evidence with exactly one reservation and one record move (Task 7, `TestAdmissionResumesFromEveryBoundary`). |
| +2. A reservation whose row says `requested` after the grace period: reclaimable only when the re-read record carries no move with its id **and** the row is absent or terminal; unreadable evidence, a malformed body or an uncertain cleanup status never reclaim (Task 3, `TestReclaimOnlyOnEvidence`). |
| +3. A deadline expiring while a box is mid-copy or mid-seal: `abort.requested_at` is set, the box's revert reports arrive later, `abort.source_reopened_at` needs the sealer's `lifted` and a source `reverted` report, and `abort.finished_at` needs every report (Task 8, `TestAbortIsInitiatedNotCompleted`). |
| +4. `cut` naming a stale or missing id → 409; a second `cut` during `cutting` → 202 with the view; `cancel` during `cutting` → 409; concurrent `cut` and `cancel` on a `ready` move: exactly one wins and the loser answers from the committed row (Task 5, `TestMutationsNameTheMoveAndRace`). |
| +5. At the three self-serve mutations: a plain instance token, an admin token, another app's move-capable token and a member's token get the move family's byte-identical 404 **before any record read**, against absent, eligible, ineligible and unreadable records; an invalid or revoked credential keeps today's authentication answer (Task 1, `TestMoveCapableGateAtTheRoutes`). |
| --- |
| ### Task 1: The move-capable token |
| **Files:** |
| -- Modify: `internal/console/authstore.go` (`ALTER TABLE cli_tokens ADD COLUMN moves INTEGER NOT NULL DEFAULT 0` in the ALTER list; `claimRawNewAppToken` gains `moves bool`; `CLIPrincipal` scans `moves` and sets `Principal.Moves`; `scanCLITokenRows` scans it into `CLIToken.Moves`), `internal/console/svcprincipal.go` (`Principal.Moves bool`), `internal/console/apiinstances.go` (`handleAPIInstanceToken`: the body gains `Moves bool \`json:"moves"\``; with it set the minter's role on the account must be `RoleOwner` (`s.store.Role(acct.ID, addr)`), else 403 "only an account owner can mint a move-capable instance token"; the audit detail becomes "minted a move-capable instance token for <app> (cli), token <id>"), `internal/console/tokens.html` or its template (the tokens page shows "move-capable" beside such a token) |
| -- Test: `internal/console/instancetoken_moves_test.go` |
| +- Modify: `internal/console/authstore.go` (`ALTER TABLE cli_tokens ADD COLUMN moves INTEGER NOT NULL DEFAULT 0` in the ALTER list; `claimRawNewAppToken` gains `moves bool`; `NewAppToken` and `NewAppTokenContext` gain it; `CLITokens` and `scanCLITokenRows` and every other `SELECT ... FROM cli_tokens` that feeds the scanner (the account-scoped listing too) select `moves` into `CLIToken.Moves`), `internal/console/svcprincipal.go` (`Principal.Moves bool`; `CLIPrincipal` selects and sets it for `cliKindApp` rows), `internal/console/apiinstances.go` (`handleAPIInstanceToken`: body `{"label","moves"}`; `moves` requires `s.store.Role(acct.ID, addr) == RoleOwner` else 403 "only an account owner can mint a move-capable instance token"; the audit detail "minted a move-capable instance token for <app> (cli), token <id>"), the tokens page template (a "move-capable" tag) |
| +- Create: `internal/console/selfserve_gate.go` |
| +- Test: `internal/console/instancetoken_moves_test.go`, `internal/console/selfserve_gate_test.go` |
| **Interfaces:** |
| -- Produces: `Principal.Moves bool`; `CLIToken.Moves bool`; `func (s *Store) NewAppTokenContext(ctx, accountID, app, mintedBy, label string, moves bool, now)` (every existing caller passes `false`); `func (s *Server) moveCapable(r *http.Request, acct *Account, app string) bool`: the principal is `PrincipalService`, `Role == svcrole.Instance`, `AccountID == acct.ID`, `App == app`, `Moves`. |
| -- The CLI side (`carlos instance-token --moves`) lives in the carlos repo and sends `{"moves": true}`; this plan ships the door. Existing tokens have `moves = 0` and cannot be upgraded. When #155's instance-bound tokens merge (their `host` column), the mint door refuses `moves` on a request that also binds a host: note it in the door's comment; no code until the column exists. |
| +- `func (s *Store) NewAppTokenContext(ctx context.Context, accountID int64, app, mintedBy, label string, moves bool, now time.Time) (string, error)`; `func (s *Store) NewAppToken(accountID int64, app, mintedBy, label string, moves bool, now time.Time) (string, error)` (the non-context wrapper, as today's); every existing caller passes `false`. |
| +- `CLIToken.Moves bool`; `Principal.Moves bool`. The token id for audit and `requested_by` is `CLIToken.ID` as the listing shows it (the stored hash), the value the revoke door takes. |
| +- `func (s *Server) selfServeGate(w http.ResponseWriter, r *http.Request) (acct *Account, app, host string, ok bool)`: `apiAccountAppSaying(w, r, s.store.HasAppIn, mainMoveCopy().gate)` (principal, account, app: today's authentication answers for an invalid or revoked credential), then `host := r.PathValue("host")` and `ValidHostShape` (404 as `moveGate`), then **`moveCapable(principal, acct, app)` else `http.NotFound`** (the family's byte-identical 404), and returns **without reading the record**. `moveCapable`: `Kind == PrincipalService && Role == svcrole.Instance && AccountID == acct.ID && App == app && Moves`. |
| +- The CLI (`carlos instance-token --moves`) is a carlos-repo change sending `{"moves": true}`. Existing tokens have `moves = 0`. When #155's instance-bound tokens merge, the mint door refuses `moves` with a host binding (a comment names it; no code until the column exists). |
| -- [ ] **Step 1: Write the failing tests**: `TestOwnerMintsAMoveCapableToken` (201; the row has `moves = 1`; `CLIPrincipal` returns `Moves true`; the audit line names the token id); `TestMemberCannotMintAMoveCapableToken` (a member with role `member` → 403 with the sentence; no row); `TestPlainMintIsUnchanged` (no `moves` in the body → `moves = 0`, today's audit sentence); `TestMoveCapableTokenGate` (table: owner-minted move-capable token of this app → true; the same app's plain instance token → false; a move-capable token of another app → false; an admin service token → false; a person's token → false; a revoked move-capable token → `CLIPrincipal` not ok). |
| +- [ ] **Step 1: Write the failing tests**: `TestOwnerMintsAMoveCapableToken` (201; `moves = 1`; `CLIPrincipal.Moves`; both listings show it; the audit line names the id); `TestMemberCannotMintAMoveCapableToken` (403; no row); `TestPlainMintIsUnchanged`; `TestMoveCapableGateAtTheRoutes` (through the real routes with a counting store: a plain instance token, an admin service token, another app's move-capable token and a member's token → the 404 and zero record reads, against an absent record, an eligible one, a replica and a store whose record `Get` fails; the owner's move-capable token → past the gate; a revoked move-capable token → today's 401). |
| - [ ] **Step 2: Run to verify they fail**: `go test ./internal/console -run 'MoveCapable|Mint' -count=1`. |
| - [ ] **Step 3: Implement.** |
| - [ ] **Step 4: Run to verify they pass.** |
| -- [ ] **Step 5: Commit** `console: the move-capable instance token, owner-minted`. |
| +- [ ] **Step 5: Commit** `console: the move-capable instance token, owner-minted, checked before any move evidence is read`. |
| -### Task 2: The queued state and the self-serve fields on the record |
| +### Task 2: The queued state, the precheck stamp and the self-serve fields on the record |
| **Files:** |
| -- Modify: `internal/provision/offline_move.go` (`const OfflineQueued = "offline-queued"`; `OfflineQueue(id, from, to, by string, st StageTarget, p Participants, ss SelfServe, now time.Time) func(*Record) error` creates the move in `offline-queued` with the same refusals as `OfflineStage`; `OfflineQueuedToStaging(id string, now time.Time)` moves `offline-queued` → `offline-staging` setting `StagedAt`; `OfflineAbort` from `offline-queued` → `OfflineFinish` semantics directly (nothing staged, nothing held: the move clears with outcome `aborted`, `NeverOffline`); every other transition refuses `offline-queued` with `refusedOfflineState`), `internal/provision/provision.go` (`MoveState` gains `SelfServe *SelfServeMove \`json:"self_serve,omitempty"\`` and `Participants *Participants \`json:"participants,omitempty"\``), `internal/console/apiinstancemove_offline.go` (`offlineVerbOf`: `offline-queued` → "" (no operator verb; the job owns it); `offlineViewOf`: `State` passes through; the operator abort door accepts `offline-queued`) |
| -- Test: `internal/provision/offline_move_test.go` (`TestOfflineRecordDiffersOnlyWhereTheMoveSaysSo` allowlist gains the new fields on the transitions that set them) |
| +- Modify: `internal/provision/offline_move.go`, `internal/provision/provision.go` (`MoveState` gains `SelfServe *SelfServeMove \`json:"self_serve,omitempty"\``, `Participants *Participants \`json:"participants,omitempty"\``, `QueuedAt *time.Time \`json:"queued_at,omitempty"\``, `PrecheckAt *time.Time \`json:"precheck_at,omitempty"\``), `internal/console/apiinstancemove_offline.go` (`offlineVerbOf`: `offline-queued` → ""; `offlineViewOf`: `State` passes through, `CanAbort` true, `NeverOffline` from `StartedOffline`), `internal/provision/offline_move_test.go` (`TestOfflineRecordDiffersOnlyWhereTheMoveSaysSo`'s allowlist) |
| +- Test: `internal/provision/offline_queue_test.go` |
| **Interfaces:** |
| -- Produces: `type SelfServeMove struct { ID string \`json:"id"\`; DisplayName string \`json:"display_name,omitempty"\`; RequestedBy string \`json:"requested_by,omitempty"\`; EstimateSeconds int \`json:"estimate_seconds,omitempty"\`; OfflineAt *time.Time \`json:"offline_at,omitempty"\` }` (`ID` is the `self_serve_moves` row id, equal to the move id; `RequestedBy` is `"token:<token id> " + request's requested_by`); `type Participants struct { Source []string \`json:"source"\`; Target string \`json:"target"\` }` (box ids); `OfflineQueued`; `OfflineQueue`; `OfflineQueuedToStaging`; `func (m *MoveState) SelfServed() bool`. |
| -- `OfflineStart`'s direct path (operator, no stage) is unchanged. The self-serve job sets `SelfServe.OfflineAt` through `OfflineStartStaged` when `rec.Move.SelfServe != nil` (the time the instance goes offline), and `EstimateSeconds` through a new `OfflineSetEstimate(id string, seconds int)` transition that changes nothing else (allowed in `offline-queued`, `offline-staging`). |
| - |
| -- [ ] **Step 1: Write the failing tests**: `TestOfflineQueueThenStaging` (queue → `offline-queued` with participants and self-serve fields; `OfflineQueuedToStaging` → `offline-staging` with `StagedAt` set; `OfflineStartStaged` then stamps `OfflineAt`); `TestOfflineQueuedRefusesEveryOtherVerb` (seal, flip, open, finish, start-staged, restage → `ErrMoveConflict`); `TestAbortFromQueuedClearsAtOnce` (outcome `aborted`, `NeverOffline`, no `offline-reverting`); `TestOfflineSetEstimateChangesNothingElse`; the allowlist test. |
| -- [ ] **Step 2: Run to verify they fail**: `go test ./internal/provision -run 'OfflineQueue|AbortFromQueued|SetEstimate|DiffersOnlyWhere' -count=1`. |
| +- `const OfflineQueued = "offline-queued"`. |
| +- `type SelfServeMove struct { ID string \`json:"id"\`; Incarnation uint64 \`json:"incarnation"\`; DisplayName string \`json:"display_name,omitempty"\`; RequestedBy string \`json:"requested_by,omitempty"\`; EstimateSeconds int \`json:"estimate_seconds,omitempty"\`; OfflineAt *time.Time \`json:"offline_at,omitempty"\` }` (`ID` is the row id, equal to the move id; `Incarnation` the lifetime admitted; `RequestedBy` is `"token:" + CLIToken.ID + " " + request's requested_by`). |
| +- `type Participant struct { BoxID string \`json:"box_id"\`; Label string \`json:"label"\` }`; `type Participants struct { Source []Participant \`json:"source"\`; Target Participant \`json:"target"\` }`. |
| +- `func OfflineQueue(id, from, to, by string, st StageTarget, p Participants, ss SelfServeMove, now time.Time) func(*Record) error`: the refusals of `OfflineStage` (a move in flight, a seed block, an open run, the same region) plus `rec.Incarnation != ss.Incarnation` → `fmt.Errorf("%w: the instance was recreated since the move was requested", ErrMoveConflict)`; creates the move in `offline-queued` with `Kind: offline`, `From`, `To`, `Stager` (the source participant's label), `StageBucket/StageRegion`, `Participants`, `SelfServe`, `RequestedAt: now`, `QueuedAt: &now`, `StagedAt: &now` (the pre-copy intent exists, so `StartedOffline()` is false until the start), `PrecheckAt: &now`. `move_seq` unchanged. |
| +- `func OfflineQueuedToStaging(id string, now time.Time) func(*Record) error`: `offline-queued` → `offline-staging`, `StagedAt = &now` (the first stage request). `move_seq` unchanged. |
| +- `func OfflinePrecheck(id string, now time.Time) func(*Record) error`: allowed in `offline-queued` and `offline-staging`; sets `PrecheckAt = &now`; nothing else; `move_seq` unchanged. |
| +- `func OfflineSetEstimate(id string, seconds int) func(*Record) error`: allowed in `offline-queued`, `offline-staging`; sets `SelfServe.EstimateSeconds`; `errRecordUnchanged` when equal. |
| +- `OfflineStartStaged` sets `SelfServe.OfflineAt = &now` when `SelfServe != nil` (plan 1's `+1` applies). |
| +- `OfflineAbort` from `offline-queued`: the move clears at once with `LastMove = {ID, aborted, At: now, OfflineS: 0}` and `NeverOffline` semantics (as an abort-from-staging finish); `move_seq` unchanged. Every other transition refuses `offline-queued` with `ErrMoveConflict` and the existing offline-state sentence (`offlineStateRefusal`'s words). |
| + |
| +- [ ] **Step 1: Write the failing tests**: `TestOfflineQueueThenStaging` (queue → the fields above, `StartedOffline() == false`; to staging → `StagedAt` moves; start-staged → `OfflineAt`); `TestOfflineQueueRefusesARecreatedLifetime` (`Incarnation` mismatch → `ErrMoveConflict` with the sentence; nothing written); `TestOfflineQueuedRefusesEveryOtherVerb`; `TestOfflinePrecheckMovesOnlyTheStamp`; `TestAbortFromQueuedClearsAtOnce` (after an earlier completed move at `move_seq 3`: `LastMove` aborted, `move_seq` still 3, `NeverOffline`); `TestOfflineSetEstimateChangesNothingElse`; the allowlist test. |
| +- [ ] **Step 2: Run to verify they fail**: `go test ./internal/provision -run 'OfflineQueue|OfflinePrecheck|AbortFromQueued|SetEstimate|DiffersOnlyWhere' -count=1`. |
| - [ ] **Step 3: Implement.** |
| - [ ] **Step 4: Run to verify they pass**, then the record-write census. |
| -- [ ] **Step 5: Commit** `provision: the offline-queued state and the self-serve move fields`. |
| +- [ ] **Step 5: Commit** `provision: the offline-queued state, the precheck stamp and the self-serve move fields`. |
| -### Task 3: Per-box reservations in active.json |
| +### Task 3: Per-box reservations in active.json, with a legacy projection |
| **Files:** |
| -- Modify: `internal/moves/offline.go` (`OfflineActive` gains `Reservations map[string]OfflineReservation \`json:"reservations,omitempty"\``; the legacy fields `Host, Op, Account, App, At` stay and mean "every box reserved by this operator move" while non-empty), `internal/console/apiinstancemove_offline.go` (`offlineTakeLock` becomes a call to `reserveBoxes` with the operator move's participants: every source-region box id and the target's; `offlineReleaseLock` becomes `releaseBoxes`) |
| +- Modify: `internal/moves/offline.go` (`OfflineActive` gains `V int \`json:"v,omitempty"\`` and `Reservations map[string]OfflineReservation \`json:"reservations,omitempty"\``; the legacy fields stay as the **projection**: `Host, Op, Account, App, At` of the oldest `move` reservation, so an older console reads a busy lock while anything is reserved), `internal/console/apiinstancemove_offline.go` (the start and stage doors call `reserveBoxes` with the selection Task 5 returns; `offlineReleaseLock` → `releaseBoxes`) |
| - Create: `internal/console/offlinereserve.go` |
| - Test: `internal/console/offlinereserve_test.go` |
| **Interfaces:** |
| -- Produces: `type moves.OfflineReservation struct { Kind string \`json:"kind"\`; ID string \`json:"id"\`; Host string \`json:"host"\`; Account string \`json:"account"\`; App string \`json:"app"\`; ReservedAt time.Time \`json:"reserved_at"\` }` (`Kind` is `move` or `cleanup`); `func (s *Server) reserveBoxes(ctx context.Context, boxes []string, r moves.OfflineReservation) *offlineRefusal` (reads `active.json` (absent = empty); a legacy body with `Host != ""` reserves every box for `{move, Op, Host, Account, App, At}`; refuses `busy` ("<host> is moving offline. move one instance at a time", the existing sentence, naming the holder's host) when any participant is held by another `ID`, unless that reservation is reclaimable; writes every participant's entry in one conditional `Put` on the object's ETag (create-only when absent); a CAS conflict re-reads once and retries, then `busy`); `func (s *Server) releaseBoxes(ctx context.Context, id string) error` (removes every entry whose `ID == id` in one conditional write; absence is success; a failure is logged and left for the next tick); `func (s *Server) reservationsOf(ctx) (map[string]moves.OfflineReservation, string, error)`. |
| -- Reclaim: an entry is reclaimable when `time.Since(ReservedAt) >= offlineLockGrace` (2 minutes) and, for `Kind == move`, the record of `(Account, App, Host)` carries no offline move with `ID` **and** the console's `self_serve_moves` row with that id is absent or terminal (`outcome != ""`); for `Kind == cleanup` (plan 4) the owning box's status report names no `retired.json` with that id. Any read that fails → not reclaimable (`unreadable`, the existing 502 sentence). |
| - |
| -- [ ] **Step 1: Write the failing tests**: `TestReserveWritesEveryParticipantOnce` (three boxes, one write, the file's ETag read back); `TestReserveRefusesAHeldParticipant` (box B held by another id → busy naming that host; nothing written); `TestDisjointBoxesReserveAtOnce`; `TestLegacyLockBodyReservesEveryBox`; `TestReclaimOnlyOnEvidence` (a young reservation → busy; an old one whose record carries the move → busy; whose row is non-terminal → busy; whose record and row both say gone → reclaimed; a store whose record `Get` fails → unreadable, nothing written); `TestReleaseRemovesOnlyItsEntries`; `TestOperatorDoorsUseReservations` (the operator stage door reserves the source boxes and the target; finish releases). |
| -- [ ] **Step 2: Run to verify they fail**: `go test ./internal/console -run 'Reserve|Reclaim|Release|OperatorDoorsUse' -count=1`. |
| +- `type moves.OfflineReservation struct { Kind string \`json:"kind"\`; ID string \`json:"id"\`; Host string \`json:"host"\`; Account string \`json:"account"\`; App string \`json:"app"\`; ReservedAt time.Time \`json:"reserved_at"\` }` (`Kind` is `move` or `cleanup`). |
| +- `func (s *Server) reserveBoxes(ctx context.Context, sel offlineSelection, r moves.OfflineReservation) *offlineRefusal`: reads `active.json` (absent = empty); a body with no `Reservations` and `Host != ""` is a **legacy lock** reserving every box for `{move, Op, Host, Account, App, At}`; refuses `busy` ("<host> is moving offline. move one instance at a time", naming the holder's host) when any participant (`sel.Source[*].BoxID`, `sel.Target.BoxID`) is held by another `ID`, unless that reservation is reclaimable; otherwise writes every participant's entry **and the projection** in one conditional `Put` on the object's ETag (create-only when absent); a CAS conflict re-reads once and retries, then `busy`. **Concurrency gate:** while `cfg.SelfServeMoves != "offline"` the helper refuses when any reservation exists at all (today's deployment-wide semantics) and still writes the projection, so an older console in the same deployment never sees a table with entries it would delete as its own; per-box concurrency begins with the switch, which is turned on only once every console of the deployment runs this code (§9). |
| +- `func (s *Server) releaseBoxes(ctx context.Context, id string) error`: removes every entry whose `ID == id`, rewrites the projection from the remaining oldest `move` entry, and when the table is empty **deletes the object** (`DeleteIfMatch`), so an old console's `offlineTakeLock` finds absence rather than a `Host == ""` body it reads as unreadable; one conditional write; absence is success; a failure is logged and the row keeps `release_pending` (Task 8). |
| +- `func (s *Server) reservationsOf(ctx context.Context) (map[string]moves.OfflineReservation, string, error)`; `func (s *Server) reservationsFor(ctx context.Context, id string) ([]string, error)`. |
| +- Reclaim: an entry is reclaimable when `time.Since(ReservedAt) >= offlineLockGrace` (2 minutes; a retry re-reserves with the **original** `ReservedAt`) and, for `Kind == move`, the record of `(Account, App, Host)` (judged read) carries no offline move with `ID` **and** the console's row with that id is absent or terminal; for `Kind == cleanup` (plan 4), the owning box's status report, no older than two status intervals, lists no `RetiredSummary` with that id, or lists it `cleaned`/`superseded` with no root request outstanding. A read that fails, a malformed body, or a cleanup status that is absent or stale → not reclaimable (`unreadable`, the existing 502 sentence). |
| + |
| +- [ ] **Step 1: Write the failing tests**: `TestReserveWritesEveryParticipantAndTheProjection`; `TestReserveRefusesAHeldParticipant`; `TestDisjointBoxesReserveAtOnceWithTheSwitch` and `TestWithoutTheSwitchOneReservationAtATime`; `TestLegacyLockBodyReservesEveryBox`; `TestReclaimOnlyOnEvidence` (young → busy; old with the record carrying the move → busy; old with a non-terminal row → busy; both gone → reclaimed with the original `ReservedAt` preserved on the retry; a failing record `Get`, a malformed body, a cleanup kind with a stale status → unreadable, nothing written); `TestReleaseRemovesOnlyItsEntriesAndDeletesWhenEmpty`; `TestOldConsoleAlgorithmsAgainstTheNewBody` (replay today's `offlineTakeLock` and `offlineReleaseLock` logic from a fixture copy against a v2 body: take → busy; release of its own op → leaves other reservations because the projection's `Op` is not its own; an empty table is never left behind); `TestOperatorDoorsUseReservations`. |
| +- [ ] **Step 2: Run to verify they fail**: `go test ./internal/console -run 'Reserve|Reclaim|Release|OldConsole|OperatorDoorsUse' -count=1`. |
| - [ ] **Step 3: Implement.** |
| - [ ] **Step 4: Run to verify they pass**, plus `go test ./internal/console -run 'Offline' -count=1`. |
| -- [ ] **Step 5: Commit** `console: active.json holds per-box reservations; the operator doors reserve their participants`. |
| +- [ ] **Step 5: Commit** `console: active.json holds per-box reservations with a legacy projection; the operator doors reserve their participants`. |
| -### Task 4: The self_serve_moves table and the pass skeleton |
| +### Task 4: The self_serve_moves table, the console lease and the pass skeleton |
| **Files:** |
| -- Modify: `internal/console/store.go` (`migrate`), `internal/console/store_gate.go` (`claimSQLTables`: `"self_serve_moves": {Sqid: "account", App: "app"}`, `"move_timings": {NonApp: "..."}`), `internal/console/claim_workers.go` (register `"StartSelfServeMovesPass"`), `cmd/console/main.go` (start it; `cfg.SelfServeMoves = os.Getenv("CARLOS_SELF_SERVE_MOVES")`), `internal/console/server.go` (`Config.SelfServeMoves string`; `func (s *Server) selfServeOn() bool`) |
| -- Create: `internal/console/selfserve_store.go`, `internal/console/selfserve_pass.go` |
| +- Modify: `internal/console/store.go` (`migrate`), `internal/console/store_gate.go` (`claimSQLTables`: `"self_serve_moves": {Sqid: "account", App: "app"}`, `"move_timings": {NonApp: "..."}`), `internal/console/claim_workers.go` (register `"StartSelfServeMovesPass"`), `cmd/console/main.go` (start it; `cfg.SelfServeMoves = os.Getenv("CARLOS_SELF_SERVE_MOVES")`; `armMovesPaging`: arm when `cfg.HostClaim || cfg.SelfServeMoves == "offline" || the store has open self-serve rows at start`), `internal/console/server.go` (`Config.SelfServeMoves string`; `func (s *Server) selfServeAdmits() bool`) |
| +- Create: `internal/console/selfserve_store.go`, `internal/console/selfserve_pass.go`, `internal/console/selfserve_lease.go` |
| - 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, from_region TEXT, to_region TEXT, source_boxes TEXT, target_box TEXT, display_name TEXT, requested_by TEXT, phase TEXT, phase_started_at INTEGER, deadline INTEGER, estimate_seconds INTEGER, reason TEXT, health TEXT, outcome TEXT, outcome_reason TEXT, warning TEXT, abort_requested_at INTEGER, source_reopened_at INTEGER, created_at INTEGER, updated_at INTEGER)`. |
| -- Phases (the `phase` column): `requested`, `reserving`, `queued`, `reports`, `staging`, `prechecks`, `ready`, `restage`, `stopping`, `sealing`, `landing`, `opening`, `health`, `finishing`, `reverting`, `done`. Outcomes: `done`, `aborted`. |
| -- `type SelfServeMove struct { ID, Account, App, Host, From, To string; SourceBoxes []string; TargetBox string; DisplayName, RequestedBy, Phase string; PhaseStartedAt, Deadline time.Time; EstimateSeconds int; Reason, Health, Outcome, OutcomeReason, Warning string; AbortRequestedAt, SourceReopenedAt time.Time; CreatedAt, UpdatedAt time.Time }`. |
| -- On `*Store`: `InsertSelfServeMove(m SelfServeMove) error`, `SelfServeMove(id string) (*SelfServeMove, error)`, `SelfServeMoveForHost(acct, app, host string) (*SelfServeMove, error)` (the non-terminal one, nil when none), `OpenSelfServeMoves() ([]SelfServeMove, error)` (non-terminal, oldest first), `SetSelfServePhase(id, phase, reason string, deadline time.Time, now time.Time) error`, `SetSelfServeEstimate(id string, seconds int) error`, `SetSelfServeHealth(id, health string) error`, `SetSelfServeAbort(id string, requestedAt time.Time) error`, `SetSelfServeReopened(id string, at time.Time) error`, `FinishSelfServeMove(id, outcome, reason, warning string, now time.Time) error`, `LastSelfServeMove(acct, app, host string) (*SelfServeMove, error)` (the newest terminal one). |
| -- `func (s *Server) StartSelfServeMovesPass(ctx context.Context)` (returns unless `selfServeOn()` or there are open rows: with the switch turned off, open moves still run to completion and no new one is admitted); every 5 s `s.selfServeTick(ctx, time.Now())`; `func (s *Server) selfServeTick(ctx context.Context, now time.Time)`: for each open row, `s.selfServeStep(ctx, row, now)` (Tasks 7 and 8), at most one step, errors logged per row and never fatal to the pass. |
| - |
| -- [ ] **Step 1: Write the failing tests**: `TestSelfServeStoreRoundTrip`; `TestOpenSelfServeMovesIsOldestFirst`; `TestSelfServeMoveForHostIsTheOpenOne`; `TestPassIsOffWithoutTheSwitchAndNoOpenRows`; `TestPassRunsOpenRowsWithTheSwitchOff`; `TestTickAdvancesEachRowAtMostOneStep` (a stub step counts calls); `TestClaimStoreOpensWithSelfServeTables`. |
| +- Table `self_serve_moves (id TEXT PRIMARY KEY, account TEXT, app TEXT, host TEXT, incarnation INTEGER, from_region TEXT, to_region TEXT, source_boxes TEXT, target_box TEXT, display_name TEXT, requested_by TEXT, phase TEXT, phase_started_at INTEGER, step_deadline INTEGER, window_deadline INTEGER, stage_request INTEGER, precheck_request INTEGER, rounds INTEGER, estimate_seconds INTEGER, estimate_terms TEXT, reason TEXT, health_attempts INTEGER, health_first_ok_at INTEGER, health_last_probe_at INTEGER, health_last_error TEXT, finish_gate TEXT, outcome TEXT, outcome_reason TEXT, warning TEXT, abort_requested_at INTEGER, source_reopened_at INTEGER, abort_finished_at INTEGER, release_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 = ''`. |
| +- Phases: `requested`, `reserving`, `queued`, `reports`, `staging`, `prechecks`, `ready`, `restage`, `stopping`, `sealing`, `landing`, `landed`, `open-uncertain`, `opened`, `health`, `finishing`, `reverting`, `done`. Outcomes: `done`, `aborted`. |
| +- `type SelfServeMove struct { ID, Account, App, Host string; Incarnation uint64; From, To string; SourceBoxes []provision.Participant; TargetBox provision.Participant; DisplayName, RequestedBy, Phase string; PhaseStartedAt, StepDeadline, WindowDeadline time.Time; StageRequest, PrecheckRequest time.Time; Rounds int; EstimateSeconds int; EstimateTerms estimateTerms; Reason string; HealthAttempts int; HealthFirstOKAt, HealthLastProbeAt time.Time; HealthLastError string; FinishGate string; Outcome, OutcomeReason, Warning string; AbortRequestedAt, SourceReopenedAt, AbortFinishedAt time.Time; ReleasePending bool; CreatedAt, UpdatedAt time.Time }`. |
| +- On `*Store`: `AdmitSelfServeMove(m SelfServeMove) (admitted bool, open *SelfServeMove, err error)` (one transaction: insert, or return the open row the unique index protects); `SelfServeMove(id string) (*SelfServeMove, error)`; `SelfServeMoveForHost(acct, app, host string) (*SelfServeMove, error)` (the open one, else the newest terminal one, nil when none); `OpenSelfServeMoves() ([]SelfServeMove, error)` (`outcome = '' OR release_pending = 1`, oldest first); `AdvanceSelfServe(id, from, to, reason string, step, window time.Time, now time.Time) (bool, error)` (`UPDATE ... WHERE id = ? AND phase = ?`); `SetSelfServeStageRequest(id string, at time.Time) error`, `SetSelfServeRounds(id string, n int) error`, `SetSelfServePrecheckRequest(id string, at time.Time) error`, `SetSelfServeEstimate(id string, seconds int, terms estimateTerms) error`, `RecordSelfServeHealth(id string, probeAt time.Time, ok bool, errText string) error`, `SetSelfServeFinishGate(id, gate string) error`, `SetSelfServeWarning(id, warning string) error`, `RequestSelfServeAbort(id string, phases []string, now time.Time) (bool, error)` (`WHERE id = ? AND phase IN (...) AND abort_requested_at = 0`), `SetSelfServeReopened(id string, at time.Time) error`, `SetSelfServeAbortFinished(id string, at time.Time) error`, `FinishSelfServeMove(id, outcome, reason, warning string, now time.Time) error` (sets `release_pending = 1`), `ClearSelfServeRelease(id string) error`. |
| +- Lease: `const selfServeLeaseKey = "control/offline-moves/console.json"`; `func (s *Server) holdSelfServeLease(ctx context.Context, now time.Time) (held bool, err error)`: the body `{console, until}`; create-only when absent; `If-Match` when ours or expired; another console's live lease → not held (logged once per minute). The pass runs only while held, renewing each tick; losing the lease stops the pass's writes until it is held again. |
| +- `func (s *Server) StartSelfServeMovesPass(ctx context.Context)` (runs when `selfServeAdmits()` or `OpenSelfServeMoves()` is non-empty at start; every 5 s: the lease, then `s.selfServeTick(ctx, time.Now())`); `func (s *Server) selfServeTick(ctx context.Context, now time.Time)`: the orphan reservation scan (Task 8), then for each open row `s.selfServeStep(ctx, row, now)`, at most one step, errors logged per row. |
| + |
| +- [ ] **Step 1: Write the failing tests**: `TestSelfServeStoreRoundTrip`; `TestAdmitIsAtomic` (two concurrent admits for one host → one row, the loser gets the open row); `TestAdvanceIsPredicated` (a stale `from` → false, row unchanged); `TestRequestAbortOnce`; `TestOpenRowsIncludePendingRelease`; `TestPassIsOffWithoutAdmissionAndNoOpenRows`; `TestPassRunsOpenRowsWithoutAdmission`; `TestTickAdvancesEachRowAtMostOneStep`; `TestConsoleLeaseExcludesASecondConsole` (two in-process consoles over one `DirStore`: one holds, the other's tick writes nothing; after expiry the other takes it); `TestPagingArmedForSelfServe` (the real `armMovesPaging` with `HostClaim` off and the switch on → armed; off with open rows → armed; off with none → as today); `TestClaimStoreOpensWithSelfServeTables`. |
| - [ ] **Step 2: Run to verify they fail.** |
| - [ ] **Step 3: Implement.** |
| -- [ ] **Step 4: Run** `go test ./internal/console -run 'SelfServe|ClaimStoreOpensWithSelfServe' -count=1`. |
| -- [ ] **Step 5: Commit** `console: the self_serve_moves table and the pass that advances each move one step per tick`. |
| +- [ ] **Step 4: Run** `go test ./internal/console -run 'SelfServe|ConsoleLease|PagingArmed|ClaimStoreOpensWithSelfServe' -count=1`. |
| +- [ ] **Step 5: Commit** `console: the self_serve_moves table with atomic admission and predicated phases, the console lease and the pass skeleton`. |
| -### Task 5: The door bodies and the view |
| +### Task 5: The shared start validation, the door bodies and the view |
| **Files:** |
| -- Modify: `internal/console/apiinstancemove.go` (`handleAPIInstanceMoveRequest`, `Cut`, `Abort`, `Status`: when `s.selfServeOn()` and `!s.cfg.MovesEnabled` dispatch to the self-serve bodies; otherwise unchanged), `internal/console/apiinstancemove_offline.go` (`offlineBeginChecks` split into `offlineRequestChecks(ctx, d, to) *offlineRefusal` (every check after the body is read) so the job and the self-serve door share it) |
| +- Modify: `internal/console/apiinstancemove_offline.go` (`offlineBeginChecks` becomes a thin wrapper over `offlineStartValidation`; the start and stage doors use the returned selection; `offlineStepFor`'s `offline-staging` branch calls `offlineStartValidation` too, so the start-from-staged path carries every check), `internal/console/apiinstancemove.go` (`handleAPIInstanceMoveRequest`, `Cut`, `Abort`, `Status`: when `!s.cfg.MovesEnabled` and (`s.selfServeAdmits()` or a self-serve row exists for the host) dispatch to the self-serve bodies; otherwise unchanged) |
| - Create: `internal/console/selfserve_door.go`, `internal/console/selfserve_view.go` |
| -- Test: `internal/console/selfserve_door_test.go` |
| +- Test: `internal/console/selfserve_door_test.go`, `internal/console/apiinstancemove_offline_snapshot_test.go` |
| **Interfaces:** |
| -- `POST .../move {"region", "display_name"?, "requested_by"?, "start_at"?}`: `moveGate` (today's), then `moveCapable` else the family's 404 (`http.NotFound`), then in order: `refuseSeedMove`; `rec.ActiveRun.Open()` → 409 the existing run sentence; a replica or a home with replicas → the existing 400s; `rec.Move != nil` → 409 "a move to <region> is already <state>" (the existing `ErrMoveConflict` sentence through `offlineConflict`); the body; `start_at` non-empty and not within 60 s of now → 400 "scheduled starts are not available yet"; `display_name` over 80 runes or containing control characters → 400 "display_name must be at most 80 characters of plain text"; the region (invalid, same region, not one of the app's regions: the existing sentences); the fleet (`readOfflineFleet`, `offlineSelect`, both regions' boxes carry `offline-move-1` and `offline-stage-1`, every participant has a `BoxID`, the source region has exactly one box → else 409 "<region> has <n> boxes. a self-serve move needs exactly one box in each region"); the files limit (`?estimate=1`'s number, Task 9: `files_bytes > archive.Cap` → 409 "this account's files are too large to move this way"); the forward lane configured (the source box's status report lists the target bucket among `Lanes`, Task 6; else 409 "no staging lane is configured from <from> to <to>"). Then `InsertSelfServeMove({ID: moves.NewMoveID, ..., Phase: requested, RequestedBy: "token:" + tokenID + " " + requested_by, DisplayName, EstimateSeconds: the estimate})`; `bumpSerial`; 202 with the view. |
| -- `GET .../move`: today's gate (any instance token); the view (below). |
| -- `GET .../move?estimate=1®ion=uk`: the gate; the refusal list above up to and including the lane, without writing; 200 `{"estimate_seconds": n, "files_bytes": b, "replica_bytes": r}` or the refusal. |
| -- `POST .../move/cut {"id"}`: gate, `moveCapable`; the row for the host must exist, be self-serve, have `ID == body.id` (else 409 "this is not the move you confirmed"; an operator-created move has no row → the same sentence); phase `ready` → `SetSelfServePhase(id, restage, ...)`, 202; phase in the cut family → 202 with the view (idempotent); any other phase → 409 "the move is not ready to cut yet. it is <phase>". |
| -- `DELETE .../move {"id"}`: gate, `moveCapable`; the row and id as above; phase in `requested..ready` → `SetSelfServeAbort` and the job runs the abort (`offlineAbort` from queued/staging clears at once; the row's phase becomes `reverting` or `done` by the job), 202; phase in the cut family → 409 "the move was confirmed and cannot be cancelled. it completes or returns on its own"; `done` → 409 "no move is in flight". |
| -- The view (`selfServeViewOf(rec, row, last) moveView`): `moveView` gains `Kind string \`json:"kind,omitempty"\`` ("offline"), `EstimateSeconds int`, `OfflineAt int64`, `Reason string`, and `Last *selfServeLast` where `type selfServeLast struct { ID, Outcome, Reason, Warning, From, To string; At int64 }`. `State`: no row (or terminal) → `none`; `requested`, `reserving`, `queued`, `reports`, `staging`, `prechecks` → `preparing`; `ready` → `ready`; the rest → `cutting`. `Target` is the row's `To` as `homeRegionOut`. `Reason` is the row's `reason`. `Last` from `LastSelfServeMove`. |
| -- Sentences new here are copy rows: "scheduled starts are not available yet"; "display_name must be at most 80 characters of plain text"; "<region> has <n> boxes. a self-serve move needs exactly one box in each region"; "this account's files are too large to move this way"; "no staging lane is configured from <from> to <to>"; "this is not the move you confirmed"; "the move is not ready to cut yet. it is <phase>"; "the move was confirmed and cannot be cancelled. it completes or returns on its own"; "no move is in flight"; "waiting for another move on this box". |
| - |
| -- [ ] **Step 1: Write the failing tests**: `TestSwitchOffIsByteIdentical` (the four doors' answers with the switch off equal main's for the same fixtures); `TestPrepareRefusesBeforeWriting` (one subtest per refusal above: no row, no record write, no reservation); `TestPrepareWritesTheRowFirst` (after 202 the row is `requested`, the record is unchanged); `TestMutationsNameTheMove` (cut with a wrong id, no id, an operator move's host; cancel during cutting; a second cut during cutting is 202); `TestViewMapsPhases` (one subtest per phase; `last` after done and after aborted); `TestEstimateDoorWritesNothing`; `TestStatusStaysOpenToPlainInstanceTokens`. |
| -- [ ] **Step 2: Run to verify they fail**: `go test ./internal/console -run 'SelfServe|SwitchOff|Prepare|MutationsName|ViewMaps|EstimateDoor' -count=1`. |
| +- `type offlineSelection struct { From, To string; Fleet offlineFleet; Source []provision.Participant; Target provision.Participant; Stage provision.StageTarget }`. |
| +- `func (s *Server) offlineStartValidation(ctx context.Context, acct *Account, app, host string, rec *provision.Record, region string, stage bool) (offlineSelection, *offlineRefusal)`: **every** check `offlineBeginChecks` and the stage door make today, in their order and with their sentences: `rec.Seed.MoveBlock()` (the seed block; a seeded record whose seed does not block is movable), `rec.Role != ""`, `len(rec.Replicas) > 0`, `rec.Steering != "" || rec.Unit != ""`, `offlineClaimEvidence`, the region (invalid, same region), the manifest's regions, `readOfflineFleet`, the home-region placement rule, `offlineSelect`; with `stage`: every box of both regions carries `offline-stage-1`, the distinct-bucket rule, the stager pinned; plus, for every participant, a non-empty `BoxID` (else the existing older-release sentence). The doors' bytes are pinned by `TestOperatorDoorAnswersAreUnchanged` (snapshots of every refusal of every operator door, taken from main before the change, replayed after). |
| +- `POST .../move {"region", "display_name"?, "requested_by"?, "start_at"?}`: `selfServeGate` (Task 1; no record read yet); `s.readRecord` (plan 1's judged read; absent or stale → 404; unreadable → 502 "could not read the instance"); then in order: `rec.ActiveRun.Open()` → 409 the existing run sentence; `rec.Move != nil` → 409 `offlineConflict`'s existing "a move to X is already Y"; an open row for the host (`SelfServeMoveForHost` open) → the same 409 (the row is the admission); the body; `start_at` non-empty and not within 60 s of now → 400 "scheduled starts are not available yet"; `display_name` over 80 runes or with a control character → 400 "display_name must be at most 80 characters of plain text"; `offlineStartValidation(..., region, stage: true)`; the source region has exactly one box → else 409 "<region> has <n> boxes. a self-serve move needs exactly one box in each region"; the status facts (Task 6) for the source box fresh and known → else 409 "the source box has not reported recently. try again in a minute"; `files_bytes > archive.Cap` → 409 "this account's files are too large to move this way"; the lane (`Lanes` of the source box's report names the target bucket) → else 409 "no staging lane is configured from <from> to <to>"; the lifetime: `rec.Incarnation == 0` → `s.migrateHostLifetimeLocked` under `withHostname` (plan 1; a conflict or settling → 409 "another change to this instance is still settling. try again in a minute"). Then `AdmitSelfServeMove({ID: moves.NewMoveID, Incarnation: rec.Incarnation, ..., Phase: requested, RequestedBy, DisplayName, EstimateSeconds, EstimateTerms, WindowDeadline: now + 24 h})`; not admitted → 409 the move-in-flight sentence from the open row; `bumpSerial`; 202 with the view. |
| +- `GET .../move`: `moveGate` (today's; any instance token); a self-serve row for the host (open or terminal) → the self-serve view; else today's view. |
| +- `GET .../move?estimate=1®ion=uk`: `moveGate`; the refusal list above through the lane, without writing or migrating; 200 `{"estimate_seconds": n, "files_bytes": b, "replica_bytes": r}` or the refusal. |
| +- `POST .../move/cut {"id"}`: `selfServeGate`; `SelfServeMoveForHost` open; none, or `row.ID != body.id` → 409 "this is not the move you confirmed"; phase `ready` → `AdvanceSelfServe(id, ready, restage, ...)`; `true` → 202; `false` → re-read: a cut phase → 202 (idempotent), `aborted`/abort requested → 409 "the move was cancelled"; any other phase → 409 "the move is not ready to cut yet. it is <phase>". |
| +- `DELETE .../move {"id"}`: `selfServeGate`; the row and id as above; `RequestSelfServeAbort(id, [requested, reserving, queued, reports, staging, prechecks, ready], now)`; `true` → 202 (the job runs the abort); `false` → re-read: abort already requested → 202; a cut phase → 409 "the move was confirmed and cannot be cancelled. it completes or returns on its own"; terminal → 409 "no move is in flight". |
| +- The view: `type selfServeMoveView struct { State string \`json:"state"\`; Target string \`json:"target,omitempty"\`; UpdatedAt int64 \`json:"updated_at,omitempty"\`; Kind string \`json:"kind"\`; ID string \`json:"id,omitempty"\`; EstimateSeconds int \`json:"estimate_seconds,omitempty"\`; OfflineAt int64 \`json:"offline_at,omitempty"\`; Reason string \`json:"reason,omitempty"\`; Abort *selfServeAbortView \`json:"abort,omitempty"\`; Last *selfServeLast \`json:"last,omitempty"\` }`, `type selfServeAbortView struct { RequestedAt int64 \`json:"requested_at"\`; SourceReopenedAt int64 \`json:"source_reopened_at,omitempty"\`; FinishedAt int64 \`json:"finished_at,omitempty"\` }`, `type selfServeLast struct { ID, Outcome, Reason, Warning, From, To string; At int64 }` (unix seconds; `from`/`to` through `homeRegionOut`). `State`: the row terminal → `none`; `requested`..`prechecks` → `preparing`; `ready` → `ready`; the rest → `cutting`. `Kind` is `offline`. `UpdatedAt` is the row's. Phase 1's `moveView` is untouched (a Phase-1 console never has a self-serve row). |
| +- Sentences new here are copy rows: "scheduled starts are not available yet"; "display_name must be at most 80 characters of plain text"; "<region> has <n> boxes. a self-serve move needs exactly one box in each region"; "the source box has not reported recently. try again in a minute"; "this account's files are too large to move this way"; "no staging lane is configured from <from> to <to>"; "this is not the move you confirmed"; "the move was cancelled"; "the move is not ready to cut yet. it is <phase>"; "the move was confirmed and cannot be cancelled. it completes or returns on its own"; "no move is in flight"; "waiting for another move on this box". |
| + |
| +- [ ] **Step 1: Write the failing tests**: `TestOperatorDoorAnswersAreUnchanged`; `TestSwitchOffIsByteIdentical` (no row: the four doors answer as main; Phase 1 fixtures unchanged); `TestPrepareRefusesBeforeWriting` (one subtest per refusal: no row, no record write, no reservation, no migration); `TestPrepareMigratesALegacyRecordThenAdmits`; `TestPrepareWritesTheRowFirst`; `TestMutationsNameTheMoveAndRace` (wrong id, no id, an operator move's host; a second cut during cutting → 202; cancel during cutting → 409; concurrent cut and cancel on `ready` → one 202 and one 409 from the committed row); `TestViewMapsPhases` (one subtest per phase; `abort` facts; `last` after done and aborted; the view with the switch off and a terminal row); `TestEstimateDoorWritesNothing`; `TestStatusStaysOpenToPlainInstanceTokens`. |
| +- [ ] **Step 2: Run to verify they fail**: `go test ./internal/console -run 'SelfServe|SwitchOff|OperatorDoorAnswers|Prepare|MutationsName|ViewMaps|EstimateDoor' -count=1`. |
| - [ ] **Step 3: Implement.** |
| - [ ] **Step 4: Run to verify they pass**, plus `go test ./internal/console -run 'Move' -count=1`. |
| -- [ ] **Step 5: Commit** `console: the self-serve move door, its refusals and the view`. |
| +- [ ] **Step 5: Commit** `console: one start validation for the operator doors and the job; the self-serve door, its refusals and its view`. |
| -### Task 6: The boxes' reports: the lane, target-ready, the source's facts, the per-host status block |
| +### Task 6: The boxes' reports: the lane, the source precheck, target-ready, and the per-host status block |
| **Files:** |
| -- Modify: `internal/moves/offline.go` (`OfflineReport` gains `Lane *LaneReport`, `TargetReady *TargetReadyReport`, `ExtraDirs []ExtraDir`, `ArchiveEligible *Check`, `FilesBytes` (exists), `Build string`, `StagePathArmed *bool`; the roles gain `OfflineRoleLane = "lane"` and `OfflineRoleReady = "ready"`), `internal/host/movesoffline.go` (`offlineRound`: `offline-queued` → the source box (a participant by `BoxID`) runs `offlineLaneProbe` once per queued move and writes the `lane` report; the target box runs `offlineTargetReady` and writes the `ready` report; nothing else in `offline-queued`), `internal/host/movesoffline_land.go` (`offlineTargetReady`: the facts of §7.3 as `TargetReadyReport`; re-run at `offline-staging` when the record's `StagedAt` moved (the cut's restage) so the report is fresh for the pre-checks), `internal/host/movesoffline.go` (`sourceReport` gains the extra directories beside the files directory with sizes, the archive eligibility dry run (`archive.Build(filesDir, envPath, io.Discard)`: the returned error is the check's reason; `bytes` is `FilesBytes`), the row's `ServingVersion` as `Build`, and `StagePathArmed` from `systemctl is-active carlos-stage.path` through the existing unit probe), `internal/host/agent.go` (the status tick fills `p.Hosts` from the registry's routes and the box's data bucket inventory the activator keeps: `ReplicaBytes`, `ReplicaObjects` from the last mirror listing (`lease.MirrorStore` list under `instances/<host>/`, cached per status tick), `FilesBytes` from a bounded walk of `/data/<host>` (skipping when over 2 GiB and recording `FilesBytesTruncated`), `ExtraDirs`), `internal/status/snapshot.go` (`BoxMetrics.Hosts map[string]HostFacts \`json:"hosts,omitempty"\``; `BoxMetrics.Lanes []string \`json:"lanes,omitempty"\`` (the target buckets `CARLOS_MOVE_LANES` names, comma-separated, as the box publishes them)) |
| +- Modify: `internal/moves/offline.go` (`OfflineReport` gains `BoxID string`, `Request time.Time` (the `PrecheckAt` answered, for `precheck` and `ready`), `Lane *LaneReport`, `Ready *TargetReadyReport`, `Precheck *SourcePrecheck`, `RoundCopiedBytes int64`, `RoundDeferredBytes int64`, `RoundDeferredObjects int`, `Rounds int` (successful rounds so far); roles gain `OfflineRoleLane = "lane"`; phases gain `OfflinePhaseLane = "lane"`, `OfflinePhasePrecheck = "precheck"`, `OfflinePhaseReady = "ready"`; `ValidateOfflineReport` admits the new role and phases and requires `BoxID` on them; `offlineCarries` unchanged), `internal/host/movesoffline.go` (`offlineRound`: participation is `cfg.BoxID` ∈ `Participants` when the record carries `Participants`, else today's region rule (the operator compatibility path); `offline-queued` → the source participant runs `offlineLaneProbe` once per move and `offlineSourcePrecheck` for the current `PrecheckAt`, the target participant runs `offlineTargetReady` for the current `PrecheckAt`; `offline-staging` → the stager round as today **and** both participants re-report when `PrecheckAt` is newer than their last report's `Request`; `sourceReport` (the stop report) gains the precheck facts too), `internal/host/movesoffline_stage.go` (the stager report publishes `RoundCopiedBytes`, `RoundDeferredBytes`, `RoundDeferredObjects` from `MirrorRound`, and `Rounds`), `internal/host/movesoffline_land.go` (`offlineTargetReady`), `internal/host/agent.go` (the status tick: `p.Hosts`, `p.Lanes`), `internal/status/snapshot.go` (`Probes.Hosts map[string]HostFacts`, `Probes.Lanes []string`; `CollectBox`'s attachment condition includes `len(p.Hosts) > 0 || len(p.Lanes) > 0`; `BoxMetrics.Hosts`, `BoxMetrics.Lanes` private) |
| - Test: `internal/moves/offline_test.go`, `internal/host/movesoffline_reports_test.go`, `internal/status/snapshot_test.go` |
| **Interfaces:** |
| -- `type moves.LaneReport struct { Bucket, Region, Key string; Put, Listed, Deleted bool; Error string }` (the key is `moves/@lanes/<move id>/<box>.probe`, put, listed with the prefix, deleted; `Error` names the failing step). |
| -- `type moves.TargetReadyReport struct { RouteAbsent, TimersAbsent bool; LiveLink string; LiveLinkBuild string; ChannelBuild string; DataPathsAbsent []string; DataPathsPresent []string; ProgramsBuild string; ParkDirClean bool; DiskFreeBytes int64; MovesPrefixClean, InstancesPrefixClean, LockAbsent bool; Capabilities []string; SecretsKeyFP string; StagePathArmed *bool }`: facts only; the console decides (Task 7). |
| -- `type moves.ExtraDir struct { Path string; Bytes int64; Entries int }`. |
| -- `type status.HostFacts struct { ReplicaBytes int64 \`json:"replica_bytes"\`; ReplicaObjects int \`json:"replica_objects"\`; FilesBytes int64 \`json:"files_bytes"\`; FilesBytesTruncated bool \`json:"files_bytes_truncated,omitempty"\`; ExtraDirs []string \`json:"extra_dirs,omitempty"\` }` (private per-box block, host-keyed like `RouteServing`). |
| -- The console reads `Hosts` and `Lanes` through `readOfflineFleet`'s `metrics`. |
| - |
| -- [ ] **Step 1: Write the failing tests**: `TestLaneProbePutsListsDeletes` (against a `DirStore` standing for the target bucket: the key exists between put and delete and not after; a failing delete leaves `Error` naming it; nothing under `moves/<host>/`); `TestTargetReadyReportsFacts` (a stale live link naming another build → `LiveLinkBuild` ≠ `ChannelBuild`; a present `/data/<host>.env` → in `DataPathsPresent`; a `lock.json` under `instances/<host>/` → `LockAbsent false`); `TestSourceReportGainsEligibilityAndExtraDirs` (an unsupported file type in the files dir → `ArchiveEligible.OK false` with the archive's reason; an extra directory beside files with its size); `TestQueuedRoundRunsOnlyLaneAndReady` (a queued record: the source writes a `lane` report, the target a `ready` report, no stage round, no hold); `TestStatusHostsBlockIsPrivateAndBounded` (`Hosts` present in `status/boxes/<label>.json`, absent from `fleet.json`; a walk over the cap sets `FilesBytesTruncated`); `TestLanesPublished`. |
| +- `type moves.LaneReport struct { Bucket, Region, Key string; Put, Listed, Deleted bool; Error string }` (key `moves/@lanes/<move id>/<box id>.probe`). |
| +- `type moves.Fact struct { Known bool; OK bool; Detail string }` (`Known == false` is a probe that failed: `Detail` says why; the console refuses "could not determine <fact> on <label>"). |
| +- `type moves.TargetReadyReport struct { RouteAbsent, TimersAbsent Fact; LiveLink Fact (OK: absent, or naming ChannelBuild; Detail: the target); ChannelBuild string; ProgramsFile Fact (OK: absent or naming ChannelBuild); DataPaths map[string]Fact (one entry per path of §7.3, OK = absent); ParkDirClean Fact; DiskFreeBytes int64; DiskKnown bool; MovesPrefix PrefixFacts; InstancesPrefix PrefixFacts; LockAbsent Fact; Capabilities []string; SecretsKeyFP string; StagePathArmed Fact }`; `type PrefixFacts struct { Known bool; Objects map[string]StagedObject }` (every current object under the prefix with its ETag and size; empty means clean). |
| +- `type moves.SourcePrecheck struct { FilesBytes int64; FilesEntries int; ArchiveEligible Fact; ExtraDirs []ExtraDir; Build string; StagePathArmed Fact; ExtraDirsRemoved []string; ExtraDirsLeft []ExtraDir }`; `type ExtraDir struct { Path string; Bytes int64; Entries int }`. The dry run is `archive.Build(files, envPath, io.Discard)` over `offlineFilesOf(host, rt)`'s `files` and `envPath` (the layout-aware selection the sealer uses); its error is `ArchiveEligible.Detail`. The empty extra directories are removed by the source at `start`, after quiescence: `os.ReadDir` returns no entries → `os.Remove` (never `RemoveAll`); a directory found non-empty at that moment is left and reported in `ExtraDirsLeft`; the seal then refuses stray entries as today. |
| +- `type status.HostFacts struct { CollectedAt int64 \`json:"collected_at"\`; Err string \`json:"err,omitempty"\`; ReplicaBytes int64 \`json:"replica_bytes"\`; ReplicaObjects int \`json:"replica_objects"\`; FilesBytes int64 \`json:"files_bytes"\`; FilesEntries int \`json:"files_entries"\`; FilesTruncated bool \`json:"files_truncated,omitempty"\`; ExtraDirs []string \`json:"extra_dirs,omitempty"\` }`: the replica from the last mirror listing of `instances/<host>/` (cached per tick), the files from `archive.PlainDigest(files, envPath)` over `offlineFilesOf`'s selection under a 60 s budget and a 2 GiB cap (over either → `FilesTruncated`), `Err` when any part failed (the console refuses on `Err != ""`). |
| +- `BoxMetrics.Lanes []string` from `CARLOS_MOVE_LANES` (comma-separated target bucket names, as the box publishes them). |
| + |
| +- [ ] **Step 1: Write the failing tests**: `TestLaneProbePutsListsDeletes`; `TestTargetReadyReportsEveryFactWithKnown` (each fact OK, refused and unknown; the full data-path set; a stale live link's target; a `lock.json`; the prefix inventories); `TestSourcePrecheckFacts` (an unsupported file type → `ArchiveEligible` not OK with the archive's reason; the layout-aware files dir on a nested instance; extra directories with sizes); `TestEmptyExtraDirsRemovedAtStartOnly` (empty → removed after quiescence and listed in `ExtraDirsRemoved`; one that gained a file → left and reported); `TestQueuedRoundRunsOnlyLanePrecheckAndReady` (a queued record: the source writes `lane` and `precheck`, the target `ready`, no stage round, no hold; a non-participant box in the region does nothing); `TestParticipantsReReportOnANewPrecheckStamp`; `TestStagerReportCarriesCopiedAndDeferred`; `TestStatusHostsBlockIsPrivateAndBounded` (through the real `PublishFleet`: `Hosts` in `status/boxes/<label>.json`, absent from `fleet.json`; the budget sets `FilesTruncated`; a failed listing sets `Err`); `TestLanesPublished`. |
| - [ ] **Step 2: Run to verify they fail.** |
| - [ ] **Step 3: Implement.** |
| -- [ ] **Step 4: Run** `go test ./internal/moves -run 'Lane|TargetReady|Report' -count=1`, `go test ./internal/host -run 'LaneProbe|TargetReady|SourceReport|QueuedRound|StatusHosts' -count=1`, `go test ./internal/status -run 'Hosts|Lanes' -count=1`, then the root-read census. |
| -- [ ] **Step 5: Commit** `boxes: the lane probe, the target-ready facts, the source's eligibility and extra directories, and the per-host status block`. |
| +- [ ] **Step 4: Run** `go test ./internal/moves -run 'Lane|TargetReady|Precheck|Report' -count=1`, `go test ./internal/host -run 'LaneProbe|TargetReady|SourcePrecheck|ExtraDirs|QueuedRound|ReReport|StagerReport|StatusHosts|Lanes' -count=1`, `go test ./internal/status -run 'Hosts|Lanes' -count=1`, then the root-read census. |
| +- [ ] **Step 5: Commit** `boxes: the lane probe, the source precheck, the target-ready facts and the per-host status block, dispatched by participant id`. |
| ### Task 7: The job, from requested to ready |
| **Files:** |
| -- Create: `internal/console/selfserve_job.go` (the admission and preparing phases), `internal/console/selfserve_checks.go` (the console's decisions over the reports) |
| -- Modify: `internal/console/apiinstancemove_offline.go` (`offlineStepFor`'s checks are reused; `offlineStageCoords`, `offlineStagerPresent` shared) |
| +- Create: `internal/console/selfserve_job.go`, `internal/console/selfserve_checks.go`, `internal/console/selfserve_apply.go` |
| - Test: `internal/console/selfserve_job_test.go` |
| **Interfaces:** |
| -- `func (s *Server) selfServeStep(ctx context.Context, row SelfServeMove, now time.Time) error` dispatches on `row.Phase`; each phase function re-reads the record (`readRecord`, plan 1's judged reader) and the reports (`readOfflineEvidence`) first and acts on evidence, never on memory. `row.AbortRequestedAt != 0` in any phase before `opening` → the abort step (Task 8) first. |
| +- `func (s *Server) selfServeApply(ctx context.Context, row *SelfServeMove, mut func(*provision.Record) error, want func(*provision.MoveState) bool) (*provision.Record, error)`: `provision.SetRecordMove` with the judged context; on any error other than `ErrMoveConflict`, re-read the record by `(Account, App, Host)` and, when `rec.Move != nil && rec.Move.ID == row.ID && want(rec.Move)`, treat the write as landed; after every landed write (including a discovered one) `bumpSerial`. `ErrMoveConflict` is returned as is (the next tick re-reads the record and reconciles the row to it: an operator's transition found already made moves the row to the matching phase; a cleared record with `LastMove.ID == row.ID` finalises the row with that outcome). |
| +- `func (s *Server) selfServeStep(ctx context.Context, row SelfServeMove, now time.Time) error`: re-reads the record and the reports first; reconciles the row's phase to the record's state when they disagree (the table below); then, if `row.AbortRequestedAt != 0` and the record's state is at or before `offline-landed` (or there is no record move), the abort step (Task 8) first; else the phase function. Every phase has `StepDeadline` (the wait) and the move has `WindowDeadline` (24 h from `created_at` until ready; then ready's 24 h). |
| - Phases: |
| - - `requested` → `reserving`: nothing to do but move on (kept distinct so a door crash after the insert is visible). |
| - - `reserving`: `reserveBoxes(participants, {move, row.ID, host, acct, app, now})`; busy → stay, `reason` "waiting for another move on this box"; at 24 h since `created_at` → abort "the move was not started in time"; reserved → write the record `OfflineQueue(...)` with `Participants`, `SelfServe{ID, DisplayName, RequestedBy, EstimateSeconds}`, `StageTarget` from the fleet (the stage door's rule: the target's `DataBucket`/`DataBucketRegion`, refused when a source box publishes the same bucket); a write whose outcome is unknown → stay in `reserving` and re-read next tick by id (the record carries `Move.ID == row.ID` → `queued`); `ErrMoveConflict` (another move appeared) → abort "another move is in flight", release. |
| - - `queued` → `reports`: the record carries the queued move (else re-read; 5 min deadline → abort). |
| - - `reports`: the `lane` report from the source participant with `Put && Listed && Deleted` and the `ready` report from the target participant; the console's decision over the facts (`targetReadyDecision(rep) *offlineRefusal`: route present → "the target box already has a route for <host>"; a live link naming another build → "the target box has a stale live link for <host> naming <build>"; any data path present → "the target box already holds data for <host>: <path>"; `ParkDirClean false`; `DiskFreeBytes < 2 × replica bytes` → "the target box has too little free disk for <host>"; prefixes not clean or lock present → "the target bucket already holds objects for <host>"; `SecretsKeyFP` ≠ the console's → the existing sentence; missing capability → the existing sentence); both pass → `OfflineQueuedToStaging`, `staging`; 5 min deadline → abort with the reason naming the missing report or the failed fact. |
| - - `staging`: the stager's report for the current `StagedAt` (`StageRequest.Equal`), phase `staged`; ready rule: at least two completed rounds and `(ReplicaBytes - StagedBytes) + round deferred < 16 MiB` (`defaultMoveReadyDelta`), else `OfflineRestage`; a round past `max(10 min, 3 × its estimate)` counts failed; a `failed` round → abort with `StageError`; the bound 30 min or 10 rounds → ready with the estimate from the last round; ready → `prechecks`. |
| - - `prechecks`: the source report's `ArchiveEligible.OK`, no non-empty `ExtraDirs` ("<host> has data outside its files directory: <path>"), the target `ready` report fresh (re-run at this `StagedAt`) and passing again, the build the source runs resolves from the channel (`landing release` rule: the channel's current build equals `Build`) → `ready` with `deadline = now + 24 h`, `SetSelfServeEstimate` refined (Task 9); a failure → abort with the reason; 5 min deadline → abort. |
| - - `ready`: every 10 min `OfflineRestage` (the stager keeps the delta small); at the deadline → abort "the move was not confirmed in time"; the cut moves it to `restage` (Task 5). |
| -- The abort before anything was offline (`offline-queued`, `offline-staging`): `OfflineAbort` → the record clears (queued) or goes `offline-reverting` (staging, the stager removes the prefix) → the job waits for the stager's `reverted` report then `OfflineFinish`; then `releaseBoxes`, `FinishSelfServeMove(aborted, reason)`. |
| - |
| -- [ ] **Step 1: Write the failing tests** with an in-process console, a `DirStore`, fixture edges files and status reports for two boxes (`ie` and `uk`), and a fake box side that writes reports when asked (`fakeBoxes` helper: `lane(ok)`, `ready(facts)`, `staged(round)`): |
| - - `TestAdmissionResumesFromEveryBoundary` (crash after the insert; after the reservation; after the queued record write with a lost response; after the reports: each resumes with exactly one reservation and one record move). |
| - - `TestReservingWaitsThenAborts` (busy box: `reason` set; 24 h → aborted, released). |
| - - `TestReportsDecideFromFacts` (one subtest per target-ready refusal; a failed lane). |
| - - `TestStagingReadyRule` (two rounds under the delta → prechecks; a never-settling instance → ready at the bound with the last round's estimate; a failed round → abort). |
| - - `TestPrechecksRefuse` (ineligible archive; a non-empty extra dir; a stale target report; a promote during staging). |
| - - `TestReadyRestagesAndExpires`. |
| - - `TestAbortBeforeOfflineIsLossless` (from queued: cleared at once; from staging: waits for the stager's reverted report; both release and record `aborted`). |
| -- [ ] **Step 2: Run to verify they fail**: `go test ./internal/console -run 'Admission|Reserving|ReportsDecide|StagingReady|Prechecks|ReadyRestages|AbortBeforeOffline' -count=1`. |
| + - `requested` → `reserving` (`AdvanceSelfServe`). |
| + - `reserving`: `reserveBoxes(sel, {move, row.ID, host, acct, app, now})` where `sel` is rebuilt from the row's participants and the fleet (a participant whose `BoxID` no longer publishes → abort "box <label> was replaced"); busy → stay, `reason` "waiting for another move on this box"; `WindowDeadline` passed → abort "the move was not started in time"; reserved → `selfServeApply(OfflineQueue(...))` with `want: state == offline-queued`; landed → `queued`; `ErrMoveConflict` → reconcile (another move appeared → abort "another move is in flight", release). |
| + - `queued` → `reports` when the record carries the queued move (else re-read; 5 min → abort). |
| + - `reports`: the `lane` report (`Put && Listed && Deleted`), the source `precheck` report and the target `ready` report, each with `BoxID` equal to the pinned participant and `Request == PrecheckAt`; `targetReadyDecision` and `sourcePrecheckDecision` (every fact: `!Known` → "could not determine <fact> on <label>"; not OK → the fact's refusal: "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>" (`!DiskKnown` or `< 2 × replica`), "the target bucket already holds objects for <host>" (`MovesPrefix` or `InstancesPrefix` non-empty before staging), "the target bucket holds a lock for <host>", the existing secrets and capability sentences, "root's stage path is not armed on <label>"; the source: `ArchiveEligible` → the archive's reason, a non-empty `ExtraDirs` entry → "<host> has data outside its files directory: <path>", `Build` must equal the channel's current build → "the channel moved to <build> during the move"); both pass → `selfServeApply(OfflineQueuedToStaging)`, `SetSelfServeStageRequest(StagedAt)`, `staging`; 5 min → abort naming the missing report or the failed fact. |
| + - `staging`: the stager report with `StageRequest == row.StageRequest` and phase `staged` → `Rounds++` persisted; ready rule: `Rounds >= 2 && RoundCopiedBytes + RoundDeferredBytes < 16 MiB` → `prechecks`; else `selfServeApply(OfflineRestage)` only when the current request has been answered, and persist the new `StageRequest` (a lost response: the re-read record's `StagedAt` is the truth; the row follows it); a `failed` report → abort with `StageError`; a round past `max(10 min, 3 × est.Copy)` → counts failed → abort; the bound (30 min since `staging` began, or 10 rounds) → `prechecks` only from a completed successful round (the last one), else wait for it; the estimate refined from the last round's remaining bytes. |
| + - `prechecks`: `selfServeApply(OfflinePrecheck(now))`, `SetSelfServePrecheckRequest`; wait for the source `precheck` and target `ready` reports answering it (the target's `MovesPrefix` is now compared with the stager report's `Staged` receipts: every object under `moves/<host>/` must appear in `Staged` with an equal ETag → else "foreign objects under the staging prefix for <host>"; `InstancesPrefix` must still be empty); both pass → `ready` with `WindowDeadline = now + 24 h`, the estimate refined and `selfServeApply(OfflineSetEstimate)` in the same tick (a disagreement found later is resolved towards the row); 5 min → abort. |
| + - `ready`: every 10 min `OfflineRestage` as in staging (answered requests only); at `WindowDeadline` → abort "the move was not confirmed in time"; the cut moves it to `restage` (Task 5). |
| +- The pre-offline abort (`AbortRequestedAt` set, or any abort reason above, while the record is in `offline-queued` or `offline-staging`, or there is no record move yet): no record move → finalise directly; queued → `selfServeApply(OfflineAbort)` clears the record; staging → `OfflineAbort` → `offline-reverting`, wait for the stager's `reverted` report, then `OfflineFinish`; then the finaliser (Task 8). Failed admission: a reservation for this row older than the grace period with no record move → abort "the move could not be started", finalise. |
| + |
| +- [ ] **Step 1: Write the failing tests** with an in-process console, a `DirStore`, fixture edges files and status reports for two boxes with ids, the real `offlineRound` of two fake-box workers driven by the serial bump (`twoBoxes` helper from the e2e harness; no hand-inserted reports): |
| + - `TestAdmissionResumesFromEveryBoundary` (crash after the insert; after the reservation; after a queued record write whose response was lost; after the reports; after the staging transition: each resumes with exactly one reservation and one record move; a reopened SQLite store each time). |
| + - `TestReservingWaitsThenAborts`; `TestReplacedParticipantAborts`. |
| + - `TestReportsDecideFromFacts` (one subtest per refusal above including every unknown fact; a lane failure; a report from a non-participant box id is ignored). |
| + - `TestStagingReadyRule` (two rounds under the delta → prechecks; a round that copied 1 GiB with zero remaining and zero deferred → not ready by the delta rule, ready after the next quiet round; a never-settling instance → ready at the bound from the last successful round; a failed round → abort; a lost restage response → the row follows the record's `StagedAt`, no second request while the current one is unanswered). |
| + - `TestPrechecksRefuse` (ineligible archive; a non-empty extra dir; foreign staged objects; a promote during staging; a stale report with the old `Request`). |
| + - `TestReadyRestagesAndExpires`; `TestEstimateOnRowAndRecordAgree`. |
| + - `TestAbortBeforeOfflineIsLossless` (requested, reserving, queued, staging: each releases and records `aborted`; staging waits for the stager's reverted report). |
| + - `TestLostResponsesAreReconciledByTheRecord` (for queue, restage and the staging transition). |
| +- [ ] **Step 2: Run to verify they fail**: `go test ./internal/console -run 'Admission|Reserving|Replaced|ReportsDecide|StagingReady|Prechecks|ReadyRestages|EstimateOnRow|AbortBeforeOffline|LostResponses' -count=1`. |
| - [ ] **Step 3: Implement.** |
| - [ ] **Step 4: Run to verify they pass.** |
| -- [ ] **Step 5: Commit** `console: the self-serve job from requested to ready, deciding from the boxes' reports`. |
| +- [ ] **Step 5: Commit** `console: the self-serve job from requested to ready, deciding from the boxes' reports through one apply helper`. |
| -### Task 8: The job's cut: restage, start, seal, flip, open, health, finish, and abort |
| +### Task 8: The job's cut, abort, finalisation and paging |
| **Files:** |
| -- Modify: `internal/console/selfserve_job.go` (the cut phases), `internal/console/apiinstancemove_offline.go` (`offlineStepFor` is called by the job with a synthetic `offlineDoor{acct, app, host, rec, addr: row.RequestedBy}`) |
| -- Create: `internal/console/selfserve_health.go`, `internal/console/selfserve_pages.go` |
| +- Modify: `internal/console/selfserve_job.go`, `internal/console/apiinstancemove_offline.go` (the operator finish door: when a self-serve row exists for the move id, it calls `s.selfServeFinalise` rather than `offlineReleaseLock`) |
| +- Create: `internal/console/selfserve_health.go`, `internal/console/selfserve_pages.go`, `internal/console/selfserve_finalise.go` |
| - Test: `internal/console/selfserve_cut_test.go` |
| **Interfaces:** |
| -- Phases and deadlines (§7.2; `est(term)` is the estimate's term, Task 9): |
| - - `restage`: `OfflineRestage` once, wait for the matching `staged` report; `max(5 min, 2 × est(copy))`; expiry → abort. |
| - - `stopping`: `offlineStepFor` at `offline-staging` (the start checks) then `OfflineStartStaged` (which stamps `OfflineAt`); then at `offline-stopping` the seal check: every source report held, fenced, quiesced, no process; `max(5 min, 2 × est(stop))`; expiry → abort. |
| - - `sealing`: `OfflineSeal` written by the step above; the sealer's `sealed` report; `max(10 min, 3 × est(copy))`; expiry → abort. |
| - - `landing`: `OfflineFlip` once the flip check passes; the target's `landed` report with every item OK (`OfflineLandedBy` is the box's); `max(10 min, 3 × est(land))`; expiry → abort. |
| - - `opening`: the open check (`offlineOpenCheck`) → `OfflineOpenTransition`; then the target's `opened` report; `max(3 min, 2 × est(open))`; expiry → **blocked**: page (below), stay, retry each tick. |
| - - `health`: two passes of the probe (below) 30 s apart; `max(3 min, 2 × est(promote)) + 30 s`; expiry → `finishing` with `warning` "the account did not answer on <region> within <n> minutes", page. |
| - - `finishing`: `offlineFinishCheck` from `offline-open` (opened + every source parked) → `OfflineFinishingTransition`; then from `offline-finishing` (lifted + every source gone) → `OfflineFinish`; 10 min each; expiry → blocked: page, retry each tick, never bypass. |
| - - `done`: `releaseBoxes`, `FinishSelfServeMove(done, "", warning)`, `bumpSerial`; the move log line; the phase timings recorded (Task 9). |
| - - `reverting` (any abort from `offline-stopping` through `offline-landed`): `OfflineAbort` once (recorded as `abort requested`); then the existing reverting evidence (`offlineStepFor` at `offline-reverting`): a source box reporting its row ready → `SetSelfServeReopened`; every report in → `OfflineFinish`, release, `aborted` with the reason; 10 min → blocked: page, retry, the view shows the three facts separately. |
| -- The health probe (`selfserve_health.go`): `func (s *Server) probeTarget(ctx, fleet, row, rec) (ok bool, why string)`: the target box's address from its edges file (`EIP`), TLS with the host as SNI and `Host`, `GET <ReadyPathOrDefault>?carlos-probe=<random>` with `Cache-Control: no-cache`, no redirects, 30 s timeout; ok when the status is 2xx and `x-carlos-version` equals the build the seal recorded (`mv.Source`'s release, or the sealer report's `Release`). Plan 3 adds the `X-Carlos-Probe`/`X-Carlos-Served-By` contract; until then this probe is the plan-2 health check and its limitation is written in the function comment. |
| -- Paging (`selfserve_pages.go`): `alerting.Page(ctx, alerting.LevelIncident, subject, body)` once per phase entry and then every 10 minutes while blocked, for: `opening` past its deadline, `health` failed, `finishing` or `reverting` past 10 minutes, a reservation held by an unknown id past the grace period (Task 3's reclaim finds a row absent but the record carries the move), a lane probe failing on a configured pair. |
| -- The operator's doors act on a self-serve move as on any other (abort before open, finish); a tick that finds the record already past the step it meant to take moves on (idempotent against an operator's transition). |
| -- Sentences (copy rows): "the account did not answer on <region> within <n> minutes"; "the move was not started in time"; "the move was not confirmed in time"; "another move is in flight"; the target-ready refusals of Task 7. |
| - |
| -- [ ] **Step 1: Write the failing tests** with Task 7's fixtures extended so the fake boxes write stop, seal, land, open, park, lift, gone and revert reports on request: |
| - - `TestCutRunsEveryPhaseToDone` (Tito's polling sequence: prepare, poll to ready, cut, poll to none, `last.outcome == done`, `last.to == uk`; the record is cleared; reservations released; one timing row). |
| - - `TestEveryDeadlineBeforeOpenAborts` (one subtest per phase from restage to landing: the deadline passes with a silent box; `abort requested` recorded; the revert reports arrive; `aborted` with the phase's reason; the source row reopened). |
| - - `TestAbortIsInitiatedNotCompleted` (a sealing box still writing after the deadline: the view shows `abort requested` without `source reopened`; then the reports arrive and both facts are set, then `abort finished`). |
| - - `TestOpeningBlockedPagesAndRetries` (no opened report: a page at the deadline, another after 10 minutes, no finish). |
| - - `TestHealthFailureFinishesWithAWarningAndPages`. |
| - - `TestFinishingNeverBypassesTheGate` (a missing parked report: finishing waits and pages; supplying it completes). |
| - - `TestJobYieldsToAnOperatorTransition` (an operator's `open` door between ticks: the job's tick finds `offline-open` and continues from there). |
| - - `TestSwitchOffMidFlightCompletesTheMove`. |
| +- Phases and deadlines (`est` is the row's `EstimateTerms`): |
| + - `restage`: `selfServeApply(OfflineRestage)`, wait for the matching `staged` report; `max(5 min, 2 × est.Copy)`; expiry → abort. Then `selfServeApply(OfflinePrecheck(now))` and wait for both participants' fresh reports passing the precheck decisions (the **cut barrier**); `max(5 min, ...)`; expiry or a failed fact → abort. |
| + - `stopping`: `offlineStartValidation(..., stage: false)` once more (every existing start check), then `selfServeApply(OfflineStartStaged(...))` with `want: state == offline-stopping`; then the seal check (`offlineSealCheck`: every source report held, fenced, quiesced, no process, with `BoxID` the pinned participants) → `selfServeApply(OfflineSeal)`; `max(5 min, 2 × est.Stop)`; expiry → abort. |
| + - `sealing`: the sealer's `sealed` report; `max(10 min, 3 × est.Copy)`; expiry → abort. |
| + - `landing`: `offlineFlipCheck` → `selfServeApply(OfflineFlip)`; the target's `landed` report (`BoxID` the pinned target) with every item OK; `max(10 min, 3 × est.Land)`; expiry → abort. |
| + - `landed`: `offlineOpenCheck` → `selfServeApply(OfflineOpenTransition)` with `want: state == offline-open`; a refusal of the open check or `max(3 min, 2 × est.Open)` **while the record is still `offline-landed`** → abort (the record never left the abortable state); a write error without a confirmed commit → `open-uncertain`. |
| + - `open-uncertain`: re-read the record each tick: `offline-open` → `opened`; still `offline-landed` → back to `landed` (the write did not land; the deadline continues); 5 min unreadable → page, stay. |
| + - `opened`: the target's `opened` report; `max(3 min, 2 × est.Open)`; expiry → **blocked**: page, stay, retry each tick (the open is committed; no abort). |
| + - `health`: a probe when `now - HealthLastProbeAt >= 30 s` (never sleeping in the tick), `RecordSelfServeHealth`; two consecutive OK probes → `finishing`; `max(3 min, 2 × est.Promote) + 30 s` without two OK → `SetSelfServeWarning("the account did not answer on <region> within <n> minutes")`, page, `finishing`; a failed probe between two OK ones resets the count. |
| + - `finishing`: `FinishGate == ""` → `offlineFinishCheck` from `offline-open` (opened + every pinned source parked) → `selfServeApply(OfflineFinishingTransition)`, `SetSelfServeFinishGate("lifted")`; then from `offline-finishing` (lifted + every pinned source gone) → `selfServeApply(OfflineFinish)` with `want: Move == nil && LastMove.ID == row.ID`; 10 min each gate → blocked: page, retry, never bypass. |
| + - `done`: the finaliser. |
| + - `reverting`: `selfServeApply(OfflineAbort)` once (`AbortRequestedAt` set by the door or by the job's reason); then the evidence: `SourceReopenedAt` when (the sealer's `lifted` report when `mv.Sealer != ""`, i.e. a tombstone existed) **and** a source `reverted` report from every pinned source; `AbortFinishedAt` when `offlineStepFor` at `offline-reverting` passes (every report in, the stager's `reverted` when staged) → `selfServeApply(OfflineFinish)`; 10 min → blocked: page, retry; a worker returning from an interrupted copy after the abort writes its reports late and the facts land when they arrive. |
| +- The finaliser (`selfServeFinalise(ctx, row, outcome, reason, warning)`): `FinishSelfServeMove` (outcome, `release_pending = 1`) **then** `releaseBoxes(row.ID)` then `ClearSelfServeRelease`; a crash or failure between leaves `release_pending`, and `OpenSelfServeMoves` returns the row until the release lands; the timings (Task 9); `bumpSerial`; the member stream event. The operator finish door routes a self-serve move's finish through it. A reservation with no row (or a terminal row with `release_pending = 0`) older than the grace period → the orphan scan reclaims it per Task 3's rule. |
| +- The probe (`probeTarget`): the target box's `EIP` from its edges file, TLS with the host as SNI and `Host`, `GET <ReadyPathOrDefault>?carlos-probe=<random>`, `Cache-Control: no-cache`, no redirects, 30 s; OK when 2xx and `x-carlos-version` equals the sealer report's `Release`. Plan 3 adds `X-Carlos-Probe`/`X-Carlos-Served-By`; the comment names the limitation until then. |
| +- Paging (`selfserve_pages.go`): `alerting.Page(ctx, alerting.LevelIncident, subject, body)` through the store's durable `MovePageAttempt`/`SetMovePageAttempt` with rule keys `selfserve:<id>:<condition>` and a 10-minute cadence, for: `opened` past its deadline; a health failure; `finishing` or `reverting` past 10 minutes; `open-uncertain` unreadable past 5 minutes; an orphan reservation (the scan each tick over `active.json`: a `move` entry whose row is absent or terminal-and-released, older than the grace period, that the reclaim rule cannot reclaim because the record still carries the move); a lane probe failing on a configured pair. |
| +- Sentences (copy rows): "the account did not answer on <region> within <n> minutes"; "the move was not started in time"; "the move was not confirmed in time"; "another move is in flight"; "box <label> was replaced"; "the move could not be started"; the fact refusals of Task 7. |
| + |
| +- [ ] **Step 1: Write the failing tests** on the serial-driven two-box harness with the console pass in-process: |
| + - `TestCutRunsEveryPhaseToDone` (Tito's sequence: prepare, poll to ready, cut, poll to none, `last.outcome == done`; the record cleared; the row terminal with `release_pending = 0`; reservations released; timings recorded). |
| + - `TestEveryDeadlineBeforeCommittedOpenAborts` (restage, the cut barrier, stopping, sealing, landing, and the open check refused or expired while `offline-landed`: each sets `abort.requested_at`, the revert reports arrive, `source_reopened_at` only after the sealer lifted and the sources reverted, then `finished_at`, `aborted` with the reason). |
| + - `TestOpenUncertainIsReconciledByTheRecord` (a lost response with the write landed → `opened`; without → back to `landed` and abortable). |
| + - `TestAbortIsInitiatedNotCompleted` (a sealing box still writing after the deadline: `requested_at` set, no `source_reopened_at`; a source `reverted` report before the sealer's `lifted` does not set it; then both facts, then `finished_at`). |
| + - `TestOpenedBlockedPagesAndRetries` (durable attempts: a restart does not page twice within 10 minutes). |
| + - `TestHealthFailureFinishesWithAWarningAndPages`; `TestHealthProbesNeverSleepInTheTick`. |
| + - `TestFinishingNeverBypassesTheGate`. |
| + - `TestFinaliseOrderAndPendingRelease` (crash between outcome and release: the row stays in `OpenSelfServeMoves` until the release lands; a release failure likewise; the operator finish door finalises a self-serve move the same way). |
| + - `TestJobYieldsToAnOperatorTransition` (an operator's open, abort and finish between ticks each reconcile the row). |
| + - `TestSwitchOffMidFlightCompletesAndRenders`. |
| - `TestTwoMovesOnDisjointBoxesRunAtOnce`, `TestTwoMovesOnOneBoxQueueInOrder`. |
| -- [ ] **Step 2: Run to verify they fail**: `go test ./internal/console -run 'Cut|Deadline|AbortIsInitiated|OpeningBlocked|HealthFailure|FinishingNever|JobYields|SwitchOffMidFlight|TwoMoves' -count=1`. |
| + - `TestOrphanReservationIsPaged`. |
| +- [ ] **Step 2: Run to verify they fail**: `go test ./internal/console -run 'Cut|Deadline|OpenUncertain|AbortIsInitiated|OpenedBlocked|HealthFailure|HealthProbes|FinishingNever|Finalise|JobYields|SwitchOffMidFlight|TwoMoves|OrphanReservation' -count=1`. |
| - [ ] **Step 3: Implement.** |
| - [ ] **Step 4: Run to verify they pass**, plus `go test ./internal/console -run 'SelfServe|Offline' -count=1`. |
| -- [ ] **Step 5: Commit** `console: the self-serve cut from restage to finish, deadlines, abort initiation and paging`. |
| +- [ ] **Step 5: Commit** `console: the self-serve cut from restage to finish, the no-return boundary at the committed open, abort facts, finalisation and durable paging`. |
| ### Task 9: The estimate, its storage, the move log and the status view fields |
| **Files:** |
| - Create: `internal/console/selfserve_estimate.go` |
| -- Modify: `internal/console/store.go` (`move_timings (id TEXT PRIMARY KEY, from_region TEXT, to_region TEXT, phase TEXT, bytes INTEGER, seconds REAL, at INTEGER)`), `internal/console/selfserve_job.go` (record each phase's `(bytes, seconds)` at its end: `stop`, `copy` (the seal's final copy, `FinalTXID` bytes from the sealer report's staged map), `land` (`FilesBytes` + replica), `open`, `promote` (from `start` to the first health pass)), `internal/console/apiinstancemove_offline.go` (`offlineView` gains `DisplayName`, `EstimateSeconds`, `OfflineAt`, `SelfServe bool`; `carlos move offline status` shows them through the existing view), the move log (`movelog`'s existing writer gains the job's lines: each phase, each refusal and its reason, the pre-check facts, the lane probes, the health probes, the timings) |
| +- Modify: `internal/console/store.go` (`move_timings (id TEXT, phase TEXT, from_region TEXT, to_region TEXT, bytes INTEGER, seconds REAL, at INTEGER, PRIMARY KEY (id, phase))`), `internal/console/selfserve_finalise.go` (record the five phases from the row's and reports' timestamps: `stop` = `OfflineAt` → every source `stopped` report's `At`, bytes 0; `copy` = the seal transition → the sealer's `sealed` report, bytes = the sum of the sealer report's `Staged` sizes; `land` = flip → the target's `landed` report, bytes = `FilesBytes` + replica bytes; `open` = open → the `opened` report, bytes 0; `promote` = open → `HealthFirstOKAt`, bytes = replica bytes), `internal/console/apiinstancemove_offline.go` (`offlineView` gains `SelfServe *selfServeMoveView` so `carlos move offline status` shows the job's facts), the member stream (`s.auditActivity(acct, app, "self-serve move", Activity{Action: "instance-move", TargetType: "instance", Target: host, Message: <phase>, Detail: <reason>})` on every phase change, keyed on `(id, phase)` so a retried tick does not repeat it), the move log (the existing `movelog` writer gains the job's lines: each phase, each refusal and its reason, the facts, the lane probes, the health probes, the timings) |
| - Test: `internal/console/selfserve_estimate_test.go` |
| **Interfaces:** |
| -- `type estimateTerms struct { Stop, Copy, Land, Open, Promote term }` with `type term struct { Fixed time.Duration; BytesPerSecond float64 }`; defaults from the flagship's measurements (2026-10-21): `Stop {20 s, 0}`, `Copy {10 s, 25 MiB/s}` (1.6 GB in 76 s, 3.7 GB in 142 s), `Land {30 s, 40 MiB/s}`, `Open {30 s, 0}`, `Promote {10 s, 70 MiB/s}` (52 s at 3.7 GB). |
| -- `func (s *Server) estimate(from, to string, remaining, files, replica int64) (seconds int)`: the sum of `Fixed + bytes / rate` per term with the bytes each term moves (`stop`: none; `copy`: `remaining`; `land`: `files + replica`; `open`: none; `promote`: `replica`), each term's rate replaced by the rolling median of the last 20 `move_timings` rows for `(from, to, phase)` when there are at least 5, rounded up to whole minutes, minimum 2 minutes. |
| -- Before a request: `remaining = replica_bytes` and `files`, `replica` from the source box's `Hosts[host]`; after staging: `remaining = ReplicaBytes - StagedBytes + RoundBytes` of the last report. |
| -- On `*Store`: `InsertMoveTiming(t MoveTiming) error`, `MoveTimings(from, to, phase string, n int) ([]MoveTiming, error)`. |
| +- `type estimateTerms struct { Stop, Copy, Land, Open, Promote time.Duration }` (stored as JSON in `estimate_terms`); `type term struct { Fixed time.Duration; BytesPerSecond float64 }`; defaults from the flagship's measurements (2026-10-21): `Stop {20 s, 0}`, `Copy {10 s, 25 MiB/s}`, `Land {30 s, 40 MiB/s}`, `Open {30 s, 0}`, `Promote {10 s, 70 MiB/s}`. |
| +- `func (s *Server) estimate(from, to string, remaining, files, replica int64) (terms estimateTerms, seconds int)`: each term `Fixed + bytes / rate` with the bytes it moves (`copy`: `remaining`; `land`: `files + replica`; `promote`: `replica`; `stop`, `open`: none); a rate term's rate is replaced by the median `bytes/seconds` of the last 20 `move_timings` rows for `(from, to, phase)` with `bytes > 0`, a fixed term's `Fixed` by the median `seconds`, each when at least 5 rows exist; the total rounded up to whole minutes, minimum 2. Before a request: `remaining = replica` from the source box's `Hosts[host]`; after staging: `RoundCopiedBytes + RoundDeferredBytes` of the last successful round. |
| +- On `*Store`: `InsertMoveTiming(t MoveTiming) error` (`type MoveTiming struct { ID, Phase, From, To string; Bytes int64; Seconds float64; At time.Time }`), `MoveTimings(from, to, phase string, n int) ([]MoveTiming, error)`. |
| -- [ ] **Step 1: Write the failing tests**: `TestEstimateUsesDefaultsThenMedians` (no rows → the defaults' sum, rounded up, minimum 2; 5 rows for `copy` → the median rate replaces the default; 4 rows → not yet); `TestEstimateMinimumIsTwoMinutes`; `TestEstimateBeforeAndAfterStaging`; `TestPhaseTimingsRecordedAtDone`; `TestOfflineStatusShowsSelfServeFields`; `TestMoveLogCarriesTheJobsLines`. |
| +- [ ] **Step 1: Write the failing tests**: `TestEstimateUsesDefaultsThenMedians` (no rows → the defaults' sum, rounded up, minimum 2; 5 copy rows → the median rate; 5 open rows → the median fixed term; 4 rows → not yet); `TestEstimateMinimumIsTwoMinutes`; `TestEstimateBeforeAndAfterStaging`; `TestPhaseTimingsRecordedAtDone` (five rows keyed `(id, phase)`, the bytes as defined); `TestOfflineStatusShowsSelfServeFields`; `TestMemberStreamEventsOncePerPhase`; `TestMoveLogCarriesTheJobsLines`. |
| - [ ] **Step 2: Run to verify they fail.** |
| - [ ] **Step 3: Implement.** |
| -- [ ] **Step 4: Run** `go test ./internal/console -run 'Estimate|Timing|OfflineStatusShows|MoveLogCarries' -count=1`. |
| -- [ ] **Step 5: Commit** `console: the move estimate from measured terms and rolling medians; the view and log carry the job`. |
| +- [ ] **Step 4: Run** `go test ./internal/console -run 'Estimate|Timing|OfflineStatusShows|MemberStream|MoveLogCarries' -count=1`. |
| +- [ ] **Step 5: Commit** `console: the move estimate from measured terms and rolling medians; the view, the member stream and the log carry the job`. |
| ### Task 10: End to end on the two-box harness |
| **Files:** |
| -- Test: `internal/host/selfserve_e2e_test.go` (extending `TestOfflineMoveE2E`'s two-box fixtures with a console running the pass) |
| +- Test: `internal/host/selfserve_e2e_test.go` (the two-box fixtures of `TestOfflineMoveE2E` with a console running the pass, boxes driven by the real serial bump) |
| -- [ ] **Step 1: Write the tests**: `TestSelfServeForwardMove` (Tito's sequence against the real doors and real boxes: prepare with a move-capable token, poll to ready, cut, poll to none, `last` done; the instance answers on the target box; reservations released; the source row gone); `TestSelfServeCancelInReady` (never offline, source serving throughout); `TestSelfServeAbortByFailedPrecheck` (a stale live link on the target: refused before the stop, `aborted` with the sentence naming the build); `TestSelfServeAbortByStopDeadline` (a source box that never reports held: abort, source reopened, data intact); `TestSelfServeAbortByLandingFailure` (a landing item failing: lossless abort; the target parked what it extracted); `TestSelfServePostOpenFailureIsDoneWithAWarning` (the app refuses to start on the target: `done` with `last.warning`, a page recorded); `TestSelfServeCutNamingAStaleID`; `TestSelfServeTwoMovesDisjointAndQueued`; `TestSelfServeReservationReclaimOnlyOnEvidence` (a reservation left by a crashed door is reclaimed after the grace period only once the row and record both say gone). |
| +- [ ] **Step 1: Write the tests**: `TestSelfServeForwardMove` (Tito's sequence against the real doors and real boxes with a move-capable token; the instance answers on the target box; reservations released; the source row gone); `TestSelfServeCancelInReady` (never offline); `TestSelfServeAbortByFailedPrecheck` (a stale live link on the target); `TestSelfServeAbortByStopDeadline`; `TestSelfServeAbortByLandingFailure`; `TestSelfServeAbortByOpenCheckRefusal` (the open check never passes while `offline-landed` → abort, not a page); `TestSelfServePostOpenFailureIsDoneWithAWarning`; `TestSelfServeCutNamingAStaleID`; `TestSelfServeRecreatedBetweenRowAndRecord` (delete and recreate between the admission and the queue → the row ends `aborted` "the instance was recreated", the successor untouched); `TestSelfServeTwoMovesDisjointAndQueued`; `TestSelfServeReservationReclaimOnlyOnEvidence`; `TestSelfServeOperatorDoorsUnchanged` (the operator door snapshots again, on the harness). |
| - [ ] **Step 2: Run them; fix what they find within this plan's scope.** |
| - [ ] **Step 3: Run both census tests and the cross-builds**; write `docs/superpowers/notes/census-proposal-self-serve-moves-2.md`. |
| - [ ] **Step 4: Commit** `self-serve moves: end-to-end witnesses on the two-box harness`; push. |
| @@ -248,6 +272,7 @@ Rev 1, 2026-10-22. Builds on plan 1 (`2026-10-22-self-serve-moves-1-lifetimes-an |
| ## Self-review notes |
| -- Spec coverage: §5 (T1), §6.1 door, view, refusals, duplicates (T5), §6.2 catch-up and readiness (T7), §6.3 estimate and files limit (T9, T5), §7 admission order (T7), §7.1 reservations (T3), §7.2 deadlines and the three abort facts (T8), §7.3 lane, clean-then-staged, target-ready, source report, build (T6, T7), §7.4 health check without the probe mode (T8; plan 3 adds the headers), §7.5 before/after open (T8), §7.7 scheduling refusal (T5), §8 observability (T8 pages, T9 log and view), §9 rollout (behind the switch; the rehearsal list is the rollout's, not a test). Deferred: §6.4 holding page and `offline_moves` in the edges file, the probe mode, the five-minute validity (plan 3); §7.6 cleanup and the `cleanup` reservation kind's producer (plan 4; T3 already reads the kind). |
| -- Names used across tasks: `Principal.Moves`, `moveCapable`, `OfflineQueued`, `OfflineQueue`, `OfflineQueuedToStaging`, `OfflineSetEstimate`, `SelfServeMove` (record) and `SelfServeMove` (row; the console's type is in package `console`, the record's in `provision`), `Participants`, `moves.OfflineReservation`, `reserveBoxes`, `releaseBoxes`, `selfServeOn`, `selfServeTick`, `selfServeStep`, `selfServeViewOf`, `moves.LaneReport`, `moves.TargetReadyReport`, `status.HostFacts`, `BoxMetrics.Lanes`, `targetReadyDecision`, `probeTarget`, `estimate`, `move_timings`. |
| +- Spec coverage: §5 (T1), §6.1 door, view, refusals, duplicates (T5), §6.2 (T7), §6.3 (T9, T5), §7 admission order and recovery boundaries (T7), §7.1 reservations with the legacy projection (T3), §7.2 deadlines and the three facts (T8), §7.3 lane, clean-then-staged, target-ready, source precheck, build, empty-directory removal (T6, T7), §7.4 without the probe mode (T8), §7.5 the no-return boundary at the committed open (T8), §7.7 (T5), §8 pages, log, member stream (T8, T9), §9 (behind the switch; per-box concurrency gated by it). Deferred: §6.4 holding page and the probe headers (plan 3); §7.6 cleanup and the `cleanup` reservation's producer (plan 4; T3 states what it needs: identity, freshness and absence semantics of the status's `RetiredSummary`). |
| +- Dependencies stated for plan 3: the record's `SelfServeMove{DisplayName, OfflineAt, EstimateSeconds}` and `Participants.Target.BoxID` for the probe's identity check; for plan 4: `RetiredSummary{ID, Host, State}` with the report's `GeneratedAt` as its freshness. |
| +- Names used across tasks: `selfServeGate`, `moveCapable`, `OfflineQueued`, `OfflineQueue`, `OfflineQueuedToStaging`, `OfflinePrecheck`, `OfflineSetEstimate`, `provision.SelfServeMove`, `Participant`, `Participants`, `moves.OfflineReservation`, `reserveBoxes`, `releaseBoxes`, `offlineSelection`, `offlineStartValidation`, `selfServeAdmits`, `selfServeTick`, `selfServeStep`, `selfServeApply`, `selfServeFinalise`, `selfServeMoveView`, `selfServeAbortView`, `selfServeLast`, `moves.Fact`, `moves.LaneReport`, `moves.TargetReadyReport`, `moves.SourcePrecheck`, `status.HostFacts`, `BoxMetrics.Lanes`, `targetReadyDecision`, `sourcePrecheckDecision`, `probeTarget`, `estimateTerms`, `estimate`, `MoveTiming`, `holdSelfServeLease`. |
| - Review Focus 1 to 5 are pinned by T7, T3, T8, T5, T1. |