| index 2d81397ae..d58fd4f54 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 |
| @@ -10,7 +10,7 @@ |
| |
| **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 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`. |
| +**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). **Build target: `origin/main` at d649d8d4 or later** (claim Plan 6, the box wiring for C1 to C4, merged 2026-10-06; inert while `CARLOS_HOST_CLAIM` is unset). Every API this plan names was checked against `origin/main` at 1c43c2ce and the line numbers re-checked at d649d8d4: `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:256`), the existing `LiveVersion` (`versions.go:290`), `decidePrev`'s `.prev version` call, `MirrorPrefix`'s two receipt branches and `GetRecord`'s `*Record`. |
| |
| ## Global Constraints |
| |
| @@ -141,7 +141,7 @@ 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`, `internal/moves/floor.go` |
| +- Create: `internal/moves/ids.go`, `internal/moves/check.go`, `internal/moves/floor.go` (the package `internal/moves` already exists on `main` at d649d8d4 with `install_contract.go`: `InstallManifest` and `ErrAttemptOver`, claim Plan 6's shared contracts; these files join it) |
| - Test: `internal/moves/ids_test.go`, `internal/moves/check_test.go`, `internal/moves/floor_test.go` |
| |
| **Interfaces:** |
| @@ -268,7 +268,7 @@ Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>" |
| |
| **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`; `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. |
| +- **Shared contract type, imported and not defined:** `provision.MoveInputs` is **on `main` at d649d8d4** (`internal/provision/moveinputs_contract.go`, claim Plan 6: `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"\``, the shape this plan was written to). **This task does not define it**: the journal below uses the existing type (`*MoveInputs` in `MoveState.Inputs` and `MoveRollback.RecoveryInputs`), and a second declaration would fail the build. |
| - 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). |
| |
| - [ ] **Step 1: Write the failing test** |
| @@ -472,14 +472,9 @@ type MoveState struct { |
| Reason string `json:"reason,omitempty"` |
| } |
| |
| -// MoveInputs pins what the target was checked against (§7.3 T3). |
| -type MoveInputs struct { |
| - 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"` |
| -} |
| +// MoveInputs (what the target was checked against, §7.3 T3) is NOT |
| +// defined here: it is main's internal/provision/moveinputs_contract.go |
| +// (claim Plan 6, d649d8d4), the shared contract this journal references. |
| |
| // MoveSource is what the source recorded at `stopped` (§7.4): the |
| // baselines every recovery compares against. |
| @@ -771,7 +766,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, 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. |
| +- 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:90`; 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** |
| |
| @@ -1602,7 +1597,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). 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: |
| +`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:190-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 |
| @@ -2608,8 +2603,8 @@ Moved here from Plan 2 (its Tasks 4 and 7) because T3, T7 and S2 need the real t |
| **Interfaces:** |
| - `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` (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. |
| +- `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:1372`) 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:256`: `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:3606`) calls it after its release adoption, with `heldStageAuthority`. There is no `pub` parameter: `ensureStaged` (`adopt.go:1372`) 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-371`, `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 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). |
| |
| @@ -3195,7 +3190,7 @@ Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>" |
| |
| **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; 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`. |
| +- 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:1330`, 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:613`, 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`. |
| |
| 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): |
| |
| @@ -3504,7 +3499,7 @@ Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>" |
| **Interfaces:** |
| - 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 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:506-522`, 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 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. |
| |
| @@ -3537,7 +3532,7 @@ 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 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). |
| +- `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:613`; "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 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`). |