plan: self-serve moves 2, the move-capable token, the door, reservations, reports and the job (rev 1)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+253
−0
docs/superpowers/plans/2026-10-22-self-serve-moves-2-door-token-job.md+253 −0
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 (137 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.613s
ok github.com/carlosframework/platform/cmd/moveprobe 11.943s
ok github.com/carlosframework/platform/cmd/zddprobe 0.785s
ok github.com/carlosframework/platform/internal/activator 60.451s
ok github.com/carlosframework/platform/internal/agentsock 0.028s
ok github.com/carlosframework/platform/internal/alerting 0.007s
ok github.com/carlosframework/platform/internal/analytics 0.024s
ok github.com/carlosframework/platform/internal/appconfig 2.929s
ok github.com/carlosframework/platform/internal/apperr 0.142s
ok github.com/carlosframework/platform/internal/applog 0.225s
ok github.com/carlosframework/platform/internal/awsinv 0.008s
ok github.com/carlosframework/platform/internal/boxchoice 0.005s
ok github.com/carlosframework/platform/internal/boxid 0.832s
? 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.784s
ok github.com/carlosframework/platform/internal/changelog 0.627s
ok github.com/carlosframework/platform/internal/claimactivation 1.313s
ok github.com/carlosframework/platform/internal/clamd 0.063s
? 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.280s
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.004s
ok github.com/carlosframework/platform/internal/docsite 0.019s
ok github.com/carlosframework/platform/internal/domains 2.949s
ok github.com/carlosframework/platform/internal/doorkey 0.010s
ok github.com/carlosframework/platform/internal/economics 0.006s
ok github.com/carlosframework/platform/internal/edge 45.339s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.013s
ok github.com/carlosframework/platform/internal/fleetapi 0.199s
ok github.com/carlosframework/platform/internal/gate 1.936s
ok github.com/carlosframework/platform/internal/genselect 0.017s
ok github.com/carlosframework/platform/internal/geo 0.022s
ok github.com/carlosframework/platform/internal/geoip 0.061s
ok github.com/carlosframework/platform/internal/historyreadback 0.239s
ok github.com/carlosframework/platform/internal/host 134.138s
ok github.com/carlosframework/platform/internal/hostactors 5.993s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.010s
ok github.com/carlosframework/platform/internal/hostbox 3.347s
ok github.com/carlosframework/platform/internal/hostclaim 0.860s
ok github.com/carlosframework/platform/internal/hostenv 0.011s
ok github.com/carlosframework/platform/internal/hostevidence 0.010s
ok github.com/carlosframework/platform/internal/hostexec 5.452s
ok github.com/carlosframework/platform/internal/hostfence 0.057s
ok github.com/carlosframework/platform/internal/hostfiles 10.225s
ok github.com/carlosframework/platform/internal/hostfloor 0.033s
ok github.com/carlosframework/platform/internal/hostguard 1.042s
ok github.com/carlosframework/platform/internal/hostops 2.195s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.004s
ok github.com/carlosframework/platform/internal/hostquarantine 0.004s
ok github.com/carlosframework/platform/internal/hostrelocate 0.720s
ok github.com/carlosframework/platform/internal/hostresolve 0.249s
ok github.com/carlosframework/platform/internal/hostwrite 0.008s
ok github.com/carlosframework/platform/internal/infraview 0.007s
ok github.com/carlosframework/platform/internal/instancectl 5.240s
ok github.com/carlosframework/platform/internal/instanceexec 2.889s
ok github.com/carlosframework/platform/internal/instancefs 0.010s
ok github.com/carlosframework/platform/internal/instancekey 0.005s
ok github.com/carlosframework/platform/internal/instanceupdates 0.009s
ok github.com/carlosframework/platform/internal/layout 1.851s
ok github.com/carlosframework/platform/internal/lbhealth 0.003s
ok github.com/carlosframework/platform/internal/lease 10.144s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.534s
ok github.com/carlosframework/platform/internal/ledger 0.016s
ok github.com/carlosframework/platform/internal/mail 0.267s
ok github.com/carlosframework/platform/internal/maintenance 0.091s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.579s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.066s
ok github.com/carlosframework/platform/internal/metrics 0.007s
ok github.com/carlosframework/platform/internal/moves 21.287s
ok github.com/carlosframework/platform/internal/moves/archive 1.126s
ok github.com/carlosframework/platform/internal/objstore 0.260s
ok github.com/carlosframework/platform/internal/ops 0.211s
ok github.com/carlosframework/platform/internal/park 0.230s
ok github.com/carlosframework/platform/internal/plans 0.006s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 13.048s
ok github.com/carlosframework/platform/internal/publicrelease 0.010s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.497s
ok github.com/carlosframework/platform/internal/relay 7.228s
ok github.com/carlosframework/platform/internal/release 24.682s
ok github.com/carlosframework/platform/internal/rollout 3.786s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.067s
ok github.com/carlosframework/platform/internal/rootstate 0.031s
ok github.com/carlosframework/platform/internal/runs 0.510s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.675s
ok github.com/carlosframework/platform/internal/seeds 0.010s
ok github.com/carlosframework/platform/internal/sim/hostlife 10.693s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.345s
ok github.com/carlosframework/platform/internal/status 0.084s
ok github.com/carlosframework/platform/internal/steering 0.010s
ok github.com/carlosframework/platform/internal/stopevidence 0.175s
ok github.com/carlosframework/platform/internal/svcrole 0.004s
ok github.com/carlosframework/platform/internal/telemetry 0.007s
ok github.com/carlosframework/platform/internal/termfence 0.008s
ok github.com/carlosframework/platform/internal/tunnelclient 0.007s
ok github.com/carlosframework/platform/internal/unixdial 0.004s
ok github.com/carlosframework/platform/internal/updatewords 0.005s
ok github.com/carlosframework/platform/internal/usage 0.007s
ok github.com/carlosframework/platform/internal/version 0.005s
ok github.com/carlosframework/platform/internal/vet 1.642s
=== internal_console@0 (78 s)
ok github.com/carlosframework/platform/internal/console 76.086s
=== internal_console@1 (36 s)
ok github.com/carlosframework/platform/internal/console 34.756s
=== internal_console@2 (42 s)
ok github.com/carlosframework/platform/internal/console 40.021s
=== internal_console@3 (37 s)
ok github.com/carlosframework/platform/internal/console 35.153s
=== internal_console@4 (36 s)
ok github.com/carlosframework/platform/internal/console 34.704s
=== internal_console@5 (34 s)
ok github.com/carlosframework/platform/internal/console 32.638s
=== internal_console@6 (36 s)
ok github.com/carlosframework/platform/internal/console 34.144s
=== internal_console@7 (42 s)
ok github.com/carlosframework/platform/internal/console 40.846s
=== root@0 (32 s)
ok github.com/carlosframework/platform 29.931s
=== root@1 (44 s)
ok github.com/carlosframework/platform 41.844s
=== root@2 (41 s)
ok github.com/carlosframework/platform 39.447s
=== 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.021s
=== RUN TestT4StorageProbeMinio
2026/10/22 00:38:00 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E0B303BBE08792, 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.00s)
PASS
ok github.com/carlosframework/platform/internal/host 0.020s
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| new file mode 100644 |
| index 000000000..10381d5c7 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-10-22-self-serve-moves-2-door-token-job.md |
| @@ -0,0 +1,253 @@ |
| +# Self-serve moves, plan 2: the move-capable token, the door, reservations, reports and the job |
| + |
| +> **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). |
| + |
| +**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`. |
| + |
| +**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. |
| + |
| +**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`). |
| + |
| +**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. |
| + |
| +## 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). |
| +- 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. |
| +- 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`). |
| + |
| +--- |
| + |
| +### 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` |
| + |
| +**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. |
| + |
| +- [ ] **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 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`. |
| + |
| +### Task 2: The queued state 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) |
| + |
| +**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`. |
| +- [ ] **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`. |
| + |
| +### Task 3: Per-box reservations in active.json |
| + |
| +**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`) |
| +- 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`. |
| +- [ ] **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`. |
| + |
| +### Task 4: The self_serve_moves table 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` |
| +- 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`. |
| +- [ ] **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`. |
| + |
| +### Task 5: 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) |
| +- Create: `internal/console/selfserve_door.go`, `internal/console/selfserve_view.go` |
| +- Test: `internal/console/selfserve_door_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`. |
| +- [ ] **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`. |
| + |
| +### Task 6: The boxes' reports: the lane, target-ready, the source's facts, 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)) |
| +- 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`. |
| +- [ ] **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`. |
| + |
| +### 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) |
| +- 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. |
| +- 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`. |
| +- [ ] **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`. |
| + |
| +### Task 8: The job's cut: restage, start, seal, flip, open, health, finish, and abort |
| + |
| +**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` |
| +- 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`. |
| + - `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`. |
| +- [ ] **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`. |
| + |
| +### 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) |
| +- 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)`. |
| + |
| +- [ ] **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 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`. |
| + |
| +### 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) |
| + |
| +- [ ] **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 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. |
| + |
| +--- |
| + |
| +## 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`. |
| +- Review Focus 1 to 5 are pinned by T7, T3, T8, T5, T1. |