docs: rehearsal runbook fix round 2: capture the return lane's obligation while the move is live
The capture loop takes an optional claim file and saves the host claim's obligations from control/hosts/<host>.json each round, since the detail answer omits them and they are gone once the move closes. Run 3 starts it with the claim file; R-8 reads the return-lane obligation (park.location moves/<host>/<rb>) from it for its retirement, park incarnation and horizon, then checks the parked manifest and the empty lane after the horizon. Sources cite the obligation fields, the close's wait for them and the park's completion.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+46
−16
docs/superpowers/plans/2026-10-06-moves-phase1-rehearsal.md+46 −16
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-rehearsal.md b/docs/superpowers/plans/2026-10-06-moves-phase1-rehearsal.md| index fd13f8294..bd43d8a5b 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-rehearsal.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-rehearsal.md |
| @@ -67,19 +67,29 @@ Each check says where to read it. |
| afterwards. |
| - **[capture]**: every detail answer of one move, saved as it happens. |
| Start it in a second terminal once the request has reached ready, before |
| - the cut. It stops by itself once no move is in flight: |
| + the cut. It stops by itself once no move is in flight. With a third |
| + argument it also saves, each time round, the host claim's |
| + `obligations` from the deployment bucket (`control/hosts/<host>.json`, |
| + plain JSON). The detail answer does not carry them, and they are gone |
| + once the move closes, so this is the only way to keep a park's horizon |
| + and retirement id. Run 3 needs it (R-8): |
| ```sh |
| - capture() { # capture <host> <file> |
| + capture() { # capture <host> <detail-file> [<claim-file>] |
| while :; do |
| if d=$(detail "$1"); then |
| printf '%s\n' "$d" >> "$2" |
| + if [ -n "$3" ]; then |
| + aws s3 cp --profile <profile> "s3://<deploy-bucket>/control/hosts/$1.json" - \ |
| + | jq -c '.obligations // []' >> "$3" |
| + fi |
| [ "$(printf '%s' "$d" | jq -r .state)" = none ] && break |
| fi |
| sleep 2 |
| done |
| } |
| - capture <host> run-<n>-detail.jsonl |
| + capture <host> run-<n>-detail.jsonl # Runs 1, 2, 4, 5 |
| + capture <host> run-3-detail.jsonl run-3-claim.jsonl # Run 3 |
| # later: the last rollback journal the move showed |
| jq -s '[.[] | .detail.move.rollback | select(. != null)] | last' run-<n>-detail.jsonl |
| ``` |
| @@ -438,16 +448,30 @@ instance is back) in place of `<to>`. R-5 is replaced: |
| rollback returns the target's writes through its own lane, |
| `moves/<host>/<rb>/` in the **source's** data bucket, where `<rb>` is |
| the rollback's id (from [capture]: the last rollback journal's `id`). |
| - The source queues its park when it has probed, with the horizon the |
| - return seal's bound; the park runs only once that horizon plus the |
| - signature age (15 minutes) and the request limit (2 minutes) have |
| - passed. While it is owed, [deploy] the claim's `obligations` has an |
| - entry with `park.location` `moves/<host>/<rb>` and `region` `<from>`; |
| - record its `park.evidence_at`. After that time plus 17 minutes, and |
| - once the obligation is gone: [data] `list-object-versions` on |
| - `<data-bucket-from>` under `moves/<host>/<rb>/` returns no versions and |
| - no delete markers, and `parked/<inc>/<host>/` holds a retirement |
| - directory for it with a `manifest.json`. |
| + The source queues the lane's park in the host claim when it has probed. |
| + The park runs only after its horizon (the return seal's bound) plus the |
| + signature age (15 minutes) and the request limit (2 minutes), and the |
| + rollback does not close until every obligation of the move is |
| + acknowledged. So expect the cut's wait to last at least that long. |
| + The obligation exists only while the move is live, so read it from the |
| + claim file [capture] saved, never from the bucket afterwards: |
| + |
| + ```sh |
| + jq -s --arg loc "moves/<host>/<rb>" '[.[][] | select(.park.location == $loc)] | first' run-3-claim.jsonl |
| + ``` |
| + |
| + It has `kind` `park-replica`, `region` `<from>`, an `id`, a |
| + `retirement`, and `park` with `location`, `incarnation`, `evidence_at` |
| + and `request_limit_ns`. Record `retirement` as `<ret>`, |
| + `park.incarnation` as `<pinc>` and `park.evidence_at`. An empty answer |
| + means the capture missed the obligation: the check fails, and the run |
| + is repeated on a spare host with the claim capture running. |
| + After `park.evidence_at` plus 15 minutes plus `request_limit_ns`, and |
| + once `log/rolled_back.json` exists: |
| + [data] `list-object-versions` on `<data-bucket-from>` under |
| + `moves/<host>/<rb>/` returns no versions and no delete markers, and |
| + `parked/<pinc>/<host>/<ret>/manifest.json` exists in |
| + `<data-bucket-from>`. |
| A clean rollback needs two things: the stopped target's digests equal |
| what the landing installed, and `src_covered` (the source's own replica |
| @@ -566,8 +590,10 @@ the rollback is dirty and the target's writes must come back. |
| carlos env set --app moveprobe --account <ops> MOVEPROBE_FAIL_READY_IN=ie,after-write |
| ``` |
| -- [ ] S-1, S-2 with `--to ie`, S-3, S-4 (capture first). [CLI] M-C8, then |
| - M-C9, exit 3. |
| +- [ ] S-1, S-2 with `--to ie`, S-3, S-4, with the capture started with |
| + its claim file: `capture <host> run-3-detail.jsonl run-3-claim.jsonl`. |
| + [CLI] M-C8, then M-C9, exit 3. The cut's wait includes the return |
| + lane's park horizon (R-8), about 17 minutes or more. |
| - [ ] From [capture]: the last rollback journal has `target_phase` |
| `released` or later and `dirty` `true`. Record its `id` as `<rb>` |
| before anything else; R-8 needs it. |
| @@ -840,7 +866,11 @@ copy is `docs/superpowers/plans/2026-10-06-moves-phase1-copy.md`. |
| | a clean rollback: `src_covered` and unchanged digests; the "clean ..., src_covered ..." line; `dirty` and `evidence` written at released | `internal/host/movesrollback.go:394-398`, `:521-526` | |
| | the rollback's `id` | `internal/provision/provision.go:1068` | |
| | a dirty rollback's return lane is `moves/<host>/<rb>`, parked in the source's region, queued when the source has probed, horizon the return seal's bound | `internal/moves/parkscope.go:66-72`; `internal/host/movesrecover.go:658-675`; `internal/hostclaim/move.go:1093-1108` | |
| -| an obligation's `park.location`, `park.evidence_at`, `region` | `internal/hostclaim/claim.go:116-121`, `:137-147` | |
| +| a claim obligation's fields: `id`, `op`, `kind`, `region`, `retirement`, and `park` with `location`, `incarnation`, `evidence_at`, `request_limit_ns`; the claim's `obligations` list | `internal/hostclaim/claim.go:116-121`, `:137-147`, `:166` | |
| +| the claim at `control/hosts/<host>.json` is plain JSON, read as is | `internal/hostclaim/claim.go:178-182`; `internal/hostclaim/store.go:38-60` | |
| +| the detail answer's claim view carries no obligations | `internal/console/apiinstancemove.go:1356-1362` | |
| +| a rollback closes only when nothing is owed under its move; a finished park removes its obligation | `internal/host/movesrecover.go:805-809`; `internal/host/movesparks.go:134-142`; `internal/park/remote.go:568`; `internal/hostclaim/delete.go:83-98` | |
| +| the parked archive is `parked/<park.incarnation>/<host>/<retirement>/` with `manifest.json` | `internal/park/remote.go:230-232`, `:473` | |
| | a park runs only after `evidence_at` plus the signature age (15 min) and the request limit (2 min) | `internal/park/remote.go:21`, `:516-519`; `internal/moves/parkscope.go:23` | |
| **The door and the fleet** |