carlos / platform Public

plans: Plan 1 rev 6 (the round-5 Plan 1 review's 14 findings): no undefined or duplicate identifiers in the task sequence (floor.go and check.go in Task 1, InstanceStoreEnv in provision, MoveGuardedCut(g)); the root staging relay validates every request component and asks heldStageAuthority before any filesystem effect; the floor is armed on disk by root before staging and enforced at executable selection by the units (prev_ok over carlos.prev.version), with the door admitting only boxes whose edges files show the armed floor and a prev_release at or above it; the dormant boundary is the floor object, not the switch; StageBlocked reused; production-shaped fleet fixtures via boxidtest; the source bound to the claim's executor; multipart receipts carry versions; Ready matches input identities; RequireReleasedMove for the abort replay; no dir-store lease fallback; Verify releases its producer; commit file sets complete

Review file committed unedited as CODEX-PLAN1-REVIEW-R5.md; dispositions in the index.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 296807a2a3375d48c0245dbb4ae43874dbb3c27b parent e1d7c3f
3 files changed, +291 −131
  • docs/superpowers/plans/2026-10-06-moves-phase1-0-index.md +27 −4
  • docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md +128 −127
  • docs/superpowers/plans/CODEX-PLAN1-REVIEW-R5.md +136 −0

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-0-index.md b/docs/superpowers/plans/2026-10-06-moves-phase1-0-index.md
index 776a8aec5..b026303cd 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-0-index.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-0-index.md
@@ -6,9 +6,11 @@
> superpowers:executing-plans. Do not start a plan whose dependencies are
> not merged on `main`.
-**Goal:** Build the move-any-app Phase 1 design (spec rev 7.18, a4711728)
-on `main` at 67547f5 or later, as an ordered series of plans that each
-produce working, tested, mergeable software.
+**Goal:** Build the move-any-app Phase 1 design (spec rev 7.22, e1d7c3f1;
+claim rev 6.30, 73b1b9dc) on `main` at 1c43c2ce or later, as an ordered
+series of plans that each produce working, tested, mergeable software.
+(The series began against spec rev 7.18 on `main` 67547f5; the revisions
+since are in the spec's header and the dispositions below.)
**Spec:** `docs/superpowers/specs/2026-10-02-move-any-app-design.md` (rev
7.20: P7 cold-only, region HA L13 to L17 folded in with claim rev 6.28's
@@ -171,7 +173,7 @@ Plans 1, 2 and 3 each carry the import rule at the definition site.
- **D10 Flat landing only.** Phase 1 lands flat (§6.2, §21); a nested source is refused at the door and at every box step.
- **D11 The terminal outcome survives the close.** Every close CAS (`done`, `rolled_back`, `aborted`) writes `Record.LastMove = &MoveOutcome{ID, Outcome, At, OfflineS}` as it clears `Move`, so a CLI that missed the intermediate states reads the outcome of the move id it requested from `GET …/move` (`{"state":"none","last":{…}}`) and exits with the right code.
- **D12 Order within the series.** The copy gate (the record file and the ship check) is Plan 1 Task 15, so every later plan's strings are checked from the day they are written; the paging primitive `host.PageOperator` is Plan 5 Task 0, before the console pass that needs it; `carlos move`'s dispatch and its `docs/site/cli.md` heading land in one task (Plan 6 Task 1), so no commit fails the docsite gate. `registry.DeleteIfHold` is Plan 1 Task 12's (the `hold_op` task, built before the floor task that reads it), beside `ReleaseHold`; `hostclaim.ReleaseMove` is Plan 1 Task 3's (Plan 4 widens its legal states); `MoveGuard` is Plan 1 Task 2's; `RenderHeld`, `MaterializeHeld` and the strict spawn are Plan 1 Task 7b's; Plan 1 Task 11's host-side report pass is built inside Task 14 (it needs that task's harness).
-- **D13 The operator-off switch.** Every Phase 1 entry point (the new request door and its siblings, the Phase 1 worker, the console's move pass, `carlos move`) is behind `CARLOS_MOVES_PHASE1=on`, read by the console and the agent, **off by default** (Plan 1 Task 0 lands it first). **Off means `main` behaves exactly as today**: the legacy move door (Instance role, `MoveRequest`), the legacy worker (preparing, ready, the staging-wins cut, aborting) and `promoteStagedReplica` stay reachable and unchanged, and every existing test keeps passing unchanged; the new code is unreachable. On: the Phase 1 path handles id'd records and refuses legacy ones. The legacy path is retired in one final task (Plan 6 Task 8) after Paul approves the copy record and the default flips. The switch is spec §15 step 4's dormancy made concrete: the series merges to `main` task by task without exposing a half-move, and the default stays off until every plan is merged and Paul's rehearsal order begins (Plan 7 P-1 turns it on per deployment). The copy gate ties to it: `TestPendingCopyIsNotWired` becomes `TestPendingCopyKeepsTheSwitchOff`: while any row of the copy record is `pending`, `moves.DefaultEnabled` must be `false`, and no task may flip it. Pending strings may therefore be wired as the tasks are built; nothing ships to an operator until Paul approves the record and the switch default changes in one commit.
+- **D13 The operator-off switch.** Every Phase 1 entry point (the new request door and its siblings, the Phase 1 worker, the console's move pass, `carlos move`) is behind `CARLOS_MOVES_PHASE1=on`, read by the console and the agent, **off by default** (Plan 1 Task 0 lands it first). **Off means `main` behaves exactly as today**: the legacy move door (Instance role, `MoveRequest`), the legacy worker (preparing, ready, the staging-wins cut, aborting) and `promoteStagedReplica` stay reachable and unchanged, and every existing test keeps passing unchanged; the new code is unreachable. On: the Phase 1 path handles id'd records and refuses legacy ones. The legacy path is retired in one final task (Plan 6 Task 8) after Paul approves the copy record and the default flips. The switch is spec §15 step 4's dormancy made concrete: the series merges to `main` task by task without exposing a half-move, and the default stays off until every plan is merged and Paul's rehearsal order begins (Plan 7 P-1 turns it on per deployment). The copy gate ties to it: `TestPendingCopyIsNotWired` becomes `TestPendingCopyKeepsTheSwitchOff`: while any row of the copy record is `pending`, `moves.DefaultEnabled` must be `false`, and no task may flip it. Pending strings may therefore be wired as the tasks are built; nothing ships to an operator until Paul approves the record and the switch default changes in one commit. **One exception, by design: the protocol floor (§11) is armed by publishing `control/moves-protocol.json`, an operator action, not by the switch; unpublished, it changes nothing, and once armed it stays armed with the switch off (Plan 1 Task 13, rev 6).**
- **D15 One-box regions only (spec rev 7.20 §7.1, the coordinator's scope ruling).** The request door refuses a move into or out of a region with more than one eligible box (M-C21, pending Paul), computed with main's `eligibleBoxes` and `chooseBox` (`internal/console/boxchoice.go`), which main's tests exercise but no production door calls yet: Plan 1 Task 4's `selectMoveBoxes` is their first production caller and composes every input itself (`liveEdges`, `regionCands` for the AWS region, `boxid.Verifier` over `boxid.Load` with the new `Config.AWSAccount`, `expectedSecretsFP`, `lost` nil, `running` nil); `choose()` therefore names the region's one box, `chooseBox` is unchanged, and its tie-break is unreachable in Phase 1 (region HA owns the hash form, L15). The model stays at one box per region (spec §14.2.6); the two-box model is not built in this series. Plan 1 Task 4 carries the refusal; Plan 7's rehearsals run only in one-box regions.
- **D16 A source terminated before the flip (spec rev 7.20 §7.10).** The fence converts the `moving` op to `relocating` at `pinning` in one claim CAS (C7's claim-side helper); the moves side writes the record's `move.state = "relocating"` with `reason` and, in a one-box region, shows `waiting for a box in <region>` (M-C22) and pages once (Plan 5 Task 7, Plan 6 Tasks 2 and 5); the lane's `park-replica` runs under the target region as for an abort; the move's record clears when the relocation releases to `serving` (the console pass, on `serving` with `rec.Move.ID == op id`). Nothing in Phase 1 builds the relocation itself: that is region HA's.
- **D14 Cold only (P7, claim rev 6.27 §1.1).** No task passes `Mode: "warm"` or `PredecessorCertified: true` to `AdmitWriter`; no task writes `closed/<session>.json`; `KeepStamp` follows the library's rule unchanged. The budget already prices every attempt as a cold admission (Plan 1 Task 10); the per-wake `park-generation` chore is outside the cut window.
@@ -313,6 +315,27 @@ Also from the review: T7 is `release.MaterializeHeld` and S2 is the strict next
| 14 | P2 inventory rejects M-C21/M-C22; record absent from main | **Fixed.** Inventory `M-C1` to `M-C22`; Task 15's first step copies the record from the design branch by sha |
| 15 | P3 D9, Task 2 and Task 14 contradict D13 | **Fixed.** D9 rewritten; Task 2's file list and commit text keep every legacy caller; Task 14 names only the guarded transitions |
+## Plan 1 review round 5 (Astra, on 80647eeb): dispositions (Plan 1 rev 6)
+
+`CODEX-PLAN1-REVIEW-R5.md` beside this file: PLAN 1 NOT READY TO BUILD, 3 P1, 10 P2, 1 P3. Every finding fixed in Plan 1 rev 6 and verified against `origin/main` at 1c43c2ce.
+
+| # | finding | disposition |
+|---|---|---|
+| 1 | P1 build failures in the task sequence: undefined `testHolderKey`; `Check` declared twice; `MoveGuardedCut(g, now)` against a one-argument definition; `instanceStoreEnv` console-local but used from `host` | **Fixed.** The fixture uses the record key grammar `create_test.go:14` uses; Task 11's duplicate removed; Task 14 calls `MoveGuardedCut(g)`; `provision.InstanceStoreEnv(*Record)` in Task 2 serves both packages. Also found and fixed: Task 4 read `moves.ProtocolVersion`/`ReadFloor` before Task 13 defined them; `floor.go` is now Task 1's |
+| 2 | P1 the root staging relay has no operation-authority check | **Fixed.** `ServeHeldStageRequests(ctx, s, optDir, dataDir, authorize)`: every request component validated before any path join (`Host == file name`, `ValidHostName`, `ValidAccount`, `ValidApp`, `ValidVersion`); `heldStageAuthority` in `host` checks `hold_op`, the claim's op id, kind, target and this box, and the record's pinned release; stale and traversal tests; no `pub` parameter |
+| 3 | P1 the hold-prev marker left recovery free to run an old `.prev` after the console committed | **Fixed, redesigned.** The marker is withdrawn. Root writes `/data/.moves/floor.{json,min-release}` before staging and `decidePrev` writes `carlos.prev.version` before the journal; both units select `.prev` only when `prev_ok` (a `sort -V` comparison in the `ExecStart` line) holds; the door admits only when every participant's edges file shows the armed floor and a `prev_release` at or above it, so nothing asynchronous follows admission; shell-level and crash-boundary tests |
+| 4 | P2 default-off system apply contradicted the dormant boundary | **Fixed.** The boundary is the floor object's existence, not the switch (D13 names the exception): `floorAllows` reads only the local file, the registry is consulted only by `rollback`'s hold rule, an armed floor stays armed with the switch off; `TestFloorIsDormantWithoutTheObject` covers both cases |
+| 5 | P2 `fs.ErrPermission` misses `EROFS` | **Fixed.** Main's `stageBlocked` (`adopt.go:250`) exported and reused; the test injects `EROFS` through the `materializeHeldStage` seam; the `chmod` fixture dropped |
+| 6 | P2 Task 4's HTTP fixtures cannot reach their outcomes | **Fixed.** `fleetFixture` writes production-shaped edges files, fresh status snapshots and `boxidtest`-signed identity records, sets `SecretsPub`, `AWSAccount` and `boxCerts`; shared by the door tests and the selector test |
+| 7 | P2 selection permits an absent source | **Fixed.** `selectMoveBoxes` returns `Source`; the door requires it and requires `Op.Exec == Source`; the floor requires files in both regions; missing-source and different-executor tests |
+| 8 | P2 the multipart receipt-match branch unspecified | **Fixed.** When versioned, the receipt match requires `DstVersionID` and records it; a two-round multipart fixture and an old receipt without a version |
+| 9 | P2 `Ready` mixes input generations; T3 loader contradiction; shadow reporting | **Fixed.** `Ready` requires `target.Inputs.FilesPlain == source.FilesPlain` and `Inputs.EnvReport == source.Env.Digest`; T3 uses `LoadMergedHostVersions`; `MergedVersions.Shadowed` → `TargetReport.Notes`, asserted |
+| 10 | P2 interim abort's claim-release crash boundary | **Fixed.** `ReleaseMove` leaves `Journal["released_move"]` on the serving op; `RequireReleasedMove(id)` is the replay rule; crash-and-retry test |
+| 11 | P2 dir-store lease fallback cannot satisfy the guard | **Fixed.** No deployment bucket → Phase 1 rounds refuse with an S1 reason; tests use the lease fake |
+| 12 | P2 `Verify` leaks its producer | **Fixed.** Deferred `pr.CloseWithError` and a wait on the producer's `done`; goroutine-count test on a multi-chunk early mismatch |
+| 13 | P2 commit commands omit files | **Fixed.** Tasks 1, 4, 6, 13 and 14 stage every directory they touch |
+| 14 | P3 competing instructions | **Fixed.** The goal names spec rev 7.22, claim 6.30 and `main` 1c43c2ce; the door's order paragraph matches the code; Task 9's interface prose lists the advisory class; Task 13 names the live read; `release.LiveVersion` is marked as existing (`versions.go:290`) |
+
## The model's limit (rev 7.20)
`internal/sim/hostlife` stays at one box per region plus replacement
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md b/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md
index 4b1bcb60a..2d81397ae 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md
@@ -8,9 +8,9 @@
**Tech Stack:** Go 1.25, modernc sqlite, litestream v0.5.14 and superfly/ltx v0.5.1 (pinned), AWS SDK v2 S3, `github.com/klauspost/compress/zstd` (new, pure Go), the repository's `release.Store`, `lease`, `hostclaim`, `genselect`, `registry`, `provision`, `appconfig` packages.
-**Spec:** `docs/superpowers/specs/2026-10-02-move-any-app-design.md` rev 7.21: §3, §4, §6.1, §6.2, §7.1, §7.2, §7.3, §8, §10, §11, §12 (door), §19.2 K2, K5, K12. Index: `2026-10-06-moves-phase1-0-index.md`.
+**Spec:** `docs/superpowers/specs/2026-10-02-move-any-app-design.md` rev 7.22: §3, §4, §6.1, §6.2, §7.1, §7.2, §7.3, §8, §10, §11, §12 (door), §19.2 K2, K5, K12. Index: `2026-10-06-moves-phase1-0-index.md`.
-**Plan revision:** rev 5 (after the round-4 Plan 1 review, `CODEX-PLAN1-REVIEW-R4.md`; dispositions in the index). Every API this plan names was checked against `origin/main` at 1c43c2ce: `boxchoice.go`'s signatures and the absence of a production caller, `Claim.Validate`'s location grammar, the two system units' direct `carlos.prev` exec, the edge unit's `ProtectSystem=strict`, `edge.go`'s cache-before-activator order, `agentOwnedVars` and `startChild`'s unconditional `ListenControl`, `LoadMergedHost`'s single etag, `objstore.GetIn`, `leasetest`'s create-only `PUT`, `cgroup.Prepared`'s method set and `s3Mirror`'s `Copy`.
+**Plan revision:** rev 6 (after the round-5 Plan 1 review, `CODEX-PLAN1-REVIEW-R5.md`; rev 5 answered round 4; both dispositions in the index). Every API this plan names was checked against `origin/main` at 1c43c2ce: `boxchoice.go`'s signatures and the absence of a production caller, `Claim.Validate`'s location grammar, the two system units' direct `carlos.prev` exec, the edge unit's `ProtectSystem=strict`, `edge.go`'s cache-before-activator order, `agentOwnedVars` and `startChild`'s unconditional `ListenControl`, `LoadMergedHost`'s single etag, `objstore.GetIn`, `leasetest`'s create-only `PUT`, `cgroup.Prepared`'s method set, `s3Mirror`'s `Copy`, `stageBlocked`'s error classes (`adopt.go:250`), the existing `LiveVersion` (`versions.go:290`), `decidePrev`'s `.prev version` call, `MirrorPrefix`'s two receipt branches and `GetRecord`'s `*Record`.
## Global Constraints
@@ -141,11 +141,11 @@ In `server.go`, `Config.MovesEnabled bool` with the doc "the Phase 1 moves switc
### Task 1: Operation ids
**Files:**
-- Create: `internal/moves/ids.go`, `internal/moves/check.go`
-- Test: `internal/moves/ids_test.go`
+- Create: `internal/moves/ids.go`, `internal/moves/check.go`, `internal/moves/floor.go`
+- Test: `internal/moves/ids_test.go`, `internal/moves/check_test.go`, `internal/moves/floor_test.go`
**Interfaces:**
-- Produces: `moves.NewMoveID(r io.Reader) (string, error)`, `moves.NewRollbackID(r io.Reader) (string, error)`, `moves.ValidOpID(id string) bool`, `moves.OpIDKind(id string) string` ("move" | "rollback" | ""); and, in `check.go`, `moves.Check{OK bool; Reason string}`, the shared result type Tasks 7b, 11 and 14 consume. It lives here, in the first task of the package, so Task 7b's `NextSpawnStrict(host) moves.Check` compiles before Task 11 exists (round-4 finding 2); `TestCheckZeroValueFails` pins that a zero `Check` is not OK.
+- Produces: `moves.NewMoveID(r io.Reader) (string, error)`, `moves.NewRollbackID(r io.Reader) (string, error)`, `moves.ValidOpID(id string) bool`, `moves.OpIDKind(id string) string` ("move" | "rollback" | ""); and, in `check.go`, `moves.Check{OK bool; Reason string}`, the shared result type Tasks 7b, 11 and 14 consume. It lives here, in the first task of the package, so Task 7b's `NextSpawnStrict(host) moves.Check` compiles before Task 11 exists (round-4 finding 2); `TestCheckZeroValueFails` pins that a zero `Check` is not OK. Also here, in `floor.go` (moved from Task 13 in rev 6: Task 4's door reads them, which was a forward dependency): `moves.ProtocolVersion = 1`; `moves.FloorKey = "control/moves-protocol.json"`; `moves.Floor{Min int \`json:"min"\`; MinRelease string \`json:"min_release"\`}`; `moves.ReadFloor(ctx, s release.Store) (*Floor, error)` (absent → `nil, nil`); `moves.ReleaseAtOrAbove(version, minRelease string) (bool, error)` (`golang.org/x/mod/semver` on `v`-prefixed strings; confirm `x/mod` is in `go.mod`, else `release`'s own comparison, grep `func Compare` there); `TestFloorReadAndCompare` (absent floor is nil; a written floor reads back; `v0.23.0`, `v0.24.1` at or above `v0.23.0`, `v0.22.9` below).
- [ ] **Step 1: Write the failing test**
@@ -251,8 +251,8 @@ Expected: PASS
- [ ] **Step 5: Commit**
```bash
-git add internal/moves/ids.go internal/moves/ids_test.go
-git commit -m "moves: operation ids (spec §3)
+git add internal/moves/ids.go internal/moves/ids_test.go internal/moves/check.go internal/moves/check_test.go internal/moves/floor.go internal/moves/floor_test.go go.mod go.sum
+git commit -m "moves: operation ids, the Check result type and the protocol floor types (spec §3, §11)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
```
@@ -267,7 +267,7 @@ Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
- Test: `internal/provision/move_test.go`
**Interfaces:**
-- Produces: the extended `provision.MoveState` below; `Record.LastMove *MoveOutcome \`json:"last_move,omitempty"\`` beside `Record.Move` (`provision.go:58`); `MoveGuard` and the guarded transitions `MoveGuardedPromoteReady`, `MoveGuardedCut`, `MoveGuardedAbort`, `MoveGuardedAbortComplete` (below); the legacy `MoveRequest`, `MovePromoteReady`, `MoveMarkCutting`, `MoveAbort`, `MoveAbortComplete`, `MoveComplete` unchanged; state constants `MovePreparing, MoveReady, MoveCutting, MoveReverting, MoveAborting, MoveLanding, MoveCommitted, MoveRollingBack, MoveStuck`; `provision.MoveStart{ID, Incarnation, From, To, Cut, By string; Now time.Time}` and `provision.MoveStartTransition(p MoveStart) func(*Record) error`; `(m *MoveState) Legacy() bool`; `Manifest.Moves *MovesManifest{ReadyPath string}`; `(m Manifest) ReadyPathOrDefault() string`.
+- Produces: the extended `provision.MoveState` below; `Record.LastMove *MoveOutcome \`json:"last_move,omitempty"\`` beside `Record.Move` (`provision.go:58`); `MoveGuard` and the guarded transitions `MoveGuardedPromoteReady`, `MoveGuardedCut`, `MoveGuardedAbort`, `MoveGuardedAbortComplete` (below); the legacy `MoveRequest`, `MovePromoteReady`, `MoveMarkCutting`, `MoveAbort`, `MoveAbortComplete`, `MoveComplete` unchanged; state constants `MovePreparing, MoveReady, MoveCutting, MoveReverting, MoveAborting, MoveLanding, MoveCommitted, MoveRollingBack, MoveStuck`; `provision.MoveStart{ID, Incarnation, From, To, Cut, By string; Now time.Time}` and `provision.MoveStartTransition(p MoveStart) func(*Record) error`; `(m *MoveState) Legacy() bool`; `Manifest.Moves *MovesManifest{ReadyPath string}`; `(m Manifest) ReadyPathOrDefault() string`; `provision.InstanceStoreEnv(rec *Record) string` (`rec.Environment`, or `rec.Slug` when it has none: the store name the create door stamps, `apiinstances.go:1074-1079`; `GetRecord` returns `*Record`, `provision.go:1128`; shared by the console door (Task 4) and the host round (Task 14), which cannot see a console-local helper, round-5 finding 1).
- **Shared contract type (coordination with claim Plan 6 Task 6, `feat/hostclaim-box` bf63e250):** `provision.MoveInputs` is defined by this task below AND by claim Plan 6 in `internal/provision/moveinputs_contract.go`, with exactly the same shape (`BundleETags map[string]string \`json:"bundle_etags,omitempty"\`; OverlayETag string \`json:"overlay_etag,omitempty"\`; Release string \`json:"release,omitempty"\`; FilesPlain string \`json:"files_plain,omitempty"\`; EnvReport string \`json:"env_report,omitempty"\``, verified field for field and tag for tag against bf63e250). Whichever lands on `main` first owns the type and the other imports it (the coordinator's rule). **If `internal/provision/moveinputs_contract.go` is on `main` when this task is built, do not redefine `MoveInputs`: delete it from the code block below and use the existing type.** If this task lands first, it owns the type and claim Plan 6 imports it.
- Later plans add the phase transitions (Plan 2 Task 1 the cut phases, Plan 2 Task 8 the landing phases, Plan 4 Task 1 the rollback phases).
@@ -771,7 +771,7 @@ Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
**Interfaces:**
- Consumes: `hostclaim.StartGuard`, `RequireOp`, `KindServing`, `KindMoving`, `Op`, `Touched`, `ErrWrongState`, `ErrMalformed`.
-- Produces: `hostclaim.BeginMove(opID, by, sourceBox, targetBox, targetRegion string, now time.Time) func(*Claim) error`; `hostclaim.Touch(opID string, regions, boxes []string) func(*Claim) error`; `hostclaim.RequireMoveOp(opID, kind, phase string) func(*Claim) error`; phase constants `PhaseMoving = ""`, `PhaseSealed = "sealed"`; `hostclaim.ReleaseMove(opID, execBox string) func(*Claim) error` (K8, in this plan so the interim abort can release: legal from `moving` with phase `""` or `"aborting"` when no obligation under the op remains, sets `Op = Op{Kind: KindServing, Exec: execBox}`; Plan 4 Task 2 widens it to `landing/committed`, `restoring/opened`, `reverting/opened`). Test `TestReleaseMoveFromMovingNeedsNoChores` beside the two below.
+- Produces: `hostclaim.BeginMove(opID, by, sourceBox, targetBox, targetRegion string, now time.Time) func(*Claim) error`; `hostclaim.Touch(opID string, regions, boxes []string) func(*Claim) error`; `hostclaim.RequireMoveOp(opID, kind, phase string) func(*Claim) error`; phase constants `PhaseMoving = ""`, `PhaseSealed = "sealed"`; `hostclaim.ReleaseMove(opID, execBox string) func(*Claim) error` (K8, in this plan so the interim abort can release: legal from `moving` with phase `""` or `"aborting"` when no obligation under the op remains, sets `Op = Op{Kind: KindServing, Exec: execBox, Journal: map[string]string{"released_move": opID}}`: the serving op remembers which move released it, **operation-bound completion evidence** for the interim abort's crash boundary (round-5 finding 10; `Claim.Validate` accepts an op journal on any kind, `claim.go:89`; if it does not for `serving`, widen it in this task); `hostclaim.RequireReleasedMove(opID string) func(*Claim) error` (nil when the claim is `serving` and `Op.Journal["released_move"] == opID`, else `ErrOpMismatch`), the replay rule Task 14 uses; `TestReleaseMoveLeavesEvidence` (after `ReleaseMove`, `RequireReleasedMove(id)` holds and `RequireReleasedMove(other)` does not; a plain serving claim satisfies neither); Plan 4 Task 2 widens it to `landing/committed`, `restoring/opened`, `reverting/opened`). Test `TestReleaseMoveFromMovingNeedsNoChores` beside the two below.
- [ ] **Step 1: Write the failing test**
@@ -789,10 +789,11 @@ import (
// holder's key, incarnation and location follow the grammar Validate
// enforces (claim.go:236-247: the key is this host's, the incarnation is
// Incarnation(account, app, host, createdAt), the location is exactly
-// "instances/<host>/<inc>" with no trailing slash), built with the same
-// helpers delete_test.go's servingClaim uses, and the fixture asserts
-// Validate() so it can never drift from the real grammar (round-4
-// findings 4 and 9).
+// "instances/<host>/<inc>" with no trailing slash; the key is the record
+// key grammar create_test.go:14 uses, which holderKeyMatches checks), and
+// the fixture asserts Validate() so it can never drift from the real
+// grammar (round-4 findings 4 and 9; round-5 finding 1: no undefined
+// helper).
func movingTestClaim(t *testing.T) *Claim {
t.Helper()
created := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
@@ -801,7 +802,7 @@ func movingTestClaim(t *testing.T) *Claim {
t.Fatal(err)
}
c := &Claim{V: 1, Host: "a.go.example.com", Gen: 3,
- Holder: &Holder{Family: "instance", Key: testHolderKey("bab", "app", "a.go.example.com"), Account: "bab", App: "app", Incarnation: inc, CreatedAt: created, Location: "instances/a.go.example.com/" + inc, Stamp: 3},
+ Holder: &Holder{Family: "instance", Key: "apps/bab/app/instances/a.go.example.com.json", Account: "bab", App: "app", Incarnation: inc, CreatedAt: created, Location: "instances/a.go.example.com/" + inc, Stamp: 3},
Op: Op{Kind: KindServing, Exec: "ie-box"},
Touched: Touched{Complete: true, Regions: []string{"ie"}, Boxes: []string{"ie-box"}}}
if err := c.Validate(); err != nil {
@@ -957,6 +958,8 @@ Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
### Task 4: The request door: operator-only, refusals, the claim CAS
**Files:**
+- Create: `internal/boxid/boxidtest/boxidtest.go` (the exported form of `boxid_test.go`'s `newSigner`/`record`/`verifier`: `boxidtest.NewSigner(t) *Signer`, `(*Signer).Record(t, box, account, region, instance) (boxid.Record, []byte)`, `(*Signer).Certs() map[string]*x509.Certificate`; `boxid_test.go` is rewritten to use it, behaviour unchanged), `internal/console/boxselect.go` (`selectMoveBoxes`, `verifiedBoxes`), `internal/console/boxselect_test.go`
+- Modify: `internal/console/server.go` (`Config.AWSAccount`, `Server.boxCerts` loaded from `boxid.EmbeddedCerts()` at construction), `cmd/console/main.go` (`AWSAccount: os.Getenv("CARLOS_AWS_ACCOUNT")`)
- Modify: `internal/console/apiinstancemove.go` (whole `handleAPIInstanceMoveRequest`, `moveGate`, new `moveOperatorGate`), `internal/console/authcli.go:633-636` (routes)
- Test: `internal/console/apiinstancemove_test.go` (extend `TestAPIInstanceMoveVerbs`; add `TestMoveDoorRefusesWithoutClaimWrite`, `TestMoveMutationsAreOperatorOnly`)
@@ -964,7 +967,7 @@ Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
- Consumes: `hostclaim.Read`, `hostclaim.Update`, `hostclaim.BeginMove`, `hostclaim.Incarnation`, `moves.NewMoveID`, `s.store.IsOperator`, `s.store.CLITokenAddress`, `provision.GetManifest` (regions and `PrivateStorage`), `s.cfg.MovesEnabled` (Task 0), `eligibleBoxes` and `chooseBox` (`boxchoice.go`). This task creates `internal/moves/floor.go` with the one constant `const ProtocolVersion = 1` and adds `MovesProtocol int \`json:"moves_protocol"\`` to the console's edges struct in `instancesplacement.go`; Task 13 fills the file (the floor object, the box's writer, the activation refusal).
- Produces: `(s *Server) moveOperatorGate(w, r) (acct *Account, app, host string, rec *provision.Record, addr string, ok bool)`; the door's refusal sentences (copy ids below).
-The door's order (§7.1, K2): `moveGate` (class rails), operator (`IsOperator`, 404 otherwise), body, region validity and `placements`, the manifest's `regions` when non-empty, a home with replicas (`len(rec.Replicas) > 0`), private storage (the instances manifest's `PrivateStorage`; the refusal is M-C17), then the claim: `hostclaim.Read`; absent or `Holder == nil` → 409 "the instance has no host claim yet. it is adopted on its next reconcile pass"; holder incarnation ≠ `hostclaim.Incarnation(acct, app, host, rec.CreatedAt)` → 409; `Op.Kind != KindServing` → 409 naming the kind; the target box: the lexically-first box publishing the target placement in `readEdgesFiles` (T8 is the target's own check); `hostclaim.Update(ctx, s.cfg.Releases, obs, hostclaim.BeginMove(id, addr, obs.Claim.Op.Exec, targetBox, to, now))`; then `provision.SetRecordMove(... MoveStartTransition ...)`. If the record write fails after the claim CAS, the door answers 502 and logs; the console pass of Plan 5 Task 7 finds a `moving` claim with no record move and releases it (noted there).
+The door's order (§7.1, K2): `moveGate` (class rails), operator (`IsOperator`, 404 otherwise), body, region validity and `placements`, the manifest's `regions` when non-empty (the manifest read fails closed, 503), a home with replicas (`len(rec.Replicas) > 0`), private storage (the instance's store binding, `objstore.GetIn` under `provision.InstanceStoreEnv(rec)`; the refusal is M-C17; a read error is 503), the fleet: `selectMoveBoxes` (the live snapshot, verified identities, exactly one eligible box in each of the two regions, the floor; M-C21 for a second box; 400 for a region with none), then the claim: `hostclaim.Read`; absent or `Holder == nil` → 409 "the instance has no host claim yet. it is adopted on its next reconcile pass"; holder incarnation ≠ `hostclaim.Incarnation(acct, app, host, rec.CreatedAt)` → 409; `Op.Kind != KindServing` → 409 naming the kind; `Op.Exec != sel.Source` → 409 "the source box <exec> is not the eligible box of <from>" (§7.1 binds the source to the claim's executor, round-5 finding 7); `hostclaim.Update(ctx, s.cfg.Releases, obs, hostclaim.BeginMove(id, addr, obs.Claim.Op.Exec, targetBox, to, now))`; then `provision.SetRecordMove(... MoveStartTransition ...)`. If the record write fails after the claim CAS, the door answers 502 and logs; the console pass of Plan 5 Task 7 finds a `moving` claim with no record move and releases it (noted there).
Copy for the gate (all pending review, Plan 6 Task 7): M-C17 "<host> uses private storage. Instances with private storage cannot move yet."; new strings "a home with replicas does not move. remove its replicas first", "region <to> is not one of this app's regions: <list>", "the instance has no host claim yet. it is adopted on its next reconcile pass", "the instance is under a <kind> operation. wait for it to finish".
@@ -1020,8 +1023,16 @@ func TestMoveDoorRefusesWithoutClaimWrite(t *testing.T) {
t.Fatal(err)
}
}
- put("control/edges/uk-box.json", `{"v":1,"box":"uk-box","eip":"1.2.3.4","placement":"uk","data_bucket":"data-uk","data_bucket_region":"eu-west-2","hosts":{},"moves_protocol":1}`)
- put("control/edges/se-box.json", `{"v":1,"box":"se-box","eip":"1.2.3.5","placement":"se","data_bucket":"data-se","data_bucket_region":"eu-north-1","hosts":{},"moves_protocol":1}`)
+ // A production-shaped fleet (round-5 finding 6): selectMoveBoxes needs
+ // a COMPLETE live snapshot and verified identities, so every box gets
+ // an edges file with every eligibility field, a fresh status snapshot
+ // and a signed boxid record; the claimless case below then reaches its
+ // claim refusal instead of an incomplete-fleet 409.
+ put("control/moves-protocol.json", `{"min":1,"min_release":"v0.23.0"}`)
+ fleetFixture(t, fx,
+ fleetBox{Box: "ie-box", Placement: "ie", AWS: "eu-west-1", EIP: "1.2.3.3", Bucket: "data-ie"},
+ fleetBox{Box: "uk-box", Placement: "uk", AWS: "eu-west-2", EIP: "1.2.3.4", Bucket: "data-uk"},
+ fleetBox{Box: "se-box", Placement: "se", AWS: "eu-north-1", EIP: "1.2.3.5", Bucket: "data-se"})
moveURL := u + "/acme.go.tito.io/move"
claimKey := "control/hosts/acme.go.tito.io.json"
// No claim yet → 409, no claim written.
@@ -1067,7 +1078,7 @@ func TestMoveDoorRefusesWithoutClaimWrite(t *testing.T) {
}
```
-`operatorInstanceFixture` and `writeServingClaim` are test helpers added to `apiinstancemove_test.go`; `operatorInstanceFixture` calls `instanceFixture` then marks the token's address an operator with `fx.st.SetOperator(addr, true)` (`store.go:1952`, the call `alertwatch_test.go:52` and `account_test.go:250` already make), where `addr` is the address `instanceFixture` signed the token for.
+`operatorInstanceFixture` and `writeServingClaim` are test helpers added to `apiinstancemove_test.go`; `operatorInstanceFixture` calls `instanceFixture` then marks the token's address an operator with `fx.st.SetOperator(addr, true)` (`store.go:1952`, the call `alertwatch_test.go:52` and `account_test.go:250` already make), where `addr` is the address `instanceFixture` signed the token for. `fleetFixture(t, fx, boxes ...fleetBox)` (in `boxselect_test.go`, shared by the door tests and the selector test) makes the fleet production-shaped: it generates a secrets key pair and sets `fx.srv.cfg.SecretsPub`, sets `fx.srv.cfg.AWSAccount = "111122223333"` and `fx.srv.boxCerts = signer.Certs()` from `boxidtest.NewSigner(t)`, and for each box writes (1) the edges file `control/edges/<box>.json` with `v`, `box`, `box_id` (= box), `eip`, `region` (the AWS region), `placement`, `data_bucket`, `data_bucket_region`, `hibernation_configured: true`, `serves_shared_pool: true`, `accepts_inbound: true`, `secrets_key_fp` (the pair's fingerprint), `capabilities: ["region-ha-1"]`, `hosts: {}`, `moves_protocol: 1`, `moves_floor_min_release` (the published floor's, when `fleetBox.FloorArmed` is not set to false) and `prev_release: "none"` (overridable per box); (2) a fresh status snapshot through `status.WriteBox(ctx, fx.rel, status.Box{Label: box, GeneratedAt: now})` (`accountfleet_test.go:34` makes the same call), which is what `readLiveEdgesSnapshot` joins on; (3) the signed identity record `signer.Record(t, box, "111122223333", aws, box)` written with `boxid.Publish`. `fleetBox{Box, Placement, AWS, EIP, Bucket, PrevRelease string; Unverified, Stale bool}`: `Unverified` skips the identity record, `Stale` writes an old snapshot.
`TestAPIInstanceMoveVerbs` stays as it is (the legacy door, switch off). Add `TestAPIInstanceMoveVerbsPhase1` with `fx.srv.cfg.MovesEnabled = true`, `operatorInstanceFixture`, the `moves_protocol` field on its edges file and a serving claim written before the first request; it mirrors the legacy test's matrix with the new rules: a second request while in flight is 409; abort from `cutting/requested` is **202** (the guarded abort allows it, Task 2); after an abort completes the test releases the claim (`hostclaim.Update(ReleaseMove(id, "ie-box"))`, Task 3) before the next request, because a request against a `moving` claim is 409 by design.
@@ -1174,7 +1185,7 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req
// policy: the create door stamps a store per instance under its
// environment name, or its slug when it has none (apiinstances.go:
// 1074-1079), and turning the policy off later removes no store.
- if store, _, err := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, instanceStoreEnv(rec)); err != nil {
+ if store, _, err := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, provision.InstanceStoreEnv(rec)); err != nil {
jsonError(w, http.StatusServiceUnavailable, "could not read this instance's storage binding. try again")
return
} else if store != nil {
@@ -1205,6 +1216,10 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req
jsonError(w, http.StatusBadRequest, "no box in "+to+" publishes an edges file yet")
return
}
+ if sel.Source == "" {
+ jsonError(w, http.StatusBadRequest, "no eligible box in "+s.homeRegionOut(rec.Region)+" publishes an edges file")
+ return
+ }
targetBox := sel.Target
if !sel.FloorOK {
jsonError(w, http.StatusConflict, "a box in "+rec.Region+" or "+to+" is below the moves protocol floor. roll the platform first")
@@ -1253,9 +1268,10 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req
// moveSelection is the door's answer about the two regions of a move.
type moveSelection struct {
- Target string // the chosen box in `to`; "" when none is eligible
+ Target string // the one eligible box in `to`; "" when none
+ Source string // the one eligible box in `from`; "" when none (the door requires it to equal the claim's Op.Exec)
Many string // the region (to or from) with more than one eligible box; "" when neither
- FloorOK bool // every edges file of either region publishes moves_protocol >= moves.ProtocolVersion
+ FloorOK bool // both regions have files; every file publishes moves_protocol >= ProtocolVersion and, when a floor is published, moves_floor_min_release == floor.MinRelease and prev_release "none" or at or above it
}
var errEdgesIncomplete = errors.New("console: the fleet's edges files could not all be read")
@@ -1295,16 +1311,30 @@ var errEdgesIncomplete = errors.New("console: the fleet's edges files could not
// (D15). Region HA owns the hash tie-break (L15, P5-D2); the one-box
// refusal is what makes that safe here.
//
-// The floor (§11, K10) is read from the same snapshot: every file of
-// either region must publish moves_protocol >= moves.ProtocolVersion
-// (Task 13 adds the field; absent reads as 0, below any floor). An
-// incomplete snapshot answers nothing: a box whose file could not be read
-// may be below the floor or may be the second box of a region.
+// Both regions are selected (round-5 finding 7): §7.1 requires the source
+// to be the one eligible box of its region, and the door then requires
+// the claim's Op.Exec to be that box.
+//
+// The floor (§11, K10, Task 13; round-5 finding 3): both regions must
+// have at least one file; every file of either region must publish
+// moves_protocol >= moves.ProtocolVersion (Task 1; absent reads as 0) and,
+// when control/moves-protocol.json is published, moves_floor_min_release
+// equal to its min_release (the box has armed its local floor files, so
+// its units already select the recovery executable by the floor) and
+// prev_release either "none" or at or above min_release (the box's
+// carlos.prev is not below the floor). That is what makes the admission
+// safe without any later asynchronous step on the boxes. An incomplete
+// snapshot answers nothing: a box whose file could not be read may be
+// below the floor or may be the second box of a region.
func (s *Server) selectMoveBoxes(ctx context.Context, to, from, app string) (moveSelection, error) {
snap, err := s.liveEdges(ctx)
if err != nil || !snap.complete() {
return moveSelection{}, errEdgesIncomplete
}
+ floor, err := moves.ReadFloor(ctx, s.cfg.Releases)
+ if err != nil {
+ return moveSelection{}, err
+ }
aws := map[string]string{}
for _, c := range s.regionCands(snap) {
aws[c.Code] = c.AWSRegion
@@ -1312,10 +1342,27 @@ func (s *Server) selectMoveBoxes(ctx context.Context, to, from, app string) (mov
verified := s.verifiedBoxes(ctx, snap, to, from)
fp := s.expectedSecretsFP()
sel := moveSelection{FloorOK: true}
+ seen := map[string]bool{}
for _, f := range snap.Files {
- if (f.Placement == to || f.Placement == from) && f.MovesProtocol < moves.ProtocolVersion {
+ if f.Placement != to && f.Placement != from {
+ continue
+ }
+ seen[f.Placement] = true
+ if f.MovesProtocol < moves.ProtocolVersion {
sel.FloorOK = false
}
+ if floor != nil {
+ prevOK := f.PrevRelease == "none"
+ if !prevOK && f.PrevRelease != "" {
+ prevOK, _ = moves.ReleaseAtOrAbove(f.PrevRelease, floor.MinRelease)
+ }
+ if f.MovesFloorMinRelease != floor.MinRelease || !prevOK {
+ sel.FloorOK = false
+ }
+ }
+ }
+ if !seen[to] || !seen[from] {
+ sel.FloorOK = false
}
for _, code := range []string{to, from} {
cands := eligibleBoxes(snap, verified, code, aws[code], s.cfg.HomeRegion, fp, nil)
@@ -1323,9 +1370,11 @@ func (s *Server) selectMoveBoxes(ctx context.Context, to, from, app string) (mov
sel.Many = code
return sel, nil
}
- if code == to {
- if c, ok := chooseBox(cands, nil, app); ok {
+ if c, ok := chooseBox(cands, nil, app); ok {
+ if code == to {
sel.Target = c.BoxID
+ } else {
+ sel.Source = c.BoxID
}
}
}
@@ -1333,7 +1382,7 @@ func (s *Server) selectMoveBoxes(ctx context.Context, to, from, app string) (mov
}
```
-`edgesFilePlacementView` (`instancesplacement.go:38`, what `readEdgesFiles` decodes) gains `MovesProtocol int \`json:"moves_protocol"\`` (Task 13 adds the writer's side). Private storage is the instance's store binding (round-4 finding 13): `instanceStoreEnv(rec)` is `rec.Environment`, or `rec.Slug` when it has none, exactly the name the create door stamps (`apiinstances.go:1074-1079`), and `objstore.GetIn` (`objstore.go:236`) answers `nil` for no store; a read error is a 503, never a pass. The manifest's `PrivateStorage` policy is not consulted: it is mutable and create-time (`apiinstances.go:1960-1966`), so it says nothing about an existing instance. Task 14's round makes the same `GetIn` read before its first staging write and fails S1 with M-C17's sentence when a store has appeared since the request (`carlos store create --environment`). Tests: `TestMoveDoorRefusesABoundStoreNotAPolicy` (a record with a store under its slug and the policy off → M-C17 and no claim written; the policy on and no store → admitted; the store read failing → 503 and no claim written; the manifest read failing → 503). `TestSelectMoveBoxesComposesTheCreateDoorsInputs` builds a live snapshot with two verified boxes in `uk` (M-C21 names `uk`), one verified box in `uk` and one unverifiable (its `boxid` record missing: the one box is chosen), a box whose edges file lacks `moves_protocol` (`FloorOK` false), and an incomplete snapshot (`errEdgesIncomplete`); the fixtures are `boxid.Publish`ed records signed with the test certificate `boxid_test.go` uses, so `Verifier.Verify` runs for real.
+`edgesFilePlacementView` (`instancesplacement.go:38`, what `readEdgesFiles` decodes) gains `MovesProtocol int \`json:"moves_protocol"\`` (Task 13 adds the writer's side). Private storage is the instance's store binding (round-4 finding 13): `instanceStoreEnv(rec)` is `rec.Environment`, or `rec.Slug` when it has none, exactly the name the create door stamps (`apiinstances.go:1074-1079`), and `objstore.GetIn` (`objstore.go:236`) answers `nil` for no store; a read error is a 503, never a pass. The manifest's `PrivateStorage` policy is not consulted: it is mutable and create-time (`apiinstances.go:1960-1966`), so it says nothing about an existing instance. Task 14's round makes the same `GetIn` read before its first staging write and fails S1 with M-C17's sentence when a store has appeared since the request (`carlos store create --environment`). Tests: `TestMoveDoorRefusesABoundStoreNotAPolicy` (a record with a store under its slug and the policy off → M-C17 and no claim written; the policy on and no store → admitted; the store read failing → 503 and no claim written; the manifest read failing → 503). `TestSelectMoveBoxesComposesTheCreateDoorsInputs` (`boxselect_test.go`) builds a live fleet with `fleetFixture` (below) and checks: two verified boxes in `uk` → `Many == "uk"`; one verified box in `uk` and one unverifiable (its `boxid` record missing) → that one box is the target; no file in the source region → `Source == ""` and `FloorOK` false; a box whose file lacks `moves_protocol` → `FloorOK` false; a published floor and a box whose `prev_release` is below it, or whose `moves_floor_min_release` is absent → `FloorOK` false, `"none"` → true; an incomplete snapshot → `errEdgesIncomplete`. The door test adds: a claim whose `Op.Exec` is not the source region's eligible box → 409 and no claim written (round-5 finding 7). The fixtures are `boxid.Publish`ed records signed by `boxidtest.NewSigner(t)`, so `Verifier.Verify` runs for real.
**The legacy door stays** (Task 0): today's bodies of `handleAPIInstanceMoveRequest`, `handleAPIInstanceMoveCut` and `handleAPIInstanceMoveAbort` are renamed `legacyHandleAPIInstanceMoveRequest` (and siblings) unchanged, and each new handler's first line is `if !s.cfg.MovesEnabled { s.legacyHandleAPIInstanceMove…(w, r); return }`; `handleAPIInstanceMoveResolve` answers 404 when the switch is off (the route did not exist before). The existing `TestAPIInstanceMoveVerbs` and `TestAPIInstanceMoveClassRails` run as they are with the switch off (no change in behaviour, which is the point); the new tests below set `fx.srv.cfg.MovesEnabled = true`.
@@ -1347,7 +1396,7 @@ Expected: PASS
- [ ] **Step 5: Commit**
```bash
-git add internal/console
+git add internal/console cmd/console internal/boxid
git commit -m "console: the move request door is operator-only and CASes the claim to moving (§7.1, §12, K2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
@@ -1553,7 +1602,7 @@ type VersionedPutter interface {
}
```
-`MirrorRound` gains `Versions map[string]string` (doc: relative key → destination version id for every current source key with a known copy). `copyReceipt` gains `DstVersionID string \`json:"dst_version_id,omitempty"\``. In `MirrorPrefix`: `versioned := m.(VersionedCopier)` decides the mode once; `round.Versions` is non-nil only when `versioned`. **The plain `MirrorStore` path is unchanged** (round-3 finding 4: `TestMirrorPrefix` requires a second identical round to copy nothing, and the legacy worker depends on it): the `staged && d.ETag == s.ETag` early-continue and the receipt-match branch keep their exact behaviour. **Only when `versioned`** does a receipt have to vouch for the version: in the equal-ETag branch, read the receipt; if `rcpt.SrcETag == s.ETag && rcpt.SrcSize == s.Size && rcpt.DstETag == d.ETag && d.Size == s.Size && rcpt.DstVersionID != ""`, record `round.Versions[r] = rcpt.DstVersionID` and continue; otherwise fall through to the copy (a copy whose receipt was lost, or a receipt from an earlier version of the key, is recopied, which yields a fresh version id; the stale version stays unreferenced). Replace the copy call with:
+`MirrorRound` gains `Versions map[string]string` (doc: relative key → destination version id for every current source key with a known copy). `copyReceipt` gains `DstVersionID string \`json:"dst_version_id,omitempty"\``. In `MirrorPrefix`: `versioned := m.(VersionedCopier)` decides the mode once; `round.Versions` is non-nil only when `versioned`. **The plain `MirrorStore` path is unchanged** (round-3 finding 4: `TestMirrorPrefix` requires a second identical round to copy nothing, and the legacy worker depends on it): the `staged && d.ETag == s.ETag` early-continue and the receipt-match branch keep their exact behaviour. **Only when `versioned`** does a receipt have to vouch for the version: in the equal-ETag branch, read the receipt; if `rcpt.SrcETag == s.ETag && rcpt.SrcSize == s.Size && rcpt.DstETag == d.ETag && d.Size == s.Size && rcpt.DstVersionID != ""`, record `round.Versions[r] = rcpt.DstVersionID` and continue; otherwise fall through to the copy (a copy whose receipt was lost, or a receipt from an earlier version of the key, is recopied, which yields a fresh version id; the stale version stays unreferenced). The **receipt-match branch** (`mirror.go:195-203`: `staged` with differing ETags, the multipart-source case, where the destination's ETag never equals the source's) gets the same rule (round-5 finding 8): when `versioned`, the match additionally requires `rcpt.DstVersionID != ""`, which is recorded in `round.Versions[r]`; a matching receipt without a version (written by the plain path, or before this task) falls through to the copy. `TestVersionedMirrorRecordsMultipartVersions`: a fake whose `CopyVersioned` answers a destination ETag different from the source's (as a multipart copy does) and a version id; round one copies and records every version; round two copies nothing and `Versions` still names every key; a receipt hand-written without `dst_version_id` is recopied exactly once and then carries one. Replace the copy call with:
```go
var etag, ver string
@@ -1806,7 +1855,7 @@ Expected: PASS
- [ ] **Step 5: Commit**
```bash
-git add internal/moves/lease.go internal/moves/lease_test.go
+git add internal/moves/lease.go internal/moves/lease_test.go internal/lease
git commit -m "moves: the staging-work lease (45 min TTL) and the SafeUntil guard; lease: AcquireTTL and the single-signature staging mirror (§7.3)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
@@ -2309,7 +2358,17 @@ func Verify(sealed io.Reader, m Manifest, priv *ecdh.PrivateKey, b appconfig.Bin
}
sealedHash := sha256.New()
pr, pw := io.Pipe()
- go func() { pw.CloseWithError(Open(io.TeeReader(sealed, sealedHash), m.SealedKey, priv, b, pw)) }()
+ done := make(chan error, 1)
+ go func() {
+ err := Open(io.TeeReader(sealed, sealedHash), m.SealedKey, priv, b, pw)
+ pw.CloseWithError(err)
+ done <- err
+ }()
+ // Every return, early or late, breaks the pipe so the producer's next
+ // write fails and the goroutine exits, then waits for it: a Verify
+ // that returned on an early mismatch would otherwise leave Open
+ // blocked on the pipe for the rest of the archive (round-5 finding 12).
+ defer func() { pr.CloseWithError(errVerifyAborted); <-done }()
zr, err := zstd.NewReader(pr)
if err != nil {
return err
@@ -2384,7 +2443,7 @@ func Verify(sealed io.Reader, m Manifest, priv *ecdh.PrivateKey, b appconfig.Bin
}
```
-(`Extract` declares `var modes []modeFix` before the loop and calls `restoreModes(modes)` on `io.EOF` before returning nil. `Verify`'s `h` hashes the decoded tar, trailer included, which is exactly what `Build`'s `capWriter` hashed, since `capWriter` sits between the `tar.Writer` and the zstd writer; `Verify` also checks `m.Sealed` over the sealed bytes it read and drains both streams to EOF so `Open`'s terminal authentication result is the verdict.) Tests added: `TestExtractPreservesModes` (a `0555` directory holding a `0400` file round-trips to exactly those modes; `PlainDigest` of the extracted tree equals the original) and `TestVerifyRefusesWhatTheManifestDoesNotPromise` (a correctly sealed archive with a manifest whose `Plain` is wrong, whose entry list lacks a file, whose entry sha256 differs, plus a hand-built sealed archive containing a symlink entry, one over the cap, one whose last sealed byte is flipped and one whose final chunk is cut off (the last two return `Open`'s authentication error, never nil): each returns the named error; the good archive verifies, and a manifest whose `sealed` differs from the bytes is refused).
+(`Extract` declares `var modes []modeFix` before the loop and calls `restoreModes(modes)` on `io.EOF` before returning nil. `Verify`'s `h` hashes the decoded tar, trailer included, which is exactly what `Build`'s `capWriter` hashed, since `capWriter` sits between the `tar.Writer` and the zstd writer; `Verify` also checks `m.Sealed` over the sealed bytes it read and drains both streams to EOF so `Open`'s terminal authentication result is the verdict.) Tests added: `TestExtractPreservesModes` (a `0555` directory holding a `0400` file round-trips to exactly those modes; `PlainDigest` of the extracted tree equals the original) and `TestVerifyRefusesWhatTheManifestDoesNotPromise` (a correctly sealed archive with a manifest whose `Plain` is wrong, whose entry list lacks a file, whose entry sha256 differs, plus a hand-built sealed archive containing a symlink entry, one over the cap, one whose last sealed byte is flipped and one whose final chunk is cut off (the last two return `Open`'s authentication error, never nil): each returns the named error; the good archive verifies, and a manifest whose `sealed` differs from the bytes is refused) and `TestVerifyReleasesTheProducerOnEarlyMismatch` (a three-chunk archive and a manifest whose first entry's size is wrong: `Verify` returns the entry error and `runtime.NumGoroutine()` returns to its baseline within a second; `errVerifyAborted` is the package-level sentinel the deferred close uses).
`seal.go`:
@@ -2544,15 +2603,15 @@ Moved here from Plan 2 (its Tasks 4 and 7) because T3, T7 and S2 need the real t
**Files:**
- Create: `internal/appconfig/renderheld.go`, `internal/appconfig/renderheld_test.go`, `internal/release/materializeheld.go`, `internal/release/materializeheld_test.go`
-- Modify: `internal/activator/backend_exec.go` (`Strict`, `prepareStrict`, `NextSpawnStrict`, the strict spawn path at `spawnInCgroup`), `internal/activator/backend_exec_test.go`, `internal/appconfig/instances.go` (`LoadMergedHostVersions`), `internal/host/agent.go` (`carlos adopt --stage-only` calls `release.ServeHeldStageRequests`), `internal/host/agent_test.go`
+- Modify: `internal/activator/backend_exec.go` (`Strict`, `prepareStrict`, `NextSpawnStrict`, the strict spawn path at `spawnInCgroup`), `internal/activator/backend_exec_test.go`, `internal/appconfig/instances.go` (`LoadMergedHostVersions`), `internal/release/adopt.go` (`StageBlocked` exported), `internal/host/agent.go` (`carlos adopt --stage-only` calls `release.ServeHeldStageRequests` with `heldStageAuthority`), `internal/host/heldstage.go`, `internal/host/heldstage_test.go`
**Interfaces:**
-- `appconfig.LoadMergedHostVersions(ctx, s release.Store, acct, app, envName, host string) (*Manifest, MergedVersions, error)` with `MergedVersions{Default, Environment, Overlay string}`: the same three reads `LoadMergedHost` and `LoadMerged` make (`instances.go:202`, `environments.go:169`), through `getIfExistsAtETag` (`store.go:65`) so the etag of each object actually merged is returned with the bytes; `LoadMergedHost` becomes a wrapper that discards all but the overlay etag (behaviour unchanged, its tests untouched). A later independent read could not establish which versions were rendered (round-4 finding 12), so this is the one loader `RenderHeld` and T3 use.
+- `appconfig.LoadMergedHostVersions(ctx, s release.Store, acct, app, envName, host string) (*Manifest, MergedVersions, error)` with `MergedVersions{Default, Environment, Overlay string; Shadowed []string}` (`Shadowed`: the bundle variables the instance overlay overrides, collected in `applyInstanceOverlay`'s loop; T3 reports them, round-5 finding 9): the same three reads `LoadMergedHost` and `LoadMerged` make (`instances.go:202`, `environments.go:169`), through `getIfExistsAtETag` (`store.go:65`) so the etag of each object actually merged is returned with the bytes; `LoadMergedHost` becomes a wrapper that discards all but the overlay etag (behaviour unchanged, its tests untouched). A later independent read could not establish which versions were rendered (round-4 finding 12), so this is the one loader `RenderHeld` and T3 use.
- `appconfig.RenderHeld(ctx, s release.Store, priv *ecdh.PrivateKey, dataDir string, rt registry.Route, op string, box string) (HeldRender, error)` with `HeldRender{Path, SHA256 string; BundleETags map[string]string; OverlayETag string}` (`BundleETags` = `{"default": v.Default, "environment": v.Environment}`, `OverlayETag` = `v.Overlay`): `LoadMergedHostVersions(ctx, s, acct, app, rt.Environment, rt.Host)`, rendered through the same file writer `syncExecHost` uses (the file-writing half extracted into `renderExecConfig(rt, m, key, priv, dataDir) (path string, changed bool, err error)` and called from both), written whether or not it changed, and a move-bound ack through `WriteConfigAck` with three additive `ConfigAck` fields: `MoveOp string \`json:"move_op,omitempty"\``, `FileSHA256 string \`json:"file_sha256,omitempty"\``, `BundleETags map[string]string \`json:"bundle_etags,omitempty"\``. `SyncExec` keeps skipping non-ready rows (`execRouteBase`), so the two never race on one file.
-- `release.MaterializeHeld(ctx, s Store, optDir string, rt registry.Route, version string) (*Manifest, error)`: `ensureStaged(ctx, s, optDir, RouteAccount(rt), rt.App, version, rt.Host)` (`adopt.go:1317`) then `PointLive(optDir, host, acct, version, app)` (`versions.go:267`, already exported), no restart, no bake-hold check: a held row runs exactly the pinned release. When this process cannot write `optDir` (`errors.Is(err, fs.ErrPermission)` from the stage or the symlink: the edge unit has `ProtectSystem=strict` and writes only `/data` and `/run/carlos`, `carlos-edge.service:35-37`), it returns `release.ErrStageBlocked` wrapping the cause, so the caller relays instead of failing (Task 11's T7, round-4 finding 6). `release.ServeHeldStageRequests(ctx, s Store, pub ed25519.PublicKey, optDir, dataDir string) error` is the root side of that relay: for every `<dataDir>/moves/held-stage/<host>.json` (`{acct, app, host, version, op}`), it runs `MaterializeHeld` and writes `<dataDir>/moves/held-staged/<host>.json` (`{version, op, at}`), then removes the request; `carlos adopt --stage-only` (the root pass `carlos-stage.service` runs when `/data/stage-requested` changes, `agent.go:3525`) calls it after its release adoption. `release.LiveVersion(optDir, host string) (string, error)` reads the `live/<host>` symlink's target version (readable from the sandboxed agent), which is the proof T7 trusts; the ack file is only a hint.
+- `release.MaterializeHeld(ctx, s Store, optDir string, rt registry.Route, version string) (*Manifest, error)`: `ensureStaged(ctx, s, optDir, RouteAccount(rt), rt.App, version, rt.Host)` (`adopt.go:1317`) then `PointLive(optDir, host, acct, version, app)` (`versions.go:267`, already exported), no restart, no bake-hold check: a held row runs exactly the pinned release. When this process cannot write `optDir` (classified by main's own `stageBlocked`, `adopt.go:250`: `EROFS`, `EPERM`, `EACCES`; `ProtectSystem=strict` answers a write with `EROFS`, which is **not** `fs.ErrPermission`, round-5 finding 5; exported in this task as `release.StageBlocked(err error) bool`; the edge unit writes only `/data` and `/run/carlos`, `carlos-edge.service:35-37`), it returns `release.ErrStageBlocked` wrapping the cause, so the caller relays instead of failing (Task 11's T7, round-4 finding 6). `release.HeldStageRequest{Account, App, Host, Version, Op, Box string}` and `release.ServeHeldStageRequests(ctx, s Store, optDir, dataDir string, authorize func(HeldStageRequest) error) error` is the root side of that relay (round-5 finding 2). For every `<dataDir>/moves/held-stage/<name>.json` it (1) decodes the request and **validates every component before any filesystem effect**: `Host == <name>` and `ValidHostName(Host)` (letters, digits, `.` and `-` only: no `/`, no `..`, no NUL), `ValidAccount(Account)`, `ValidApp(App)`, `ValidVersion(Version)` (`versions.go:69`), `Op != ""`, `Box != ""`; a request failing any of these is removed with a log line and never joined into a path; (2) calls `authorize(req)` and on error removes the request with a log line; (3) runs `MaterializeHeld` and writes `<dataDir>/moves/held-staged/<host>.json` (`{version, op, at}`), then removes the request. `authorize` is the host package's closure `heldStageAuthority(reg *registry.Registry, store release.Store, box string)` (`internal/host/heldstage.go`; `release` never imports `hostclaim`): it reads the registry row for `req.Host` and requires `hold_op == req.Op` (Task 12) and the route's account and app equal to the request's; reads the claim (`hostclaim.Read`) and requires `Op.ID == req.Op`, `Op.Kind` in `{KindMoving, KindLanding}`, `Op.Intent["target"] == req.Box` and `req.Box == box` (this box); reads the record and requires its pinned release (`rec.Move.Inputs.Release`, the version T7 is checking) equal to `req.Version`; anything else is `ErrStaleHeldStage`. So a request queued for operation A cannot run after A is aborted or replaced: the claim no longer names A, and `live/<host>` is never repointed to A's build. `carlos adopt --stage-only` (the root pass `carlos-stage.service` runs when `/data/stage-requested` changes, `agent.go:3525`) calls it after its release adoption, with `heldStageAuthority`. There is no `pub` parameter: `ensureStaged` (`adopt.go:1317`) is main's own staging step and verifies what the adopt pass verifies at that layer. `release.LiveVersion(optDir, host string) (string, error)` **already exists on main** (`versions.go:290`) and is reused: it reads the `live/<host>` symlink's target version (readable from the sandboxed agent), which is the proof T7 trusts; the ack file is only a hint.
- `ExecBackend.Strict func(host string) bool` (when true the spawn uses `b.prepareStrict(host, limit)` then `Start(cmd)` on what it returns, keeping it for the process's life and running `KillAndEmpty(ctx)` then `Close()` on exit; a failure refuses the spawn with `ErrStrictRequired`). `prepareStrict func(host string, limit cgroup.Limit) (strictProc, error)` is an unexported seam with `strictProc interface{ Start(*exec.Cmd) error; KillAndEmpty(context.Context) error; Close() error }`, which `*cgroup.Prepared` satisfies on every build (`strict_linux.go:233-348`, `strict_other.go:15-18`); nil → `b.Cgroups.PrepareStrict`, and a nil `Cgroups` under `Strict` is `ErrStrictRequired`. `Cgroups` stays the concrete `*cgroup.Manager` (`backend_exec.go:100`): tests replace the seam, never the field (round-4 finding 2). `(b *ExecBackend) NextSpawnStrict(host string) moves.Check` (`moves.Check` is Task 1's; S2: OK when `Strict(host)` and cgroup v2 delegation is present, `cgroup.Manager.Root() != ""`, and `PrepareStrict` is supported on this build). The host package sets `eb.Strict` from the hold view (a host whose record has a move in flight and the switch on); off → never strict, today's behaviour.
-Tests: `RenderHeld` writes the file for a `landing` row `SyncExec` would skip and the ack carries `move_op`, the sha256 and the bundle etags, which equal the etags the dir store reports for the fixture's default and environment objects; a second call with no change still writes an ack. `MaterializeHeld` stages the pinned version into a temp `optDir` and points `live/<host>` at it, never at the channel head; with `optDir` made read-only (`os.Chmod(optDir, 0o555)`, skipped as root) it returns `ErrStageBlocked` and writes nothing. `ServeHeldStageRequests` materializes a request file into `optDir`, writes the ack and removes the request; `LiveVersion` reads the result. `TestSpawnUnderAMoveIsStrictOrRefused` (the `prepareStrict` seam returning an error → `Start` returns `ErrStrictRequired` and spawns nothing; the seam returning a recording `strictProc` → `Start` is called on it and `KillAndEmpty` then `Close` on exit; with `Strict` false the plain path runs as today).
+Tests: `RenderHeld` writes the file for a `landing` row `SyncExec` would skip and the ack carries `move_op`, the sha256 and the bundle etags, which equal the etags the dir store reports for the fixture's default and environment objects; a second call with no change still writes an ack. `MaterializeHeld` stages the pinned version into a temp `optDir` and points `live/<host>` at it, never at the channel head; with the `materializeHeldStage` seam (a package variable `= ensureStaged`, replaced by the test) failing with `&os.PathError{Err: syscall.EROFS}`, and separately `EACCES`, it returns `ErrStageBlocked` and writes nothing (the `chmod` fixture is gone: it tested discretionary permissions, not a read-only mount). `TestServeHeldStageRequestsRefusesStaleAndTraversal`: a request whose op the claim no longer names → removed, `live/<host>` untouched; a request whose `Host` is `../x`, or does not match its file name → removed before any path join (a `release.Store` fake records no reads); the happy path materializes, acks and removes; `LiveVersion` reads the result. `heldStageAuthority`'s own test covers each refused field. `TestSpawnUnderAMoveIsStrictOrRefused` (the `prepareStrict` seam returning an error → `Start` returns `ErrStrictRequired` and spawns nothing; the seam returning a recording `strictProc` → `Start` is called on it and `KillAndEmpty` then `Close` on exit; with `Strict` false the plain path runs as today).
- [ ] **Steps 1 to 5**. Commit: `git commit -m "appconfig.RenderHeld, release.MaterializeHeld, strict spawns under a move (§9.1, §9.2, §5.3)" ...`
@@ -2742,7 +2801,7 @@ Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
- Modify: `internal/activator/backend_exec.go` (export `LaunchEnvironment`), `internal/activator/backend_exec_test.go` (one test)
**Interfaces:**
-- Produces: `moves.ReportKeyFromSecrets(secretsKeyB64 string) ([]byte, error)` (HKDF-SHA256 over the raw private key bytes, info `"carlos-move-env-report-v1"`); `moves.EnvReport{V int; Vars map[string]string; Digest string}`; `moves.ReportEnv(key []byte, env map[string]string) EnvReport` (each value HMAC-SHA256 under key, hex; `Digest` the sha256 of the sorted `name=mac` lines); `moves.Class(name string) string` returning `"region" | "layout" | "box" | "equal"`; `moves.CompareEnv(source, target EnvReport, flatLayout bool) []EnvDiff` with `EnvDiff{Name, Kind string}` (kinds: `missing`, `extra`, `differs`, `region-same`, `layout-missing`, `layout-present`); `moves.RegionVar = "CARLOS_REGION"`, `LayoutVars = {"CARLOS_CONTROL_SOCKET", "CARLOS_PURGE"}`, `BoxVars = {"INVOCATION_ID", "JOURNAL_STREAM", "HOSTNAME", "PWD", "OLDPWD", "SHLVL", "_"}`.
+- Produces: `moves.ReportKeyFromSecrets(secretsKeyB64 string) ([]byte, error)` (HKDF-SHA256 over the raw private key bytes, info `"carlos-move-env-report-v1"`); `moves.EnvReport{V int; Vars map[string]string; Digest string}`; `moves.ReportEnv(key []byte, env map[string]string) EnvReport` (each value HMAC-SHA256 under key, hex; `Digest` the sha256 of the sorted `name=mac` lines); `moves.Class(name string) string` returning `"region" | "layout" | "advisory" | "box" | "equal"` (`LayoutVars` is `CARLOS_CONTROL_SOCKET` alone; `AdvisoryVars` is `CARLOS_PURGE`); `moves.CompareEnv(source, target EnvReport, flatLayout bool) []EnvDiff` with `EnvDiff{Name, Kind string}` (kinds: `missing`, `extra`, `differs`, `region-missing`, `region-same`, `layout-missing`, `layout-present`, `advisory-differs`; `(EnvDiff).Note() bool` is true for `advisory-differs` alone, the one kind T3 records without failing); `moves.RegionVar = "CARLOS_REGION"`, `LayoutVars = {"CARLOS_CONTROL_SOCKET", "CARLOS_PURGE"}`, `BoxVars = {"INVOCATION_ID", "JOURNAL_STREAM", "HOSTNAME", "PWD", "OLDPWD", "SHLVL", "_"}`.
- `(b *ExecBackend) LaunchEnvironment(rt registry.Route) (map[string]string, error)`: `childEnv(instanceEnv(rt))` plus the agent-owned variables `Start` adds, decoded back into a map (the composed launch environment, codex R2-8).
- [ ] **Step 1: Write the failing tests**
@@ -3135,10 +3194,10 @@ Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
- Create: `internal/host/movesreport.go`, `internal/host/movesreport_test.go`
**Interfaces:**
-- Produces (pure): `moves.PutReplace(ctx, s release.Store, key string, body []byte) error`: `release.Store.Put` with an empty `ifMatch` is create-only (`store.go:38`), so a recurring object (a report, the console record) is written by `Get` for the current etag (absent → `""`), then `Put(key, body, etag)`, retrying once on `release.ErrCASConflict`; every mutable object the series writes to the deployment bucket goes through it (reports here, the console record in Task 12, the hold view's receipts ledger when it lives in a `release.Store`); `moves.Check` (Task 1's); `moves.TargetReport{V int; Box, Region string; MoveID string; At time.Time; Checks map[string]Check; Inputs provision.MoveInputs; AttemptsAdmitted int; CutBudgetS int}`; check ids `T1 … T12` as constants `CheckProtocol = "T1"` etc.; `moves.SourceReport{V int; Box string; MoveID string; At time.Time; Env EnvReport; S1, S2 Check; FilesPlain string; Archive *ArchiveRef}` with `ArchiveRef{ManifestKey, ManifestVersion, SealedKey, SealedVersion, Plain string}` (the versions the source's `PutVersionedAt` responses returned, Task 14's ledger `archive_versions`, published so T5 reads by exact version: round-4 finding 12); `moves.ReportFreshness = 10 * time.Minute`; `moves.Ready(roundDelta int64, bar int64, target *TargetReport, source *SourceReport, now time.Time) (ok bool, reason string)`; `(r TargetReport) Passing() (bool, string)`.
-- Produces (host): `(w *movesWorker) writeSourceReport(ctx, s, rec, env map[string]string, filesPlain string, archive *moves.ArchiveRef) error`; `(w *movesWorker) targetReportPass(ctx, s release.Store, recs []provision.Record) error` run by the target region's box for every record whose `Move.To` is its placement: computes T1 (own `moves_protocol` vs floor), T2 (own `secrets_key_fp`, `appconfig.KeyFingerprint` of `CARLOS_SECRETS_KEY`'s public half, equals the console's `SecretsKeyFP` in `moves.ConsoleKey` = `control/moves-console.json`, which Task 12 has the console write at startup; no such object yet on main), T5 (the archive manifest and sealed object the source report's `Archive` names, read by exact version through `lease.VersionedStore`'s `GetVersion`/`OpenVersion` in the target's own bucket; a report without `Archive` fails T5 "the source has published no archive yet": `archive.Verify(sealed, manifest, priv, binding)` (Task 7) checks the plaintext digest, every entry's path, size and sha256, the entry types and the cap; the report records the manifest's `plain`), T6 (`diskBytes(dataDir())`: free ≥ 2×replica + archive + 2 GiB and resulting use < 85%), T8 (one hosting box publishes the target placement), T9 (`hostclaim.Read`: `KindMoving`, id, holder incarnation = `move.json`'s; this region and box in `touched`), T10 (`PutIfAbsent`/`DeleteVersion` of `moves/_lane/<id>` in the source bucket through `lease.VersionedStore`), T11 (`moves.Estimate` with **measured** rates, never the worked example: `moves.Rates` come from `moves.Measurements{Cross, S3, Disk MeasuredRate}` with `MeasuredRate{MiBPerS float64; At time.Time; Bytes int64}`; the target measures regional S3 by putting and getting a 16 MiB object under `moves/<host>/<id>/probe/` in its own bucket once per report and the disk by writing and reading a 64 MiB file under `<DataDir>/.moves/` with `O_DIRECT` where available, fsync otherwise; the cross-region rate comes from the source's receipts ledger, which Task 14 extends with `LastRoundBytes` and `LastRoundSeconds` of its most recent copying round (copied through the target bucket's region, so it is the path the cut uses), published in the source report as `source.cross_rate`; a measurement older than one hour, or missing, fails T11 with "no fresh rate measurement for <which>"; `CARLOS_MOVE_RATES` exists only as a test override and is refused outside tests (`testing.Testing()`)), T12 (`claim.QuietAfter[region]` passed), T3 (compose the flat held row's environment: `appconfig.LoadMergedHost` for the record's environment rendered to a temp config file, the carried `<host>.env` from the newest archive, the agent-owned variables, this process's inherited variables; `moves.CompareEnv`), T4 (for each store binding in the composed environment per `objstore.EnvNames`: list one key, put/get/delete `.carlos-move-probe/<move id>` through the S3 client of `internal/objstore`'s existing probe helper if one exists, else a minimal AWS SDK client), T7 (`release.MaterializeHeld(ctx, s, optDir, rt, version)`, Task 7b; on a hardened box it returns `ErrStageBlocked`, and then the pass writes `<DataDir>/moves/held-stage/<host>.json` and touches the `stage-requested` marker through `stageRequester(dd)` (`agent.go:1262`, the existing relay to the root `carlos-stage.service`, whose `carlos adopt --stage-only` now calls `release.ServeHeldStageRequests`); T7 passes only once `release.LiveVersion(optDir, rt.Host)` equals the pinned version, and until then fails "waiting for the root stage pass"; the report records the version; round-4 finding 6); T8 and T9 bind to the named target: this box's id must equal `op.intent.target` (`selectMoveBoxes`'s choice, Task 4), else T9 fails "this box is not the move's target"; the report writer is the named target only. S2 (`ExecBackend.NextSpawnStrict(host)`, Task 7b); S1 wakes a hibernating source and reads the answer from its own process, never from the response cache (round-4 finding 11: `edge.go:1651-1663` serves a cached GET before the activator runs, so a cached 200 could certify a source that cannot wake): `(*edge.Edge).ServeUncached(w, r)` (Task 14 adds it: `ServeHTTP` with the request marked `uncachedProbe` in its context, the same marker pattern as `doorCallerFrom`, which makes `cacheable` false at `edge.go:1651` so neither `GetFor` nor `maybeStore` runs, while the hibernating branch still wakes through `e.act.ServeHTTP`) with `GET /<ready path>`, `Host: <host>`, a recording `ResponseWriter` and a 30 s timeout; `moveCfg.ReadyProbe` is set by `setMovesReadyProbe(e)` from `edge.RunWithActivatorReady`'s callback (`agent.go:576`, where the agent receives its `*edge.Edge`) and fails "edge not ready" before that. Direct socket dialing is not used: it cannot wake a sleeping instance. Writes `TargetReportKey` with `Inputs`, `AttemptsAdmitted` and the measurements. The host-side pass and its test are **written in Task 14** (they need its harness; D12); this task's deliverable is the pure package and `moves.PutReplace`.
+- Produces (pure): `moves.PutReplace(ctx, s release.Store, key string, body []byte) error`: `release.Store.Put` with an empty `ifMatch` is create-only (`store.go:38`), so a recurring object (a report, the console record) is written by `Get` for the current etag (absent → `""`), then `Put(key, body, etag)`, retrying once on `release.ErrCASConflict`; every mutable object the series writes to the deployment bucket goes through it (reports here, the console record in Task 12, the hold view's receipts ledger when it lives in a `release.Store`); `moves.Check` (Task 1's); `moves.TargetReport{V int; Box, Region string; MoveID string; At time.Time; Checks map[string]Check; Inputs provision.MoveInputs; AttemptsAdmitted int; CutBudgetS int; Rates Rates; Notes []string}`; check ids `T1 … T12` as constants `CheckProtocol = "T1"` etc.; `moves.SourceReport{V int; Box string; MoveID string; At time.Time; Env EnvReport; S1, S2 Check; FilesPlain string; Archive *ArchiveRef}` with `ArchiveRef{ManifestKey, ManifestVersion, SealedKey, SealedVersion, Plain string}` (the versions the source's `PutVersionedAt` responses returned, Task 14's ledger `archive_versions`, published so T5 reads by exact version: round-4 finding 12); `moves.ReportFreshness = 10 * time.Minute`; `moves.Ready(roundDelta int64, bar int64, target *TargetReport, source *SourceReport, now time.Time) (ok bool, reason string)`; `(r TargetReport) Passing() (bool, string)`.
+- Produces (host): `(w *movesWorker) writeSourceReport(ctx, s, rec, env map[string]string, filesPlain string, archive *moves.ArchiveRef) error`; `(w *movesWorker) targetReportPass(ctx, s release.Store, recs []provision.Record) error` run by the target region's box for every record whose `Move.To` is its placement: computes T1 (own `moves_protocol` vs floor), T2 (own `secrets_key_fp`, `appconfig.KeyFingerprint` of `CARLOS_SECRETS_KEY`'s public half, equals the console's `SecretsKeyFP` in `moves.ConsoleKey` = `control/moves-console.json`, which Task 12 has the console write at startup; no such object yet on main), T5 (the archive manifest and sealed object the source report's `Archive` names, read by exact version through `lease.VersionedStore`'s `GetVersion`/`OpenVersion` in the target's own bucket; a report without `Archive` fails T5 "the source has published no archive yet": `archive.Verify(sealed, manifest, priv, binding)` (Task 7) checks the plaintext digest, every entry's path, size and sha256, the entry types and the cap; the report records the manifest's `plain`), T6 (`diskBytes(dataDir())`: free ≥ 2×replica + archive + 2 GiB and resulting use < 85%), T8 (one hosting box publishes the target placement), T9 (`hostclaim.Read`: `KindMoving`, id, holder incarnation = `move.json`'s; this region and box in `touched`), T10 (`PutIfAbsent`/`DeleteVersion` of `moves/_lane/<id>` in the source bucket through `lease.VersionedStore`), T11 (`moves.Estimate` with **measured** rates, never the worked example: `moves.Rates` come from `moves.Measurements{Cross, S3, Disk MeasuredRate}` with `MeasuredRate{MiBPerS float64; At time.Time; Bytes int64}`; the target measures regional S3 by putting and getting a 16 MiB object under `moves/<host>/<id>/probe/` in its own bucket once per report and the disk by writing and reading a 64 MiB file under `<DataDir>/.moves/` with `O_DIRECT` where available, fsync otherwise; the cross-region rate comes from the source's receipts ledger, which Task 14 extends with `LastRoundBytes` and `LastRoundSeconds` of its most recent copying round (copied through the target bucket's region, so it is the path the cut uses), published in the source report as `source.cross_rate`; a measurement older than one hour, or missing, fails T11 with "no fresh rate measurement for <which>"; `CARLOS_MOVE_RATES` exists only as a test override and is refused outside tests (`testing.Testing()`)), T12 (`claim.QuietAfter[region]` passed), T3 (compose the flat held row's environment: `appconfig.LoadMergedHostVersions` (Task 7b) for the record's environment rendered to a temp config file, its `MergedVersions` recorded as `Inputs.BundleETags` and `Inputs.OverlayETag` and its `Shadowed` names (bundle variables the instance overlay overrides) recorded in `TargetReport.Notes []string`, which `TestTargetReportPassFlagsEachCheck` asserts for an overlay that shadows one bundle variable (round-5 finding 9), the carried `<host>.env` from the newest archive, the agent-owned variables, this process's inherited variables; `moves.CompareEnv`), T4 (for each store binding in the composed environment per `objstore.EnvNames`: list one key, put/get/delete `.carlos-move-probe/<move id>` through the S3 client of `internal/objstore`'s existing probe helper if one exists, else a minimal AWS SDK client), T7 (`release.MaterializeHeld(ctx, s, optDir, rt, version)`, Task 7b; on a hardened box it returns `ErrStageBlocked`, and then the pass writes `<DataDir>/moves/held-stage/<host>.json` and touches the `stage-requested` marker through `stageRequester(dd)` (`agent.go:1262`, the existing relay to the root `carlos-stage.service`, whose `carlos adopt --stage-only` now calls `release.ServeHeldStageRequests`); T7 passes only once `release.LiveVersion(optDir, rt.Host)` equals the pinned version, and until then fails "waiting for the root stage pass"; the report records the version; round-4 finding 6); T8 and T9 bind to the named target: this box's id must equal `op.intent.target` (`selectMoveBoxes`'s choice, Task 4), else T9 fails "this box is not the move's target"; the report writer is the named target only. S2 (`ExecBackend.NextSpawnStrict(host)`, Task 7b); S1 wakes a hibernating source and reads the answer from its own process, never from the response cache (round-4 finding 11: `edge.go:1651-1663` serves a cached GET before the activator runs, so a cached 200 could certify a source that cannot wake): `(*edge.Edge).ServeUncached(w, r)` (Task 14 adds it: `ServeHTTP` with the request marked `uncachedProbe` in its context, the same marker pattern as `doorCallerFrom`, which makes `cacheable` false at `edge.go:1651` so neither `GetFor` nor `maybeStore` runs, while the hibernating branch still wakes through `e.act.ServeHTTP`) with `GET /<ready path>`, `Host: <host>`, a recording `ResponseWriter` and a 30 s timeout; `moveCfg.ReadyProbe` is set by `setMovesReadyProbe(e)` from `edge.RunWithActivatorReady`'s callback (`agent.go:576`, where the agent receives its `*edge.Edge`) and fails "edge not ready" before that. Direct socket dialing is not used: it cannot wake a sleeping instance. Writes `TargetReportKey` with `Inputs`, `AttemptsAdmitted` and the measurements. The host-side pass and its test are **written in Task 14** (they need its harness; D12); this task's deliverable is the pure package and `moves.PutReplace`.
-Test (pure) `internal/moves/readiness_test.go`:
+Tests (pure) `internal/moves/readiness_test.go`: `TestReadyNeedsFreshReports` below, and `TestReadyRejectsMismatchedInputs` (two fresh passing reports whose `target.Inputs.FilesPlain` and `source.FilesPlain` differ → not ready, the reason naming both digests; the same for `Inputs.EnvReport` against `source.Env.Digest`; equal inputs → ready):
```go
func TestReadyNeedsFreshReports(t *testing.T) {
@@ -3215,10 +3274,7 @@ var AllChecks = []string{CheckProtocol, CheckSecrets, CheckEnv, CheckStorage, Ch
// (§7.3); the cut re-checks it at `admitted` (§7.4).
const ReportFreshness = 10 * time.Minute
-type Check struct {
- OK bool `json:"ok"`
- Reason string `json:"reason,omitempty"`
-}
+// Check is Task 1's (check.go).
type TargetReport struct {
V int `json:"v"`
@@ -3302,6 +3358,15 @@ func Ready(roundDelta, bar int64, target *TargetReport, source *SourceReport, no
if !source.S2.OK {
return false, "S2: " + source.S2.Reason
}
+ // The target's checks must have been made against what the source
+ // publishes NOW (round-5 finding 9): a fresh passing report for
+ // archive A must not promote after the source has published archive B.
+ if target.Inputs.FilesPlain != source.FilesPlain {
+ return false, fmt.Sprintf("target checked files %s, the source now publishes %s", target.Inputs.FilesPlain, source.FilesPlain)
+ }
+ if target.Inputs.EnvReport != source.Env.Digest {
+ return false, "target checked another launch environment than the source now reports"
+ }
return true, ""
}
```
@@ -3333,7 +3398,7 @@ Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
- Test: `internal/registry/hold_test.go`
**Interfaces:**
-- Produces: `Route.HoldOp string`; `(r *Registry) SetHold(host, status, op string) error` (status one of `moving`, `landing`, `restoring`, `releasing`; refuses others); `(r *Registry) ReleaseHold(host, op string) (bool, error)` (`UPDATE routes SET status='ready', hold_op='' WHERE host=? AND hold_op=?`, true when a row changed); `(r *Registry) DeleteIfHold(host, op string) (bool, error)` (`DELETE FROM routes WHERE host=? AND hold_op=?`; the source's row deletion at finalization, §7.9 step 3; here rather than Plan 3 so Plan 3 Task 8 does not depend on Task 7, D12; its negative test: a row held by another op, or not held, is not deleted and returns false); `registry.HoldStatuses = map[string]bool{...}`; `registry.IsHold(status string) bool`.
+- Produces: `Route.HoldOp string`; `(r *Registry) SetHold(host, status, op string) error` (status one of `moving`, `landing`, `restoring`, `releasing`; refuses others); `(r *Registry) ReleaseHold(host, op string) (bool, error)` (`UPDATE routes SET status='ready', hold_op='' WHERE host=? AND hold_op=?`, true when a row changed); `(r *Registry) HoldRows() ([]Route, error)` (every row with `hold_op != ''`; Task 13's rollback hold rule and Task 7b's `heldStageAuthority` read it); `(r *Registry) DeleteIfHold(host, op string) (bool, error)` (`DELETE FROM routes WHERE host=? AND hold_op=?`; the source's row deletion at finalization, §7.9 step 3; here rather than Plan 3 so Plan 3 Task 8 does not depend on Task 7, D12; its negative test: a row held by another op, or not held, is not deleted and returns false); `registry.HoldStatuses = map[string]bool{...}`; `registry.IsHold(status string) bool`.
- [ ] **Step 1: Write the failing test**
@@ -3433,97 +3498,33 @@ Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
### Task 13: The protocol floor and `moves_protocol` (after Task 12: it reads `hold_op`)
**Files:**
-- Create: `internal/moves/floor.go`, `internal/moves/floor_test.go`
-- Modify: `internal/host/edgesfile.go` (field and writer), `internal/console/instancesplacement.go` (reader struct), `internal/host/systemapply.go` (refusal on activation), `internal/host/systemapply_test.go`, `internal/console/server.go` (publish the console's fingerprint and protocol at startup, switch on only), `internal/host/units/carlos-system-recover.service` and `internal/host/units/carlos-system-update.service` (the `.prev` selection honours the hold-prev marker), `internal/host/host_test.go` (the unit assertions), `internal/host/movesfloor.go` and `internal/host/movesfloor_test.go` (the agent's floor arming, `participants.json`, the hold-prev marker)
+- Create: `internal/host/movesfloor.go`, `internal/host/movesfloor_test.go`
+- Modify: `internal/host/edgesfile.go` (fields and writer), `internal/host/edgesfile_test.go`, `internal/console/instancesplacement.go` (reader struct), `internal/host/systemapply.go` (`armFloor`, `floorAllows`, the `decidePrev` sidecar, the rollback hold rule), `internal/host/systemapply_test.go`, `internal/console/server.go` (publish the console's fingerprint and protocol at startup, switch on only), `internal/host/units/carlos-system-recover.service` and `internal/host/units/carlos-system-update.service` (the `.prev` selection by the floor, `prev_ok`), `internal/host/host_test.go` (the unit assertions and the shell-level selection test), `internal/host/movesfloor.go` and `internal/host/movesfloor_test.go` (the agent's hourly refresh of the local floor files and the edges-file fields' readers)
**Interfaces:**
-- Produces: `moves.ProtocolVersion = 1`; `moves.FloorKey = "control/moves-protocol.json"`; `moves.Floor{Min int; MinRelease string}`; `moves.ReadFloor(ctx, s release.Store) (*Floor, error)` (absent → nil, nil); `moves.ReleaseAtOrAbove(version, minRelease string) (bool, error)` using `release.CompareVersions` if it exists (grep `func Compare` in `internal/release`), else `golang.org/x/mod/semver` on `v`-prefixed strings; `moves.ConsoleKey = "control/moves-console.json"`, `moves.ConsoleRecord{V int; Protocol int; SecretsKeyFP string; At time.Time}`.
-- `edgesFile.MovesProtocol int \`json:"moves_protocol"\`` (not omitempty: absent means a box below any floor).
-- `systemApplier` refuses to activate a build below `Floor.MinRelease` in `install`, `rollback` and `recoverIfNeeded`'s restore (`restoreFiles`). The floor is **armed locally**: every successful read of `control/moves-protocol.json` (the agent's edges-file loop once an hour, and `carlos system apply` before it stages) writes `<DataDir>/.moves/floor.json` durably (temp file, fsync, rename; under `/data`, which the sandboxed agent may write (`carlos-edge.service:37`, `ReadWritePaths=/data /run/carlos`) and root's `system apply` may read; `<optDir>` is not writable by the agent, round-4 finding 6); activation reads the local copy first and the store second. Boot recovery runs before networking (`systemapply.go:109`), so the local copy is what it has. With a floor in hand, below it refuses. With **no** floor anywhere: the box is a participant when its registry (`/data/registry.db`, the agent's path) holds any row with `hold_op != ""` **or** the hold view's last written snapshot (`<DataDir>/.moves/participants.json`, written by every moves pass with the hosts whose move names this box as source or target, `preparing` included) names any host; then activation refuses ("the moves floor is unknown and this box holds an operation"); otherwise it proceeds with a log line (a box holding no operation cannot harm a move). The floor is never deleted locally once armed.
+- Produces: `moves.ConsoleKey = "control/moves-console.json"`, `moves.ConsoleRecord{V int; Protocol int; SecretsKeyFP string; At time.Time}`; `moves.FloorFile = ".moves/floor.json"` and `moves.FloorMinReleaseFile = ".moves/floor.min-release"` (relative to `DataDir`); `moves.PrevVersionFile = "carlos.prev.version"` (beside `.prev` under `optDir`). `moves.ProtocolVersion`, `FloorKey`, `Floor`, `ReadFloor` and `ReleaseAtOrAbove` **are Task 1's** (`floor.go`; Task 4 reads them).
+- `edgesFile.MovesProtocol int \`json:"moves_protocol"\`` (not omitempty: absent means a box below any floor); `edgesFile.MovesFloorMinRelease string \`json:"moves_floor_min_release,omitempty"\`` (the local floor file's `min_release`; absent means the box has not armed a floor); `edgesFile.PrevRelease string \`json:"prev_release,omitempty"\`` (`carlos.prev.version`'s content; `"none"` when there is no `.prev`; absent means unknown, which the door treats as below the floor).
+- **The floor is enforced at executable selection, by the units themselves, and by the applier** (§11, "every path that activates an executable"; round-5 findings 3 and 4). Three durable files, all written synchronously by root, never deleted once written: (1) `<DataDir>/.moves/floor.json` and the one-line `<DataDir>/.moves/floor.min-release`, written by `carlos system apply` from `control/moves-protocol.json` **before it stages anything** (`install`'s first step, after `openSystemStore`; temp file, fsync, rename) and refreshed by the agent's hourly edges-file loop (the agent may write `/data`, `carlos-edge.service:37`; `<optDir>` it may not, round-4 finding 6); (2) `<optDir>/carlos.prev.version`, written by `decidePrev` (`systemapply.go:473-489`, which already runs `.prev version` to learn it) with that version, durably, **before the update journal is written**, so a `.prev` and its sidecar are never out of step at a recovery. The units then select the executable with the floor in hand. Both `ExecStart` lines become `/bin/sh -c 'prev_ok() { [ -s /data/.moves/floor.min-release ] || return 0; [ -s /opt/carlos/carlos.prev.version ] || return 1; f=$(cat /data/.moves/floor.min-release); p=$(cat /opt/carlos/carlos.prev.version); [ "$(printf "%s\n%s\n" "$f" "$p" | sort -V | head -n1)" = "$f" ]; }; if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && prev_ok; then exec /opt/carlos/carlos.prev system apply --recover; fi; exec /opt/carlos/carlos system apply --recover'` (`system apply` without `--recover` in the update unit; `sort -V` is GNU coreutils, on every Debian box; the `v` prefix sorts naturally; equal is at-or-above). No floor file → `prev_ok` is true and the line behaves exactly as today. A floor file with a `.prev` below it, or without a sidecar → the current `carlos system apply` runs, and its `floorAllows` refuses a below-floor activation itself: `install` for `req.Version` (after arming), `rollback` and `restoreFiles` for the sidecar's version, **reading only the local floor file** in `rollback` and `restoreFiles` (boot recovery runs before networking, `systemapply.go:109`). `host_test.go:1314-1330`'s unit assertions gain `prev_ok`, `floor.min-release` and `carlos.prev.version` for both units (red before the unit edit).
- **The recovery entry points are the units, not only the applier** (round-4 finding 5): `carlos-system-recover.service:17` and `carlos-system-update.service:25` exec **`carlos.prev system apply [--recover]`** directly when `/opt/carlos/system/router/journal.json` exists, and an older `.prev` runs its own recovery without these checks. So the agent maintains a **hold-prev marker**, `/data/.moves/hold-prev` (a one-line reason): written whenever this box is a participant (the registry's `hold_op` rows or `participants.json`, as above) and either no floor is armed or `.prev`'s version (the previous build `decidePrev` recorded, `systemapply.go:473-489`, read through `a.prevBuild(ctx)`) is below `Floor.MinRelease`; removed when the condition clears. Both units' `ExecStart` lines gain `&& ! [ -e /data/.moves/hold-prev ]` in the `.prev` condition, so a held box falls through to the current `carlos system apply`, whose `floorAllows` checks run; `host_test.go:1314-1330`'s unit assertions gain the marker string for both units (red before the unit edit). The hold is honest about its cost: a held box whose current `carlos` cannot start recovery loops on `OnFailure` until an operator acts, which is what §11 asks of a participant below the floor.
+ **The admission-to-enforcement boundary** (round-5 finding 3): the console admits a move only when every edges file of both regions publishes `moves_floor_min_release` equal to the published floor's `min_release` (so the box has written its local floor files and its units already select by them) and `prev_release` either `"none"` or at or above it (Task 4's `FloorOK`). So at the moment a move is committed, every participant's recovery already selects by the floor and its `.prev` is already at or above it; nothing asynchronous has to happen on a box after admission, and a crash right after the commit recovers correctly. A floor raised later is enforced at the next recovery by the same files once the hourly refresh has written them, and the door refuses new moves until every box's file shows the new value.
- **Dormancy (D13):** arming, `participants.json` and the marker run only with `moveCfg.Enabled`. With the switch off none of the three files exists, `floorAllows` finds no floor and no participant and proceeds with its log line, and the unit lines select `.prev` exactly as today: the dormant boundary is the absence of those files, and `TestFloorIsDormantWithTheSwitchOff` (an agent pass with the switch off writes none of them over a registry with a held row) pins it.
+ **Dormancy (D13; round-5 finding 4):** the floor is the one Phase 1 element not behind the switch, by design: it is a safety property armed by an operator action (publishing `control/moves-protocol.json`, create-only, before the first production move, §11), not by the switch. A deployment that never published the object has no floor files anywhere, `prev_ok` is true, `floorAllows` finds no local file and proceeds with one log line, and the registry is not consulted: `main` behaves exactly as today. Once armed, the floor stays armed if the switch is later turned off. `rollback` additionally refuses while the registry holds any `hold_op` row (§11, "any downgrade of a participant in a held operation"); no such row exists in a deployment where the switch was never on. The agent writes no marker and no `participants.json`: rev 5's `hold-prev` marker is withdrawn (its asynchronous publication left exactly the window the review named).
- [ ] **Step 1: Write the failing tests**
-```go
-package moves
-
-func TestFloorReadAndCompare(t *testing.T) {
- s := release.NewDirStore(t.TempDir())
- f, err := ReadFloor(context.Background(), s)
- if err != nil || f != nil {
- t.Fatalf("absent floor: %+v %v", f, err)
- }
- s.Put(context.Background(), FloorKey, []byte(`{"min":1,"min_release":"v0.23.0"}`), "")
- f, err = ReadFloor(context.Background(), s)
- if err != nil || f.Min != 1 || f.MinRelease != "v0.23.0" {
- t.Fatalf("floor: %+v %v", f, err)
- }
- for v, want := range map[string]bool{"v0.23.0": true, "v0.24.1": true, "v0.22.9": false} {
- if ok, err := ReleaseAtOrAbove(v, f.MinRelease); err != nil || ok != want {
- t.Fatalf("%s: %v %v", v, ok, err)
- }
- }
-}
-```
-
-In `internal/host/systemapply_test.go`, beside the existing applier tests: `TestSystemApplyRefusesBelowTheMovesFloor` writes `control/moves-protocol.json` with `min_release: "v0.30.0"` into the test's store and asserts an apply of a pointer at `v0.29.0` records the refusal reason containing "moves protocol floor" and leaves the binary untouched, and that `<DataDir>/.moves/floor.json` now exists; `TestBootRecoveryUsesTheArmedFloorOffline` (the store returns errors for every read, the local floor file names `v0.30.0`, `carlos.prev` is `v0.29.0` → `restoreFiles` refuses); `TestUnknownFloorRefusesOnlyAParticipant` (no floor anywhere: a registry with a held row refuses, an empty one proceeds); `TestHoldPrevMarkerFollowsTheFloor` (participant and no floor → marker with its reason; participant and `.prev` below the floor → marker; `.prev` at the floor → no marker; not a participant → no marker; switch off → never written); `TestSystemUnitsSkipPrevUnderHold` (the `ExecStart` line's `sh -c` script is run with a temp root substituted for `/opt/carlos` and `/data` and stub `carlos`/`carlos.prev` scripts that record which ran: with the marker the current `carlos` runs, without it `carlos.prev`); `TestEdgesFilePublishesMovesProtocol` asserts the written file carries `"moves_protocol":1`. The door (Task 4) reads `readEdgesSnapshot(ctx, s)` and refuses with 409 "the fleet's edges files could not all be read. try again" unless `snap.Complete` (every edges file read; `complete()` would also demand `BoxesComplete`, which only `readLiveEdgesSnapshot` computes, and the floor has no liveness question); `readEdgesFiles` discards completeness and is not used for the floor decision.
+(`TestFloorReadAndCompare` and `floor.go` are Task 1's.) In `internal/host/systemapply_test.go`, beside the existing applier tests: `TestSystemApplyArmsTheFloorBeforeStaging` (the store holds `min_release: "v0.30.0"`; `install` of a pointer at `v0.29.0` writes both local floor files first, then refuses with "moves protocol floor" and leaves the binary untouched); `TestBootRecoveryUsesTheArmedFloorOffline` (the store errors on every read; the local floor names `v0.30.0`; `carlos.prev.version` says `v0.29.0` → `restoreFiles` refuses; `v0.30.0` → proceeds; no sidecar → refuses); `TestDecidePrevWritesTheVersionSidecarBeforeTheJournal` (the fake `ops.run` answers `version`; the sidecar exists with `.prev`'s version before `journal.json` appears); `TestFloorIsDormantWithoutTheObject` (no object and no local files, a registry with a held row: `install`, `rollback` and `restoreFiles` all proceed; then an armed local file and the switch off: a below-floor `restoreFiles` still refuses); `TestRollbackRefusesUnderAHold` (a `hold_op` row refuses `rollback`; none does not). In `host_test.go`: `TestSystemUnitsSelectPrevByTheFloor` (both `ExecStart` scripts are extracted from the unit text and run under `sh -c` with `/opt/carlos` and `/data` rewritten to a temp root and stub `carlos`/`carlos.prev` scripts that record which ran: no floor file → `.prev`; floor `v0.30.0` and sidecar `v0.29.0` → current; sidecar `v0.30.0` → `.prev`; floor present and no sidecar → current). In `edgesfile_test.go`: `TestEdgesFilePublishesMovesProtocolFloorAndPrev` (`"moves_protocol":1`; `moves_floor_min_release` from the local file, absent without it; `prev_release` from the sidecar, `"none"` without a `.prev`). The door (Task 4) reads the live snapshot and `complete()` (every edges file and every status snapshot read; the floor question needs every file, and liveness is already required for eligibility).
- [ ] **Step 2: Run tests to verify they fail**, **Step 3: implement**:
-```go
-package moves
-
-const ProtocolVersion = 1
-const FloorKey = "control/moves-protocol.json"
-const ConsoleKey = "control/moves-console.json"
-
-type Floor struct {
- Min int `json:"min"`
- MinRelease string `json:"min_release"`
-}
-
-type ConsoleRecord struct {
- V int `json:"v"`
- Protocol int `json:"protocol"`
- SecretsKeyFP string `json:"secrets_key_fp"`
- At time.Time `json:"at"`
-}
-
-func ReadFloor(ctx context.Context, s release.Store) (*Floor, error) {
- b, _, err := s.Get(ctx, FloorKey)
- if errors.Is(err, os.ErrNotExist) {
- return nil, nil
- }
- if err != nil {
- return nil, err
- }
- var f Floor
- if err := json.Unmarshal(b, &f); err != nil {
- return nil, fmt.Errorf("moves: %s: %w", FloorKey, err)
- }
- return &f, nil
-}
-
-func ReleaseAtOrAbove(version, minRelease string) (bool, error) {
- if !semver.IsValid(version) || !semver.IsValid(minRelease) {
- return false, fmt.Errorf("moves: not a release version: %q / %q", version, minRelease)
- }
- return semver.Compare(version, minRelease) >= 0, nil
-}
-```
-
-(`golang.org/x/mod/semver`; confirm `x/mod` is in go.mod, else use `release`'s own comparison.) Edges file: add the field and `MovesProtocol: moves.ProtocolVersion` in the writer at `edgesfile.go:722-736`; `internal/console/instancesplacement.go`'s edges struct gains the same field. Console startup, **only when `s.cfg.MovesEnabled`** (D13: off publishes nothing; `TestConsoleRecordNeedsTheSwitch`), where the edges-dependent config is logged (`server.go`): `moves.PutReplace(ctx, s.cfg.Releases, moves.ConsoleKey, json(ConsoleRecord{V:1, Protocol: moves.ProtocolVersion, SecretsKeyFP: fp, At: now}))` with `fp, _ := appconfig.PublicFingerprint(s.cfg.SecretsPub)` (empty when unset), best-effort, logged on failure; a second console start replaces the record (a create-only `Put` would fail every restart after the first). `systemapply.go`: a method `(a *systemApplier) floorAllows(ctx, version string) error` reading `moves.ReadFloor` from `a.store` (the store `openSystemStore` returned, stored on the applier; add the field if absent) and returning `fmt.Errorf("moves protocol floor: %s is below %s", version, f.MinRelease)` when below; called at the top of `install` for `req.Version`, in `rollback` and `restoreFiles` for `a.prevBuild(ctx)`; a refusal in `restoreFiles` leaves the journal and returns the error (the box stays on the failed build, paged by the existing result path), per §11.
+`internal/moves`: `ConsoleKey`, `ConsoleRecord` and the three file-name constants only (`floor.go` is Task 1's). Edges file: add the three fields in the writer at `edgesfile.go:722-736` (`MovesProtocol: moves.ProtocolVersion`; `MovesFloorMinRelease` read from `<DataDir>/.moves/floor.min-release` and `PrevRelease` from `<optDir>/carlos.prev.version`, or `"none"` when `.prev` is absent, both read at each write); `internal/console/instancesplacement.go`'s `edgesFilePlacementView` gains the same fields. Units: the two `ExecStart` lines above. Console startup, **only when `s.cfg.MovesEnabled`** (D13: off publishes nothing; `TestConsoleRecordNeedsTheSwitch`), where the edges-dependent config is logged (`server.go`): `moves.PutReplace(ctx, s.cfg.Releases, moves.ConsoleKey, json(ConsoleRecord{V:1, Protocol: moves.ProtocolVersion, SecretsKeyFP: fp, At: now}))` with `fp, _ := appconfig.PublicFingerprint(s.cfg.SecretsPub)` (empty when unset), best-effort, logged on failure; a second console start replaces the record (a create-only `Put` would fail every restart after the first). `systemapply.go`: `(a *systemApplier) armFloor(ctx) error` (first in `install`: `moves.ReadFloor` from `a.store`, the store `openSystemStore` returned, stored on the applier (add the field if absent); when present, both local files written durably; a store error with a local file present keeps the local file; absent everywhere → no floor) and `(a *systemApplier) floorAllows(version string) error` reading **only** `<DataDir>/.moves/floor.json` and returning `fmt.Errorf("moves protocol floor: %s is below %s", version, f.MinRelease)` when below (a `.prev` without a sidecar is below any armed floor); called in `install` for `req.Version` after `armFloor`, and in `rollback` and `restoreFiles` for the version `carlos.prev.version` records; a refusal in `restoreFiles` leaves the journal and returns the error (the box stays on the failed build, paged by the existing result path), per §11. `decidePrev` writes the sidecar durably with the version it already obtains from `.prev version`, before the journal. `rollback` refuses while `reg.HoldRows()` (Task 12) is non-empty.
- [ ] **Step 4: Run**: `nice -n 10 go test ./internal/moves ./internal/host ./internal/console -count=1 -p 1` → PASS.
- [ ] **Step 5: Commit**
```bash
-git add internal/moves/floor.go internal/moves/floor_test.go internal/host/edgesfile.go internal/host/systemapply.go internal/host/systemapply_test.go internal/console
-git commit -m "moves: protocol floor; edges file publishes moves_protocol; system apply refuses below the floor (§11)
+git add internal/moves internal/host internal/console
+git commit -m "moves: the protocol floor armed on disk and enforced at executable selection; edges file publishes moves_protocol, the armed floor and prev_release (§11)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
```
@@ -3536,12 +3537,12 @@ Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
- Modify: `internal/host/movespass.go` (`moveCfg`, `armMovesPass`, `runMoveRound`'s `MovePreparing`/`MoveReady` arm, `MoveAborting` arm's prefix), `internal/host/movespass_test.go` (`fakeMoveMirror` gains `CopyVersioned`/`PutVersioned`; `moveHarness` gains a `lease.VersionedStore` fake and a staging-lease fake), `internal/host/agent.go` (`setMovesReadyProbe` in the `RunWithActivatorReady` callback), `internal/edge/edge.go` (`ServeUncached` and the `uncachedProbe` context marker beside `doorCallerFrom`), `internal/edge/edge_test.go` (`TestServeUncachedBypassesTheCacheAndWakes`: the cache primed with a 200 for the path and a fake activator whose wake fails: `ServeHTTP` answers the cached 200, `ServeUncached` answers the wake failure and stores nothing)
**Interfaces:**
-- `moveCfg` gains: `Box string` (the box label `metricsBoxLabel` gives), `DeployBucket, DeployBucketRegion string` (from `CARLOS_DEPLOYMENT_BUCKET` and `CARLOS_AWS_REGION`/the IMDS region the edges loop resolves; empty in dir-store deployments, where the lease falls back to a `lease.Acquire` on the own bucket under the same prefix), `Versioned func(ctx, bucket, region string) (lease.VersionedStore, error)`, `StagingLease func(ctx, targetRegion, host string) (moves.SafeUntilGuard, func(context.Context) error, error)`, `SecretsKey string` (`CARLOS_SECRETS_KEY`), `SecretsPub *ecdh.PublicKey`, `LaunchEnv func(host string) (map[string]string, error)`, `Timers func(host string) ([]registry.Timer, error)`, `FilesDir func(host string) (dir, envPath string)` (flat: `<DataDir>/<host>/`, `<DataDir>/<host>.env`), `ReadyProbe func(ctx, host string) moves.Check` (S1 through `(*edge.Edge).ServeUncached`, Task 11's definition; set by `setMovesReadyProbe(e *edge.Edge)` from `RunWithActivatorReady`'s callback, `agent.go:576`; "edge not ready" before that), `StrictNext func(host string) moves.Check` (S2: `ExecBackend.NextSpawnStrict`, Task 7b).
+- `moveCfg` gains: `Box string` (the box label `metricsBoxLabel` gives), `DeployBucket, DeployBucketRegion string` (from `CARLOS_DEPLOYMENT_BUCKET` and `CARLOS_AWS_REGION`/the IMDS region the edges loop resolves; **empty means Phase 1 rounds refuse** (round-5 finding 11: a 15-minute `lease.Acquire` can never satisfy the signing guard, and a per-participant bucket is not one shared coordination location): the round writes S1 "this deployment has no deployment bucket for the staging lease" and does nothing else; the harness supplies a staging-lease fake, so tests never need the bucket), `Versioned func(ctx, bucket, region string) (lease.VersionedStore, error)`, `StagingLease func(ctx, targetRegion, host string) (moves.SafeUntilGuard, func(context.Context) error, error)`, `SecretsKey string` (`CARLOS_SECRETS_KEY`), `SecretsPub *ecdh.PublicKey`, `LaunchEnv func(host string) (map[string]string, error)`, `Timers func(host string) ([]registry.Timer, error)`, `FilesDir func(host string) (dir, envPath string)` (flat: `<DataDir>/<host>/`, `<DataDir>/<host>.env`), `ReadyProbe func(ctx, host string) moves.Check` (S1 through `(*edge.Edge).ServeUncached`, Task 11's definition; set by `setMovesReadyProbe(e *edge.Edge)` from `RunWithActivatorReady`'s callback, `agent.go:576`; "edge not ready" before that), `StrictNext func(host string) moves.Check` (S2: `ExecBackend.NextSpawnStrict`, Task 7b).
- The switch (Task 0, D13): `moveCfg.Enabled` decides which worker runs. **Off:** today's `runMoveRound` (renamed `legacyMoveRound`, unchanged, with its tests unchanged) handles every record exactly as main does; an id'd record (none exists while the switch was never on) is skipped with one log line a minute. **On:** `phase1MoveRound` below handles id'd records; a legacy record is refused with one log line a minute (M-C: MV-N7's sentence in the log, not to a user).
-- The interim abort (until Plan 5 Task 1 replaces it): for `aborting`, the source deletes its own `move-snapshots/<host>/<id>/` and receipts, then `hostclaim.Update(ReleaseMove(id, cfg.Box))` (Task 3), then `MoveGuardedAbortComplete(g, now)`; the staging lane in the target bucket is left for Plan 5's target-owned park (the source deletes nothing there: M17 allows it, INV-8 does not want it).
+- The interim abort (until Plan 5 Task 1 replaces it): for `aborting`, the source deletes its own `move-snapshots/<host>/<id>/` and receipts, then `hostclaim.Update(ReleaseMove(id, cfg.Box))` (Task 3), then `MoveGuardedAbortComplete(g, now)`; **the crash boundary between those two writes** (round-5 finding 10): a round that finds the record `aborting` and the claim already `serving` completes the record only when `hostclaim.RequireReleasedMove(id)` holds (the serving op's journal names this move as the one it released, Task 3); a serving claim without that evidence was released by someone else (the console pass, Plan 5) and the round leaves the record to it with one log line; `TestInterimAbortReleasesTheClaim` adds the crash case (stop the round after the claim release; run it again; the record completes; and a serving claim with no evidence does not complete it); the staging lane in the target bucket is left for Plan 5's target-owned park (the source deletes nothing there: M17 allows it, INV-8 does not want it).
- **Every transition is guarded by op identity** (Task 2's `MoveGuard{ID: rec.Move.ID, Incarnation: rec.Move.Incarnation}`, captured when the round read the record): `MoveGuardedPromoteReady`, `MoveGuardedCut`, `MoveGuardedAbortComplete`; a replacement move between the read and the CAS is `ErrMoveConflict` and the round ends (`TestRoundNeverAdvancesAReplacementMove`).
- **The source copy is bound to the claim's location and generation** (round-3 finding 5), never to `HibernatePrefix(cfg.OwnPrefix, host)`: `moves.ReplicaSource(h hostclaim.Holder) (prefix string, keep func(rel string) bool)`: `base := strings.TrimSuffix(h.Location, "/") + "/"` first (`Claim.Validate` accepts only a location WITHOUT a trailing slash, `claim.go:245`, and `genselect.requestLocation` adds the separator the same way, `select.go:160-166`; a naive join would yield `instances/h/<inc>g-cur/` and an empty listing, round-4 finding 4); for generation `""` or `hostclaim.RootGeneration`, `prefix = base` and `keep` admits only `<level 0000-0009>/<min>-<max>.ltx` directly under it (not `g-*/`, not a 64-hex child incarnation, not `generation.json`, `lock.json` or `closed/`); for a generation `g`, `prefix = base + "g-" + g + "/"` and `keep` admits its level directories. `lease.MirrorPrefix` gains a variant `lease.MirrorPrefixKeep(ctx, m, srcBucket, srcPrefix, dstBucket, dstPrefix, rc, keep func(rel string) bool)` (`MirrorPrefix` is `MirrorPrefixKeep` with `keep == nil`), so the lane holds the generation's LTX files flat under `replica/<level>/…` and nothing else. `TestReplicaSourceSelectsTheClaimsGeneration`: the holder comes from a claim written through `hostclaim.Update` (so its location is the validated `instances/<host>/<inc>` form, no trailing slash) and the asserted prefix is `instances/<host>/<inc>/g-cur/`; a location with two incarnations, a `g-old/` generation and the live `g-cur/`: only `g-cur/`'s level files are copied; a legacy root location (`instances/<host>`) with `g-x/` beside its level directories copies the level directories only.
-- The round, per in-flight record this box owns, in order (§7.3, K5, K12), **after** taking the staging lease (`lease.AcquireTTL(..., moves.StagingLeaseTTL)`) and **then** re-reading the claim under it (§7.3: every round takes the lease first, then revalidates the claim's op id and phase): refuse a legacy record (log once a minute, `return nil`); refuse a nested row (`rt.DBPath != provision.DefaultDBPath(dataDir, host)`: "nested instances cannot move yet"); refuse a source with `<DataDir>/<host>-sidecar.env` present or any refused registry column set on its row (`Caps`, `Unit`, `Backing`, `Platform`, `Tunnel`, `FleetBox`, `Pool`, `Canary`, `Addr`, `Addrs`, `Health`, `Replica`, `Replicas`; §6.2, §7.1), writing the reason into the source report's `S1` so the operator sees it in `carlos move status`; refuse likewise (S1, M-C17's sentence) a source whose store binding exists: `objstore.GetIn(ctx, s, acct, app, instanceStoreEnv(rec))` non-nil, the door's check (Task 4) repeated every round before the first staging write, a read error ending the round; `hostclaim.RequireMoveOp(id, KindMoving, "")` on the claim read under the lease and holder incarnation = `rec.Move.Incarnation`; if the target region or the target box (`Op.Intent["target"]`) is not in `Touched`, `hostclaim.Update(... Touch(id, []string{to}, []string{targetBox}))` (K12 and T9 both hold from the first staging write; the target's `checking` re-adds its box idempotently); `move.json`: `stagingMirror.PutIfAbsentVersioned(move.json)` through the guarded staging mirror (Task 6; **every** write into the target bucket, `move.json`, `replica/`, `files/`, `timers/` and Task 11's `probe/` measurement object, goes through the one guarded path: `guardedMirror` implements `lease.MirrorStore`, `VersionedCopier` and `VersionedPutter` over `*lease.StagingMirror` by calling `Guarded.Before` and passing its `signAt` to `CopyVersionedAt`, `PutVersionedAt`, `PutIfAbsentVersionedAt` and `DeleteAt`, Task 6; nothing calls `lease.VersionedStore` against the target bucket); success records the returned version in the ledger; `lease.ErrExists` records `move_json: "present"` and the round goes on. The source cannot tell its own lost-response PUT from a foreign object (M17 forbids the read, and a ledger intent written first cannot tell them apart either: round-2 finding 7), so **the target owns that validation**: T9 reads `moves/<host>/<id>/move.json` in its own bucket and requires `move_id == rec.Move.ID`, `incarnation == holder.incarnation`, `host`, `from`, `to` all equal to the record's; a mismatch fails T9 with "the staging lane holds another move's move.json" and the move never becomes ready (the operator aborts; the target's park retires the lane); `checking` repeats the same check before any local write. The id is 128 random bits, so the case is a replayed request or a bug, never a collision in practice; `MirrorPrefixKeep(ReplicaSource(holder) → ReplicaPrefix, keep)` through the guarded staging mirror, whose caller retries a failed call only by calling again (a new check, a new signature); compute `archive.PlainDigest`; when it differs from the receipts' recorded plain, `archive.Build` + `archive.Seal` to a temp file, `PutVersioned` to `FilesPrefix+"files-<sealed>.sealed"` and `.json` (manifest), record both keys and both returned versions in the ledger's `archive_versions` and publish them as the source report's `Archive`; write the source report with `LaunchEnv` → `moves.ReportEnv`, S1 and S2; read the target report; `moves.Ready(round.DeltaBytes(), cfg.ReadyDelta, target, source, now)`; on ready from preparing, `MoveGuardedPromoteReady(g, now)` and `nudgeSerial`; in `ready`, keep mirroring. For `cut == "auto"` and `ready`, the source itself CASes `MoveGuardedCut(g, now)` (§7.4 "cut door or the source (auto-cut)"); Plan 2 picks it up.
+- The round, per in-flight record this box owns, in order (§7.3, K5, K12), **after** taking the staging lease (`lease.AcquireTTL(..., moves.StagingLeaseTTL)`) and **then** re-reading the claim under it (§7.3: every round takes the lease first, then revalidates the claim's op id and phase): refuse a legacy record (log once a minute, `return nil`); refuse a nested row (`rt.DBPath != provision.DefaultDBPath(dataDir, host)`: "nested instances cannot move yet"); refuse a source with `<DataDir>/<host>-sidecar.env` present or any refused registry column set on its row (`Caps`, `Unit`, `Backing`, `Platform`, `Tunnel`, `FleetBox`, `Pool`, `Canary`, `Addr`, `Addrs`, `Health`, `Replica`, `Replicas`; §6.2, §7.1), writing the reason into the source report's `S1` so the operator sees it in `carlos move status`; refuse likewise (S1, M-C17's sentence) a source whose store binding exists: `objstore.GetIn(ctx, s, acct, app, provision.InstanceStoreEnv(rec))` non-nil, the door's check (Task 4) repeated every round before the first staging write, a read error ending the round; `hostclaim.RequireMoveOp(id, KindMoving, "")` on the claim read under the lease and holder incarnation = `rec.Move.Incarnation`; if the target region or the target box (`Op.Intent["target"]`) is not in `Touched`, `hostclaim.Update(... Touch(id, []string{to}, []string{targetBox}))` (K12 and T9 both hold from the first staging write; the target's `checking` re-adds its box idempotently); `move.json`: `stagingMirror.PutIfAbsentVersioned(move.json)` through the guarded staging mirror (Task 6; **every** write into the target bucket, `move.json`, `replica/`, `files/`, `timers/` and Task 11's `probe/` measurement object, goes through the one guarded path: `guardedMirror` implements `lease.MirrorStore`, `VersionedCopier` and `VersionedPutter` over `*lease.StagingMirror` by calling `Guarded.Before` and passing its `signAt` to `CopyVersionedAt`, `PutVersionedAt`, `PutIfAbsentVersionedAt` and `DeleteAt`, Task 6; nothing calls `lease.VersionedStore` against the target bucket); success records the returned version in the ledger; `lease.ErrExists` records `move_json: "present"` and the round goes on. The source cannot tell its own lost-response PUT from a foreign object (M17 forbids the read, and a ledger intent written first cannot tell them apart either: round-2 finding 7), so **the target owns that validation**: T9 reads `moves/<host>/<id>/move.json` in its own bucket and requires `move_id == rec.Move.ID`, `incarnation == holder.incarnation`, `host`, `from`, `to` all equal to the record's; a mismatch fails T9 with "the staging lane holds another move's move.json" and the move never becomes ready (the operator aborts; the target's park retires the lane); `checking` repeats the same check before any local write. The id is 128 random bits, so the case is a replayed request or a bug, never a collision in practice; `MirrorPrefixKeep(ReplicaSource(holder) → ReplicaPrefix, keep)` through the guarded staging mirror, whose caller retries a failed call only by calling again (a new check, a new signature); compute `archive.PlainDigest`; when it differs from the receipts' recorded plain, `archive.Build` + `archive.Seal` to a temp file, `PutVersioned` to `FilesPrefix+"files-<sealed>.sealed"` and `.json` (manifest), record both keys and both returned versions in the ledger's `archive_versions` and publish them as the source report's `Archive`; write the source report with `LaunchEnv` → `moves.ReportEnv`, S1 and S2; read the target report; `moves.Ready(round.DeltaBytes(), cfg.ReadyDelta, target, source, now)`; on ready from preparing, `MoveGuardedPromoteReady(g, now)` and `nudgeSerial`; in `ready`, keep mirroring. For `cut == "auto"` and `ready`, the source itself CASes `MoveGuardedCut(g)` (Task 2's signature: the cut stamps nothing at `requested`) (§7.4 "cut door or the source (auto-cut)"); Plan 2 picks it up.
- Receipts: `lease.Receipts{Bucket: cfg.OwnBucket, Key: moves.ReceiptsKey(host, id)}`; a per-op `moveLedger` JSON beside it at `move-receipts/<host>/<id>-moves.json` records `move_json_version`, `archive_plain`, `archive_sealed`, `archive_versions`.
Tests: the legacy tests (`TestMovesPassPrepareAndPromote`, every `TestMovesPassCut…`, `TestMovesPassAbort…`) stay as they are and run with the switch off; the new tests run the harness with `cfg.Enabled = true` and a `MoveStart`ed record: `TestPhase1PrepareAndPromote` (keys under `moves/<host>/<id>/replica/<level>/…`, `move.json` present; the round is not promoted until a passing fresh `target.json` exists in the harness store), `TestPhase1IsInertWithTheSwitchOff` (an id'd record and the switch off: no copy, no CAS, one log line), `TestStagingMoveJSONReplayAfterLostLedger` (Review Focus 2: the fake versioned store answers `ErrExists` and refuses every `GetVersion`/`OpenVersion` of the target lane with an access-denied error; the round proceeds and records `present`; the target-side `TestTargetReportPassFlagsEachCheck` has the matching case: a `move.json` naming another move id fails T9 with the lane sentence), `TestInterimAbortReleasesTheClaim`, `TestRoundNeverAdvancesAReplacementMove`, `TestReplicaSourceSelectsTheClaimsGeneration`, `TestEveryStagingWriteIsGuarded` (a `Guarded` whose `Now` is past the bound: the round issues no request of any class, `move.json`, replica copy, archive, timers or probe object), `TestPreparingRefusesLegacyNestedAndRefusedColumns`, `TestPreparingTouchesTheTargetRegionAndBoxFirst` (the claim's `touched.regions` gains `uk` and `touched.boxes` gains `uk-box` before the fake mirror sees a copy, from a claim that never touched either), `TestStagingWriterRetriesWithANewCheck` (the fake mirror fails one copy; the lane calls `Before` again before the retry; a `Now` past the bound makes the retry `ErrLeaseUnsafe` and the round ends without a second SDK call), `TestSourceReportIsWrittenEachRound` (two rounds, the second's report replaces the first: `PutReplace`), `TestArchiveIsRebuiltOnlyWhenPlainChanges`, `TestAutoCutMarksCuttingFromReady`.
@@ -3551,7 +3552,7 @@ Tests: the legacy tests (`TestMovesPassPrepareAndPromote`, every `TestMovesPassC
- [ ] **Step 5: Commit**
```bash
-git add internal/host/movespass.go internal/host/movespass_test.go
+git add internal/host internal/edge internal/moves
git commit -m "host: preparing and ready rounds on the per-operation staging layout, under the staging lease (§7.3)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
diff --git a/docs/superpowers/plans/CODEX-PLAN1-REVIEW-R5.md b/docs/superpowers/plans/CODEX-PLAN1-REVIEW-R5.md
new file mode 100644
index 000000000..e66a572d3
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-PLAN1-REVIEW-R5.md
@@ -0,0 +1,136 @@
+| R4 finding | Status | Assessment at `80647eeb` |
+|---|---|---|
+| 1. Target-selection integration | Partly | Adapter and signatures are now specified; the HTTP fixtures cannot satisfy it, and source selection is incomplete. |
+| 2. Early `moves.Check` / strict-spawn fake | Partly | Earlier producer and mock seam added; Task 11 still redeclares `Check`. |
+| 3. Signing timestamp | Resolved | The guard’s captured timestamp reaches the signer; pause-across-expiry coverage is specified. |
+| 4. Generation-source prefix | Resolved | Separator normalization and a validated-claim fixture address it. |
+| 5. Recovery entry points | Partly | Units check a marker, but its asynchronous publication leaves an unsafe recovery window. |
+| 6. Privileged materialization / floor persistence | Partly | Floor moved to `/data`; staging relay added, but its error classification and authority checks are incomplete. |
+| 7. Default-off behavior | Partly | Ordinary S3 mirroring and console publication are corrected; system-apply dormancy remains contradictory. |
+| 8. Archive verification | Resolved | Decoded-tar hashing, sealed digest and terminal authentication are addressed. A separate error-path leak remains. |
+| 9. Invalid fixtures | Partly | Earlier bucket, PUT and claim-location defects are corrected; other fixtures still fail. |
+| 10. Flat spawn environment | Resolved | Actual spawn composition and optional purge availability are represented. |
+| 11. Source readiness probe | Resolved | Explicit cache bypass and a cached-success/failed-wake test are specified. |
+| 12. Readiness input producers | Partly | Version-aware loader and archive references added; T3 still contains conflicting loader instructions. |
+| 13. Private-storage refusal | Resolved | Checks the instance’s actual binding, fails closed and repeats the check in the worker. |
+| 14. Copy inventory | Resolved | Includes M-C1–M-C22 and explicitly imports the copy record into the implementation branch. |
+| 15. Legacy/guarded-transition instructions | Resolved | The cited replacement instructions are corrected. A new guarded-call signature error remains. |
+
+Reviewed the pinned plans, moves spec rev **7.21**, claim rev **6.30** at `73b1b9dc`, and prior reviews. **Fetching was attempted but failed:** the sandbox forbids writing `FETCH_HEAD`; the subsequent remote lookup failed DNS resolution. Source comparisons therefore use cached `origin/main` **`1c43c2ce82f621c6ac84a623db54f0ef63075603`**, whose freshness I cannot establish. No files changed, hosts contacted or tests run.
+
+**Findings**
+
+1. **P1 — The task sequence contains definite build failures.**
+ Task 3 calls undefined `testHolderKey` at [Plan 1:803](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:803); it exists neither on the inspected main nor in an earlier task. Task 1 creates `moves.Check`, then Task 11 declares it again at [Plan 1:3217](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:3217). Task 14 calls `MoveGuardedCut(g, now)` at [Plan 1:3543](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:3543), but Task 2 defines `MoveGuardedCut(g MoveGuard)`.
+
+ Define the fixture helper, retain one `Check` declaration and correct the cut call. Task 14 also needs its own or a shared `instanceStoreEnv`: Task 4’s console-local helper is not accessible from package `host`.
+
+2. **P1 — The new root staging relay lacks an operation-authority check.**
+ [Plan 1:2551](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:2551) directs root to read `{acct, app, host, version, op}` and call `MaterializeHeld`, which calls `PointLive`. It specifies no verification that the request still names the current operation, incarnation, assigned box and pinned release. The `pub` parameter has no stated verification role.
+
+ A request queued for operation A can run after A is aborted or replaced, repointing the host to A’s build. Request-controlled path components also reach main’s filesystem joins without a specified validation contract. Require validation immediately before the filesystem effect, safe request/path handling, and tests for stale-operation and traversal requests. Keep authority validation in an appropriate host-side layer or callback to avoid introducing a `release`/`hostclaim` import cycle.
+
+3. **P1 — The recovery marker still permits execution below the protocol floor.**
+ [Plan 1:3441](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:3441) has the agent publish participation and `hold-prev` asynchronously. Nothing establishes that publication before the console commits a move. A crash after that commit but before the next agent pass leaves recovery free to execute old `carlos.prev` before the agent starts.
+
+ The marker is also removed for nonparticipants even when an armed floor remains and `.prev` is below it. That does not implement §11’s activation floor. The proposed unit test starts with the marker already present and misses both cases. Enforce the floor at executable selection and test the admission-to-marker crash boundary.
+
+4. **P2 — Default-off system-apply behavior contradicts the stated dormant boundary.**
+ [Plan 1:3445](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:3445) says absence of agent-written files makes `floorAllows` dormant. But the preceding contract also reads registry holds and the remote floor; the applier hooks are unconditional at [Plan 1:3517](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:3517).
+
+ The proposed switch-off test deliberately uses a held registry row, but only checks that the agent writes no files. Applying the specified activation rule to that same fixture refuses recovery. Define and test the root activation boundary, including the separate case of a previously armed, persistent floor.
+
+5. **P2 — The staging fallback misses the production read-only-mount error.**
+ [Plan 1:2551](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:2551) recognizes only `errors.Is(err, fs.ErrPermission)`. `ProtectSystem=strict` can produce `EROFS`, which is not `fs.ErrPermission`. Main already handles this distinction in `release.stageBlocked` (`internal/release/adopt.go:250`), covering `EROFS`, `EPERM` and `EACCES`.
+
+ Reuse that classification. The proposed `chmod(0555)` fixture tests discretionary permissions, not read-only mounts; inject `EROFS` explicitly.
+
+6. **P2 — Task 4’s supplied HTTP fixtures cannot reach their expected outcomes.**
+ [Plan 1:1022](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:1022) supplies minimal edges files, while `selectMoveBoxes` now requires a complete live snapshot and verified eligible identities. Matching status snapshots, signed identity/binding fixtures and eligibility inputs are missing.
+
+ Consequently, the claimless test returns incomplete-fleet refusal before its expected claim refusal; the admitted case cannot select its target. Adding only protocol and serving-claim data does not fix this. Reuse a complete production-shaped fixture in the HTTP tests, not just in the separate selector unit test.
+
+7. **P2 — Selection permits an absent source.**
+ At [Plan 1:1319](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:1319), both regions reject more than one candidate, but only the target must produce a candidate. Zero source candidates passes; the source candidate is never bound to the claim’s executor. The source-region floor check can also pass vacuously when no source file exists.
+
+ Require the source required by §7.1 to be present and consistent with the claim. Add missing-source and different-executor tests.
+
+8. **P2 — Versioned receipts leave the multipart receipt-match branch unspecified.**
+ [Plan 1:1555](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:1555) gives concrete changes for equal source/destination ETags. Main has another early return for matching receipts when those ETags differ—the normal multipart-source case.
+
+ That branch also needs to require `DstVersionID` and populate `round.Versions`. Otherwise the specified edits can skip copying while omitting the object’s version from the round. Add a two-round multipart fixture and an old receipt without a version; the supplied single-part case cannot catch this.
+
+9. **P2 — Readiness can combine different input generations, and T3’s provenance wiring remains contradictory.**
+ `Ready` at [Plan 1:3276](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:3276) checks age and passing flags, but does not compare the target’s checked files/environment inputs with the current source report. A passing target report for archive A can remain fresh after the source publishes archive B, allowing promotion or auto-cut before B passes T5.
+
+ Separately, Task 7b requires `LoadMergedHostVersions`, while T3 at [Plan 1:3138](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:3138) still specifies `LoadMergedHost`, which cannot return both bundle ETags. Require matching report input identities, use the version-aware loader for the actual render, and test two fresh reports with different digests. T3’s specified reporting of host-overlay shadowing also needs an explicit producer and assertion.
+
+10. **P2 — Interim abort cannot recover from its claim-release crash boundary.**
+ [Plan 1:3540](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:3540) releases the moving claim before clearing the record. A crash between those writes leaves an aborting record and a serving claim. Task 3’s `ReleaseMove` requires the matching moving operation, so replay cannot complete as specified.
+
+ Provide operation-bound completion evidence or an explicitly safe idempotent completion path. `TestInterimAbortReleasesTheClaim` needs a crash/retry case between these two writes.
+
+11. **P2 — The dir-store staging-lease fallback cannot satisfy the signing guard.**
+ [Plan 1:3538](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:3538) falls back to ordinary `lease.Acquire` on the own bucket. Its 15-minute lease cannot provide the required 15-minute signature lifetime plus skew/margin, so the first guarded write is unsafe. Using each participant’s own bucket also fails to identify one shared coordination location.
+
+ Specify a shared fallback with the staging TTL, or explicitly refuse that deployment mode. Test its first guarded write.
+
+12. **P2 — Archive verification leaks its producer on early errors.**
+ [Plan 1:2309](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:2309) launches `Open` writing to an `io.Pipe`, but early manifest, tar or decoder errors never close the pipe reader. The producer can remain blocked writing the remaining archive. Repeated T5 failures accumulate blocked goroutines and retained resources.
+
+ Close/cancel the producer on every return and verify termination using an early mismatch in a multi-chunk archive.
+
+13. **P2 — Several commit commands omit files required by their own task.**
+ Examples: Task 1 omits `check.go` at [Plan 1:254](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:254); Task 4 omits its moves prerequisite and console entry-point changes; Task 6 omits its `internal/lease` implementation changes at [Plan 1:1808](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:1808); Task 13 omits units and `movesfloor` files; Task 14 stages only `movespass` files at [Plan 1:3553](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:3553).
+
+ A passing working-tree test does not make those commits buildable. Correct the file sets and validate the committed tree at each task boundary.
+
+14. **P3 — Competing instructions remain in the index and task bodies.**
+ The index still identifies older spec/claim revisions at [Index:9](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-0-index.md:9). Task 4 retains older private-policy/selection directions; Task 9’s interface description disagrees with its revised purge treatment; Task 13 prescribes `readEdgesSnapshot` at [Plan 1:3471](/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/plans/2026-10-06-moves-phase1-1-copy-and-staging.md:3471), while Task 4 now uses `liveEdges` and full completeness.
+
+ Reconcile these rather than marking the dispositions wholly complete. Also identify `release.LiveVersion` as reuse: it already exists on main at `internal/release/versions.go:290`.
+
+The task-by-task assessment below is static. “Meaningful tests” means the proposed assertions discriminate the intended behavior; it does **not** mean a red/green run was observed.
+
+| Task | Main interfaces, ordering and tests |
+|---|---|
+| 0 — Switch | New configuration is straightforward. Parser tests do not establish actual console/agent wiring. |
+| 1 — IDs/shared types | Self-contained; ID tests are meaningful. Retain the early `Check` producer and include it in the commit. |
+| 2 — Journal | Existing record/store interfaces match; legacy transitions remain. Guard-conflict tests discriminate. Task 14 must use the actual cut signature. |
+| 3 — Claim transitions | Merged guards, kinds, fields and update primitives exist. Undefined fixture helper blocks compilation; abort replay needs coverage. |
+| 4 — Door | Existing selector signatures now match, and the adapter is specified locally. HTTP fixtures and source selection remain defective. Legacy dispatch is explicitly gated. |
+| 5 — Version receipts | Interfaces are additive and ordinary S3 mirroring stays plain. Multipart receipt/version coverage is missing. |
+| 6 — Lease/signing | Existing TTL acquisition internals, `SafeUntil` and signer support the additions. Timestamp test is now discriminating; commit file set is incomplete. |
+| 7 — Archive | Crypto interfaces match. Round-trip, authentication and mode tests are useful; producer cleanup on rejection is untested. |
+| 7b — Held helpers/strict spawn | Staging, `PointLive`, `PrepareStrict` and configuration primitives exist. Mock seam is viable. Root relay, `EROFS` handling and existing `LiveVersion` reuse need correction. |
+| 8 — Timers | Registry timer shape matches; digest and registration round-trip tests are meaningful. No additional blocker found. |
+| 9 — Environment | Actual spawn helpers/signatures now align. Composition tests discriminate; stale interface prose and shadow reporting need reconciliation. |
+| 10 — Budget | Referenced model at `9c417aaa` and numerical examples align. Boundary tests are meaningful. |
+| 11 — Readiness | Duplicate `Check` blocks compilation. Freshness tests miss mismatched input generations. Host work is deferred to Task 14. |
+| 12 — Holds | Registry APIs and fixture exist. Conditional release/deletion tests are meaningful; additions are compatible with existing rows. |
+| 13 — Floor | Applier hooks exist. Tests miss admission-to-marker crash ordering and actual switch-off activation behavior. |
+| 14 — Integration | Worker and harness exist. Wrong call signature, helper placement, abort replay, fallback lease and readiness integration remain unresolved. |
+| 15 — Copy gate | Earlier `DefaultEnabled` producer, complete inventory and record import are specified. Negative fixtures meaningfully test the gate. |
+| 16 — Final gate | Cannot pass the preceding tasks as written. No gate was run in this review. |
+
+**Claim independence:** I found no direct Plan 1 dependency on unmerged `hostops`, `hostbox`, `hostwrite`, `hostexec`, `hostfence` or `hostquarantine`. The intended implementation can use merged primitives plus the additions specified here. Claimless-host refusal pending adoption is an explicit operational restriction, not a build dependency on claim Plan 5. The findings above can be repaired without claim Plans 5/6/8.
+
+**Imports and shippability:** No intended package import cycle was found. Missing/duplicate identifiers, the wrong signature and incomplete commit boundaries prevent buildability after every task. Default-off preservation is substantially improved for the console, worker, mirror and strict spawn, but remains unproven for system activation.
+
+**Spec coverage:** The remaining gaps include §11’s activation floor, §7.1’s source selection, §7.3’s checked-input consistency and shadow reporting, and operation-bound held materialization. These prevent treating the plan as a complete implementation prescription.
+
+**PLAN 1: NOT READY TO BUILD.**
+
+---
+
+**FIRST PROMPT:** Read-only review of Plan 1 and its index before implementation.
+
+**WHAT:** Checked R4 dispositions, main interfaces, claim independence, tests and spec coverage.
+
+**STATUS:** Review complete; blockers remain. Remote-main freshness unverified.
+
+**NEEDED:** Correct the findings before implementation.
+
+**RECOMMENDED:** Re-review the amended plan against successfully fetched `origin/main`.
+
+**SESSION:** Safe to close. Repository unchanged; no processes started.
\ No newline at end of file