carlos / platform Public

plan: claim Plan 10 rev 4 (moves cleanup authority: park registration and engine accessor, move-recovery grant via Recovery.Moves, recovery-path B8 settlement, sweep precondition; applied onto main)

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 9ed74832e00f0b6290a22d4abd1a37b8a819d6ad parent 0df82f5
1 file changed, +2580 −187
  • docs/superpowers/plans/2026-10-09-host-lifecycle-claim-10-moves-recovery-grants.md +2580 −187

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-09-host-lifecycle-claim-10-moves-recovery-grants.md b/docs/superpowers/plans/2026-10-09-host-lifecycle-claim-10-moves-recovery-grants.md
index efcd22cea..34c92cc11 100644
--- a/docs/superpowers/plans/2026-10-09-host-lifecycle-claim-10-moves-recovery-grants.md
+++ b/docs/superpowers/plans/2026-10-09-host-lifecycle-claim-10-moves-recovery-grants.md
@@ -1,11 +1,13 @@
# Host lifecycle claim, Plan 10: moves recovery and console grants
-Implementation plan, 2026-10-09, rev 3. This plan extends the claim's recovery-probe contract, gives move loss declarations a registered operation owner, and admits console move writes through typed authority. Scope: J2, J4 and J17, as ruled with Paul on 2026-10-08. It authorizes no production change.
+Implementation plan, 2026-10-09, rev 4. This plan extends the claim's recovery-probe contract, gives move loss declarations a registered operation owner, and admits console move writes through typed authority. Scope: J2, J4 and J17, plus the moves Plan 5 cleanup-authority addendum (K1, K2, K3, K10, K12 and K13). It authorizes no production change.
**Rev 2:** answers review round 1's three P2 findings and the moves Plan 4 addendum. Console grants retain the gate-read move ID, incarnation and `record_seq`; lost-target release retains inventory-bound local retirement; D5 discharges only separately attested local chores, including after commit. Task A records clean-return inputs at `probing` and connects the recovery hook to Group D through the activator. The tests below reproduce the rev 1 failures before applying these changes.
**Rev 3:** answers both review round 2 findings. Task A supervises held recovery probes before the sweep skips fenced hosts, retires failed probes on sweep or join, and releases only the adapter-bound operation fence when the exact admitted session opens. Task D accepts the paired legacy cut identity `{session: 0, generation: ""}` while refusing missing journal evidence and mismatched pairs. Failing-first tests cover the real sweep with its cut fence present and complete loss declarations before and after the target's first admission. The J4 signature stays unchanged.
+**Rev 4:** applies directly to `origin/main` at `abcc732d` or later, which includes claim Plan 9 and moves Plan 4. The replay exports one base and needs no alias normalization. Task A retains restore/install authority at `configured`, and its probes start at `probing`. The new moves cleanup authority group supplies independent park registration and the verified engine accessor (K1), listing-time sweep evidence with a retirement precondition (K10), and a typed `Recovery.Moves` grant for record close, deadline rollback, claim halves and verified relocating conversion (K2/K12/K13). It also settles B8 uncertain starts without the original handle through the effect runner (K3). The J4 `OperationOwner` signature remains byte-for-byte unchanged.
+
## J4 owner interface
**The J4 `OperationOwner` signature is unchanged from rev 1.** Moves Plan 4 callers may keep building against it. Local-only declarations use the same inspection, signed consequences and owner hooks; no extra caller-supplied authority or interface method is introduced.
@@ -28,25 +30,25 @@ The service calls `InspectLoss` on a detached claim and the authoritative record
The write order is signed acceptance, global roster quarantine, prepared loss record, `PrepareLoss`, claim declaration, committed loss record, `FinishLoss`. Moves records its own target-loss decision in `PrepareLoss` and releases only the declared authority in `FinishLoss`. Both hooks are idempotent. A retained claim loss reference skips preparation on replay. Completion runs again even after the loss record is committed, so a lost answer cannot strand the move. The service dispatches retries by the accepted loss's `OpKind`, not the current claim kind. Callback arguments and returned consequences are copied; callbacks cannot mutate the service's evidence through shared maps, slices or pointers.
-## Pinned base and scope
+## Base and scope
-The claim base is `feat/hostclaim-activation` at `e3399ad07caf1b133dc71f8a2d0380e7c386f89e`, including the required Plan 9 review fixes at `3ca545e3`. The moves integration input is `moves-phase1-4` at `c74408612d20da9082572cd64ad47cd2fe8d74bf`, including `ea1649ca` and `MoveRecordRevertInputs`. Their common base is `4f26f15cb41ecfdd9214b49393c228399f34490b`.
+Build on `origin/main` at `abcc732dbe9d21dd2fe8e7edcd3d28ab0ebac020` or later. This merged base already carries claim Plan 9, its review fixes and moves Plan 4. Do not export or combine the former feature-branch tips. `internal/hostclaim/sessionstep.go` comes from Plan 9; leave its `PhaseLandingCommitted = PhaseCommitted` definition intact. Moves adds no competing alias.
-At authoring, the remote moves branch still pointed to `2dbec05a2812391d3089c6716e1edeb13a1a1130`. That remote tip does not contain the brief's recovery addendum. The local moves commit above is therefore an explicit integration dependency, not an assertion that it has landed. The two pinned exports combine without textual conflicts. They have one semantic compile conflict: Plan 9's `phaseLandingCommitted` became moves' exported `PhaseCommitted`. The bootstrap below applies that exact alias repair before Task A. Carry the same one-line repair into the later implementation branch as part of combining these bases.
+The patches below are rebased onto that main commit with surrounding context. On a later main, stop on a patch conflict and review the changed code before updating a hunk. The moves policy rows grant restore and install at `configured`, before the CAS to `restored`; Task A's verification includes those merged policy tests.
-Read `docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md` sections 6.4, 7.2 and 7.3, and the moves input's `docs/superpowers/specs/2026-10-02-move-any-app-design.md` sections 7.7, 7.8, 7.10 and 7.14. Plan 9's Group D, B3/B4 authority boundaries and B8 `ResolveMoveStart` are dependencies. Do not reconstruct the pre-Plan-9 APIs or weaken those boundaries.
+Read `docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md` sections 6.4, 7.2 and 7.3, and the moves design `docs/superpowers/specs/2026-10-02-move-any-app-design.md` sections 7.7, 7.8, 7.10 and 7.14. Plan 9's Group D, B3/B4 authority boundaries and B8 `ResolveMoveStart` are dependencies. Do not reconstruct the pre-Plan-9 APIs or weaken those boundaries.
-This authoring task changes only this plan. Its Go changes are executable implementation instructions, verified in an external overlay. No index, branch, ref, worktree registration, service, floor, flag or production resource is changed. Later implementation goes through a reviewed amadan branch and the repository's full gate.
+This authoring task changes only this plan. Its Go changes are executable implementation instructions, verified in an external source export. No index, branch, ref, worktree registration, service, floor, flag or production resource is changed. Later implementation goes through a reviewed amadan branch and the repository's full gate.
-Flag-off compatibility means byte-identical existing responses and storage on the legacy paths of the pinned inputs. New console behavior is reached only with both `HostClaim` and `MovesEnabled` and the typed authority connected. The HC5-N1 literal stays for every unavailable-authority case. Do not replace a legacy error sentence merely to remove punctuation. New copy contains no em dashes and is inventoried below for Paul's review before shipment.
+Flag-off compatibility means byte-identical existing responses and storage on the legacy paths of the merged main base. New console behavior is reached only with both `HostClaim` and `MovesEnabled` and the typed authority connected. The HC5-N1 literal stays for every unavailable-authority case. Do not replace a legacy error sentence merely to remove punctuation. New copy contains no em dashes and is inventoried below for Paul's review before shipment.
## Replay without git writes
-Save the bootstrap and extractor below outside the repository. They export tracked objects with `git archive`, combine them with `git merge-file -p`, and apply this plan's exact patches. They never invoke checkout, reset, merge, worktree, add, commit, fetch or push. A failed merge or patch stops the replay.
+Save the exporter and extractor below outside the repository. Export `origin/main` once with `git archive`, then apply this plan directly to that tree. These commands read git objects only; they do not change the index, refs or worktree registration. There is no feature-branch overlay or bootstrap source repair.
-Use one scratch directory under `/home/paulca/.cache/tmp/claude-1001/`. Socket fixtures need the separately created short `TMPDIR`. Keep tests serial under `nice`; all authors use the same `flock` within this replay. Leave the default Go cache alone when writable. Only the permitted read-only-cache fallback is the shared `/home/paulca/.cache/tmp/claude-1001/gocache`, which cleanup must never remove.
+Use one scratch directory under `/home/paulca/.cache/tmp/claude-1001/`. Socket fixtures need the separately created short `TMPDIR`. Keep tests serial under `nice` and the shared replay `flock`. Use the default Go cache when writable; if it is read-only, use `/home/paulca/.cache/tmp/claude-1001/gocache`. Never remove that shared cache.
-Save as `$CLAIM10_SCRATCH/bootstrap.py`:
+Save as `$CLAIM10_SCRATCH/export.py`:
```python
import io
@@ -56,46 +58,15 @@ import sys
import tarfile
repo, scratch = map(pathlib.Path, sys.argv[1:])
-claim = "e3399ad07caf1b133dc71f8a2d0380e7c386f89e"
-moves = "c74408612d20da9082572cd64ad47cd2fe8d74bf"
-common = "4f26f15cb41ecfdd9214b49393c228399f34490b"
-
-def git(*args):
- return subprocess.check_output(["git", "-C", str(repo), *args])
-
-if git("merge-base", claim, moves).decode().strip() != common:
- raise SystemExit("base changed")
+base = subprocess.check_output(["git", "-C", str(repo), "rev-parse", "origin/main"]).decode().strip()
subprocess.run(["git", "-C", str(repo), "merge-base", "--is-ancestor",
- "ea1649ca3241ea33ddbfc233bfda20c4faccb8b9", moves], check=True)
-for name, ref in (("overlay", claim), ("moves", moves), ("common", common)):
- target = scratch / name
- target.mkdir()
- with tarfile.open(fileobj=io.BytesIO(git("archive", ref))) as archive:
- archive.extractall(target, filter="data")
-for name in git("diff", "--name-only", common, moves).decode().splitlines():
- path, old, new = (scratch / root / name for root in ("overlay", "common", "moves"))
- if not new.exists():
- if path.exists() and old.exists() and path.read_bytes() == old.read_bytes():
- path.unlink()
- else:
- raise SystemExit("delete conflict: " + name)
- elif not path.exists() or (old.exists() and path.read_bytes() == old.read_bytes()):
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_bytes(new.read_bytes())
- elif path.read_bytes() != new.read_bytes():
- if not old.exists():
- raise SystemExit("add conflict: " + name)
- merged = subprocess.run(["git", "merge-file", "-p", str(path), str(old), str(new)],
- capture_output=True, check=True)
- path.write_bytes(merged.stdout)
-# The moves input renamed this constant. Both names denote "committed".
-# Normalize the integration baseline before testing Plan 10 behavior.
-alias = scratch / "overlay/internal/hostclaim/sessionstep.go"
-source = alias.read_text()
-needle = "PhaseLandingCommitted = phaseLandingCommitted"
-if source.count(needle) != 1:
- raise SystemExit("landing alias baseline changed")
-alias.write_text(source.replace(needle, "PhaseLandingCommitted = PhaseCommitted"))
+ "abcc732dbe9d21dd2fe8e7edcd3d28ab0ebac020", base], check=True)
+(scratch / "base.txt").write_text(base + "\n")
+target = scratch / "source"
+target.mkdir()
+raw = subprocess.check_output(["git", "-C", str(repo), "archive", base])
+with tarfile.open(fileobj=io.BytesIO(raw)) as archive:
+ archive.extractall(target, filter="data")
```
Save as `$CLAIM10_SCRATCH/apply.py`:
@@ -148,22 +119,21 @@ export TMPDIR="$CLAIM10_SOCKETS"
export GOFLAGS=-buildvcs=false
# Use this fallback only when the default cache is read-only:
export GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache
-# Save bootstrap.py, apply.py and plan.md as described above, then:
-python3 "$CLAIM10_SCRATCH/bootstrap.py" "$CLAIM10_REPO" "$CLAIM10_SCRATCH"
-cd "$CLAIM10_SCRATCH/overlay"
+# Save export.py, apply.py and plan.md as described above, then:
+python3 "$CLAIM10_SCRATCH/export.py" "$CLAIM10_REPO" "$CLAIM10_SCRATCH"
+cd "$CLAIM10_SCRATCH/source"
```
-Apply A, B, C, D and E in order. In each task, apply `tests`, run its command and
+Apply A, B, C, D, E, F and G in order. In each task, apply `tests`, run its command and
record the expected failure, then apply `implementation` and repeat the same
command to obtain green, then build every package before the next task. Do not accept unrelated failures as the expected red.
-The bootstrap separately repairs the combined-base alias, so the task tests
-reach their behavioral assertions.
+The exporter makes no source edits. Each implementation task applies directly to the exported main plus the preceding tasks.
### Task A: keep the recovery probe's admitted writer through opening
Invariant: a recovery starts at `probing`, and one admitted writer may advance through `probing -> probed -> opened` only when its operation, executor, incarnation, session, stamp and generation remain unchanged. A phase CAS skips no step. Read observation and teardown may catch up along the row, using Plan 9's separate `SessionObserves` and `SessionReaches` contracts. They do not grant a skipped mutation.
-Apply the tests first. The bootstrap has already repaired the combined export's landing constant alias. Without the new rows, recovery continuation fails. Without the assembly changes, recovery admission returns the existing dependency refusal before leases, stop or reservation. After implementation, the tests exercise the actual `Prepare` path and its bound journal and readiness callbacks, not just the table helper.
+Apply the tests first. Plan 9's landing constant is already correct on main. Without the new rows, recovery continuation fails. Without the assembly changes, recovery admission returns the existing dependency refusal before leases, stop or reservation. After implementation, the tests exercise the actual `Prepare` path and its bound journal and readiness callbacks, not just the table helper.
Reuse `OperationStart`, `OperationAttempts`, `ClaimProbeReadiness` and `ClaimOwnerDispatch`. No interface reconstruction is needed: dispatch already routes both recovery kinds to the move owners. Every operation-attempt binding and admitted role path must use the expanded kind predicate. A recovery probe uses the journalled role's cgroup for readiness and the bound readiness callback for the settle check. A serving wake and the committed landing wake keep their existing readiness behavior. Missing owners continue to refuse.
@@ -175,16 +145,16 @@ Edge reloads preserve held operation-owned probes. The actual sweep supervises t
The adapter supplies `StartPolicy.RecoveryFence` from `moveFenceGeneration(rec.Move)`; an unrelated installed fence refuses admission. The entry retains the fence observed at start. After the off-lock claim read, it rechecks health and fence identity under `a.mu`. Only the exact admitted session at `opened` (or retained through release to `serving`) ends the hold and removes the owning generation under that same lock. A replacement fence is preserved even if it arrives during the claim read. If the moves worker already lifted its own fence, keep a healthy exact session held until the claim confirms opening; a stale `probing` answer cannot retire an already recovered process or enable routing. Removing a fence never substitutes for an opened claim. No fence is required after an agent restart, and an absent fence grants no authority to lift a later one.
-The restore/install, park leases, recovery admission evidence and source/target cleanup remain the moves owner's work. This task adds no generic recovery takeover or artifact-retirement authority.
+The merged `movesPhaseRights` and `restoringRights` rows permit `restore` and `install` at `configured`; `restored` grants neither. Task A leaves those rows intact. Recovery admission begins only at `probing`; its continuation table does not grant another install. Run `TestMovesClaimPolicyGrantsByPhase`, `TestMovesClaimPolicyRestoringRows` and `TestMovesClaimPolicyRestoringThroughPolicyCheck` alongside the probe tests. Park providers and recovery evidence remain moves-owned; Task F supplies their registration and access contract.
-A clean return records inputs **at `Rollback.SourcePhase == probing`, before rendering or materializing anything**. The adapter calls `recoveryProbeRequest.Record`, which uses `provision.MoveRecordRecoveryInputs` under the move and rollback IDs. That transition is already on the pinned moves base and accepts `probing` for both clean and dirty returns. Its `Release` must be nonempty. A failed record write stops the attempt before render. The clean return's path has no `configured` phase and needs none. Each restart records the newly selected inputs before its own render. A revert analogously uses the already-built `MoveRecordRevertInputs`. None of these values is compared to the original `Move.Inputs` pin.
+A clean return records inputs **at `Rollback.SourcePhase == probing`, before rendering or materializing anything**. The adapter calls `recoveryProbeRequest.Record`, which uses `provision.MoveRecordRecoveryInputs` under the move and rollback IDs. That transition is already on the merged main base and accepts `probing` for both clean and dirty returns. Its `Release` must be nonempty. A failed record write stops the attempt before render. The clean return's path has no `configured` phase and needs none. Each restart records the newly selected inputs before its own render. A revert analogously uses the already-built `MoveRecordRevertInputs`. None of these values is compared to the original `Move.Inputs` pin.
```bash
python3 "$CLAIM10_SCRATCH/apply.py" "$CLAIM10_SCRATCH/plan.md" A tests
-flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go test -p 1 -timeout 10m -count=1 ./internal/hostclaim ./internal/host ./internal/activator -run 'TestRecoveryProbe|TestRecoveryHold|TestOrdinaryClaimWake|TestPlan10J2|TestPlan9D[123]|TestPlan9Fix1Session|TestPlan9Fix2Observe'
+flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go test -p 1 -timeout 10m -count=1 ./internal/hostclaim ./internal/host ./internal/activator -run 'TestRecoveryProbe|TestRecoveryHold|TestOrdinaryClaimWake|TestPlan10J2|TestPlan9D[123]|TestPlan9Fix1Session|TestPlan9Fix2Observe|TestMovesClaimPolicy'
# After recording the expected red:
python3 "$CLAIM10_SCRATCH/apply.py" "$CLAIM10_SCRATCH/plan.md" A implementation
-flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go test -p 1 -timeout 10m -count=1 ./internal/hostclaim ./internal/host ./internal/activator -run 'TestRecoveryProbe|TestRecoveryHold|TestOrdinaryClaimWake|TestPlan10J2|TestPlan9D[123]|TestPlan9Fix1Session|TestPlan9Fix2Observe'
+flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go test -p 1 -timeout 10m -count=1 ./internal/hostclaim ./internal/host ./internal/activator -run 'TestRecoveryProbe|TestRecoveryHold|TestOrdinaryClaimWake|TestPlan10J2|TestPlan9D[123]|TestPlan9Fix1Session|TestPlan9Fix2Observe|TestMovesClaimPolicy'
flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go build -p 1 ./...
```
@@ -940,9 +910,18 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
```diff
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
-@@ -71,0 +72 @@
+@@ -69,6 +69,7 @@
+ "time"
+
+ "github.com/carlosframework/platform/internal/edge"
+ "github.com/carlosframework/platform/internal/hostclaim"
-@@ -347,0 +349,12 @@
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/registry"
+@@ -338,6 +339,18 @@
+
+ type actEntry struct {
+ claimResult *wokenInstance
+ // Recovery probe entries (RecoveryProbe, recoveryprobe.go). probe is the
+ // caller's probe policy, set at construction and never written again;
+ // nil for every other entry, whose wake is main's. probeAdmitted is the
@@ -955,24 +934,48 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
+ probeReviewing bool
+ probeObsolete bool
+ probeFence uint64
-@@ -751,0 +765,5 @@
+ // bounceAfterWake is a Bounce that arrived while this INSTANCE was still
+ // waking. The wake had already read its config and execed its build, so
+ // the bounce's reason has not reached the child; wake() spends it the
+@@ -808,6 +821,11 @@
+ a.entries[rt.Host] = e
+ go a.wake(e)
+ }
+ // A held recovery probe serves nothing until the source opens.
+ if e.probeHeld {
+ a.mu.Unlock()
+ return nil, ErrFenced
+ }
-@@ -836,0 +855,4 @@
+
+ switch e.state {
+ case actAwake:
+@@ -893,6 +911,10 @@
+ if e == nil {
+ a.mu.Unlock()
+ return errSidecarAsleep
+ }
+ if e.probeHeld {
+ a.mu.Unlock()
+ return ErrFenced
-@@ -1428,0 +1451,5 @@
+ }
+ switch e.state {
+ case actAwake:
+@@ -1524,6 +1546,11 @@
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ e := a.entries[host]
+ if e != nil && e.probeHeld {
+ // A held recovery probe runs the moves-held inputs, not delivered
+ // config: the recovery owns its process until the source opens.
+ return false
+ }
-@@ -2450,0 +2478,12 @@
+ if e != nil && e.state == actWaking && !e.worker {
+ // Not dropped: held for the end of the wake (bounceAfterWake). Still
+ // reported as no stop, because none has happened — the callers that
+@@ -2566,6 +2593,18 @@
+ // with the recycles below that converge instead (recycleWorkerLocked).
+ a.convergeBudget = maxConvergeCyclesPerSweep
+ for _, e := range a.entries {
+ // Recovery retains its cut fence. Supervise its process and lease
+ // before the ordinary fenced-host skip; only the claim can open it.
+ if e.probeHeld && e.state == actAwake {
@@ -985,11 +988,20 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
+ }
+ continue
+ }
-@@ -2835 +2874,3 @@
+ if a.fences[e.host] != 0 {
+ continue
+ }
+@@ -2952,7 +2991,9 @@
+ // change at all) would stop every running standalone sidecar. A
+ // sidecar's removal authority belongs to the sidecar reconcile pass,
+ // not to the edge's route map.
- if e.worker {
+ // A held recovery belongs to its claim operation, not the edge's
+ // serving set. Explicit fences and claim/session review still stop it.
+ if e.worker || e.probeHeld {
+ continue
+ }
+ switch e.state {
```
#### Patch: `internal/activator/claimwake.go`
@@ -997,7 +1009,10 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
```diff
--- a/internal/activator/claimwake.go
+++ b/internal/activator/claimwake.go
-@@ -22,6 +22,8 @@
+@@ -19,12 +19,14 @@
+ var ErrAttemptOver = moves.ErrAttemptOver
+
+ type StartPolicy struct {
- MaxStarts int
- Window time.Duration
- Deadline time.Time
@@ -1012,7 +1027,13 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
+ Probe bool
+ Prepared *moves.InstallManifest
+ Inputs *provision.MoveInputs
-@@ -105 +107,7 @@
+ }
+ type startPolicy = StartPolicy
+
+@@ -102,8 +104,17 @@
+ if e.route.WakeIncarnation != "" && (observed.Claim == nil || observed.Claim.Holder == nil || observed.Claim.Holder.Incarnation != e.route.WakeIncarnation) {
+ return nil, hostbox.ErrChanged
+ }
- if err := hostbox.WakePhase(observed.Claim, r.Box, false); err != nil {
+ // A recovery probe entry carries its caller's probe policy; every other
+ // wake runs main's serving policy.
@@ -1021,16 +1042,31 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
+ policy = *e.probe
+ }
+ if err := hostbox.WakePhase(observed.Claim, r.Box, policy.Probe); err != nil {
-@@ -106,0 +115,3 @@
+ return nil, err
+ }
+ if e.probe != nil && (!policy.Probe || !recoveryProbeKind(observed.Claim.Op.Kind)) {
+ return nil, hostbox.ErrChanged
-@@ -122 +132,0 @@
+ }
+ if r.Prerequisites == nil || r.Admit == nil || r.Prepare == nil || r.FailedAdmission == nil {
+ return nil, hostwrite.ErrUnavailable
+@@ -119,7 +130,6 @@
+ }
+ rt = *fresh
+ }
- policy := startPolicy{MaxStarts: 5, Window: 30 * time.Second}
-@@ -133,0 +144,3 @@
+ if err := r.Prerequisites(ctx, rt, observed, policy); err != nil {
+ return nil, err
+ }
+@@ -131,6 +141,9 @@
+ return nil, hostbox.ErrChanged
+ }
+ e.route = rt
+ if e.probe != nil {
+ e.probeAdmitted = admitted
+ }
+ if err := a.startAdmitted(ctx, e, admitted, policy); err != nil {
+ recoveryCtx, cancelRecovery := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancelRecovery()
```
#### New file: `internal/activator/recoveryprobe.go`
@@ -2097,18 +2133,30 @@ func TestRecoveryProbeLateHealthyAfterInputChangeStops(t *testing.T) {
```diff
--- a/internal/host/movespass.go
+++ b/internal/host/movespass.go
-@@ -388,0 +389,5 @@
+@@ -390,6 +390,11 @@
+ }
+ eb.Strict = w.strictHost
+ eb.Phase1 = true
+ }
+ // Recovery uses the same admitted Group D start path as other claim
+ // writers. Preserve the legacy nil hook with the claim switch off.
+ if cfg.claimRecords && svc != nil {
+ armRecoveryProbe(cfg, w, svc.RecoveryProbe, svc.RecoveryProbeReady)
-@@ -590,2 +595,3 @@
+ }
+ }
+
+@@ -591,8 +596,9 @@
+ // recovery also uses HandHold, LogEvent, the landing's hooks (the dirty
+ // return's install) and Timers above; ParkLeases nil leaves it unarmed
+ // in production (recoverySourceArmed). recoveryProbe is the recovery's
- // probe (Task 9, BLOCKED-ON-CLAIM C1, rulings J2 and J18): nil in
- // production, where probing logs and waits, and faked in the tests.
+ // probe (Task 9, rulings J2 and J18; claim Plan 10 J2): in production
+ // armRecoveryProbe binds the activator's Group D entry when host claims
+ // are enabled. Missing runtime wiring waits before recording inputs.
+ // rcFault is the recovery's test seam, nil in production.
+ ReleaseHeld func(host, op string) (bool, error)
+ recoveryProbe func(ctx context.Context, s release.Store, rec provision.Record, req recoveryProbeRequest) (recoveryProbeOutcome, error)
```
#### Patch: `internal/host/movesrecover.go`
@@ -2116,17 +2164,32 @@ func TestRecoveryProbeLateHealthyAfterInputChangeStops(t *testing.T) {
```diff
--- a/internal/host/movesrecover.go
+++ b/internal/host/movesrecover.go
-@@ -67 +67,3 @@
+@@ -64,7 +64,9 @@
+ // into the return generation (dirty, coverage sealed) or a fresh one
+ // restored from src_gen (clean, coverage clean-return; J14: both
+ // returns are cold and never keep S). The probe records the inputs it
-// renders before it renders them (MoveRecordRecoveryInputs). Once the
+// renders before it renders them (MoveRecordRecoveryInputs, at
+// probing: recoveryProbeAdapter, movesrecoveryprobe.go, claim Plan 10
+// J2; a clean return records only here). Once the
-@@ -96 +98,3 @@
+ // record says probing nothing earlier runs again: a restarted probe
+ // restarts only the probe (INV-2, INV-7). Nine failed attempts stop
+ // for an operator at probing.
+@@ -93,7 +95,9 @@
+ // it needs the park engine's leases (ParkLeases, for the dirty return's
+ // residue parks), which production does not wire, so recoverySourceArmed
+ // answers false there and the pass logs a standing notice. The probe hook is
-// nil in production too, until Task 9; probing then logs and waits. LogEvent
+// the recovery probe adapter (armRecoveryProbe) with CARLOS_HOST_CLAIM on,
+// bound to the activator's Group D entry. Without the claim switch the
+// legacy nil hook still waits, recording nothing. LogEvent
-@@ -625,0 +630,7 @@
+ // is nil in production, where the event is a log line.
+
+ // recoverStepsPerPass bounds the phases one pass runs: the dirty path from
+@@ -623,6 +627,13 @@
+ req.Coverage = recoveryCoverageClean
+ }
+ out, err := cfg.recoveryProbe(ctx, r.s, r.rec, req)
+ var missing *ClaimDependencyError
+ if errors.As(err, &missing) {
+ // The adapter refused before recording or waking (Plan 10 J2):
@@ -2134,6 +2197,9 @@ func TestRecoveryProbeLateHealthyAfterInputChangeStops(t *testing.T) {
+ r.w.notice(host+"#rc-probe", "moves: %s: the recovery probe of rollback %s waits: %v", host, r.rb, err)
+ return false, nil
+ }
+ if err != nil {
+ return false, fmt.Errorf("the recovery probe: %w", err)
+ }
```
#### New file: `internal/host/movesrecoveryprobe.go`
@@ -3502,7 +3568,7 @@ func ownerHook(ctx context.Context, owner OperationOwner, o hostclaim.Observed,
Invariant: a console can change only the observed move's named transition, with its exact admission, holder and record identities checked at the write. An action token grants no unrelated record, app, SQL, claim or storage mutation. Accepted work can drain after admission closes; a successor registration or incarnation cannot borrow its authority.
-Apply the tests first. Against rev 1, the new HTTP race tests return 202 and mutate the successor; after the fix they return the existing move-changed 409 without changing the claim, record or serial. The tests independently replace the move ID, incarnation and record sequence between `moveOperatorGate` and `ObserveConsoleMove` for cut, abort and retry. On the pinned base the expected failures identify the missing typed console move methods; the door tests then observe HC5-N1 until their typed writer is connected. Keep HC5-N1 until their implementation and wiring are present. `ObserveConsoleMove` captures account, app, app ID, host, incarnation, move ID, claim ETag, record ETag and `record_seq`. The grant is one `ControlWriter.ConsoleMove` call with a closed `ConsoleMoveAction`; it returns no reusable context permit or raw store. Cut, abort and retry each select a typed provision transition. Retry resumes the recorded recovery state and clears the stuck reason; it never bypasses verification, resets a session by fiat or completes a probe. Before calling `ControlWriter.ConsoleMove`, `claimConsoleMove` compares the fresh observation to the gate-read record's `Move.ID`, `Move.Incarnation` and `RecordSeq`. Reading a coherent successor is a conflict with this request, not permission to retarget it.
+Apply the tests first. Against rev 1, the new HTTP race tests return 202 and mutate the successor; after the fix they return the existing move-changed 409 without changing the claim, record or serial. The tests independently replace the move ID, incarnation and record sequence between `moveOperatorGate` and `ObserveConsoleMove` for cut, abort and retry. On the merged base the expected failures identify the missing typed console move methods; the door tests then observe HC5-N1 until their typed writer is connected. Keep HC5-N1 until their implementation and wiring are present. `ObserveConsoleMove` captures account, app, app ID, host, incarnation, move ID, claim ETag, record ETag and `record_seq`. The grant is one `ControlWriter.ConsoleMove` call with a closed `ConsoleMoveAction`; it returns no reusable context permit or raw store. Cut, abort and retry each select a typed provision transition. Retry resumes the recorded recovery state and clears the stuck reason; it never bypasses verification, resets a session by fiat or completes a probe. Before calling `ControlWriter.ConsoleMove`, `claimConsoleMove` compares the fresh observation to the gate-read record's `Move.ID`, `Move.Incarnation` and `RecordSeq`. Reading a coherent successor is a conflict with this request, not permission to retarget it.
```bash
python3 "$CLAIM10_SCRATCH/apply.py" "$CLAIM10_SCRATCH/plan.md" C tests
@@ -4315,19 +4381,40 @@ func TestJ17ReviewClaimCheckedAtTheFinalWrite(t *testing.T) {
```diff
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
-@@ -24,0 +25 @@
+@@ -22,6 +22,7 @@
+ "github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
-@@ -820,0 +822,4 @@
+ "github.com/carlosframework/platform/internal/svcrole"
+ )
+
+@@ -820,6 +821,10 @@
+ jsonError(w, http.StatusConflict, "this move was started by an older console and has no id. abort it and request it again")
+ return
+ }
+ if s.cfg.HostClaim {
+ s.claimConsoleMove(w, r, acct, app, host, rec, hostops.ConsoleMoveCut)
+ return
+ }
-@@ -840,0 +846,4 @@
+ s.moveTransitionP1(w, r, acct, app, host, provision.MoveGuardedCut(moveGuardOf(rec)), http.StatusAccepted)
+ }
+
+@@ -840,6 +845,10 @@
+ }
+ if rec.Move.Legacy() {
+ s.moveTransitionP1(w, r, acct, app, host, legacyAbortOf(*rec.Move), http.StatusAccepted)
+ return
+ }
+ if s.cfg.HostClaim {
+ s.claimConsoleMove(w, r, acct, app, host, rec, hostops.ConsoleMoveAbort)
-@@ -876,4 +885,7 @@
+ return
+ }
+ s.moveTransitionP1(w, r, acct, app, host, provision.MoveGuardedAbort(moveGuardOf(rec)), http.StatusAccepted)
+@@ -875,11 +884,66 @@
+ jsonError(w, http.StatusConflict, "nothing to resolve yet")
+ return
+ }
- // The record write is a console move write, so on a host-claim console
- // moveTransitionSaying refuses it with HC5-N1 before any write (ruling
- // J17). Claim Plan 10's console move grant (platform-1e accepted J17)
@@ -4339,7 +4426,7 @@ func TestJ17ReviewClaimCheckedAtTheFinalWrite(t *testing.T) {
+ s.claimConsoleMove(w, r, acct, app, host, rec, hostops.ConsoleMoveRetry)
+ return
+ }
-@@ -880,0 +893,52 @@
+ s.moveTransitionP1(w, r, acct, app, host, provision.MoveRetry(moveGuardOf(rec)), http.StatusAccepted)
+}
+
+// claimConsoleMove is the cut, abort and retry doors on a host-claim console
@@ -4392,6 +4479,9 @@ func TestJ17ReviewClaimCheckedAtTheFinalWrite(t *testing.T) {
+ log.Printf("console: api instance move %s/%s/%s: typed move write: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not write the move. try again")
+ }
+ }
+
+ // registerMovesPhase1Routes adds the routes that exist only with the switch
```
#### New file: `internal/hostops/console_move_grant.go`
@@ -6931,19 +7021,34 @@ func TestMoveRollbackLossRebind(t *testing.T) {
```diff
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
-@@ -19,0 +20 @@
+@@ -17,7 +17,9 @@
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostfence"
-@@ -20,0 +22 @@
+ "github.com/carlosframework/platform/internal/hostops"
+ "github.com/carlosframework/platform/internal/hostresolve"
-@@ -871 +873 @@
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/provision"
+@@ -870,8 +872,12 @@
+ http.NotFound(w, r)
+ return
+ }
- acct, app, host, rec, _, ok := s.moveOperatorGate(w, r)
+ acct, app, host, rec, addr, ok := s.moveOperatorGate(w, r)
-@@ -872,0 +875,4 @@
+ if !ok {
+ return
+ }
+ if s.cfg.HostClaim {
+ s.claimMoveResolve(w, r, acct, app, host, rec, addr)
-@@ -885,4 +891,26 @@
+ return
+ }
+ var req struct {
+@@ -884,14 +890,88 @@
+ jsonError(w, http.StatusConflict, "nothing to resolve yet")
+ return
+ }
- // The record write is a console move write. On a host-claim console it is
- // the typed console move grant's (claim Plan 10, ruling J17); without the
- // claim runtime moveTransitionSaying still refuses it with HC5-N1.
@@ -6974,7 +7079,7 @@ func TestMoveRollbackLossRebind(t *testing.T) {
+ case req.Retry && (req.DeclareLost != "" || req.Submission != nil), req.DeclareLost == "" && req.Submission != nil:
+ jsonError(w, http.StatusBadRequest, "invalid resolution request")
+ case req.Retry:
-@@ -890,3 +918,55 @@
+ s.claimConsoleMove(w, r, acct, app, host, rec, hostops.ConsoleMoveRetry)
- return
- }
- s.moveTransitionP1(w, r, acct, app, host, provision.MoveRetry(moveGuardOf(rec)), http.StatusAccepted)
@@ -7033,6 +7138,9 @@ func TestMoveRollbackLossRebind(t *testing.T) {
+ default:
+ claimConsoleMoveFailed(w, acct, app, host, err)
+ }
+ }
+
+ // claimConsoleMove is the cut, abort and retry doors on a host-claim console
```
#### New file: `internal/hostclaim/move_loss.go`
@@ -8618,7 +8726,10 @@ func MoveRollbackLossRebind(g MoveGuard, oldRef, newRef string, now time.Time) f
```diff
--- a/internal/hostclaim/lossrecord.go
+++ b/internal/hostclaim/lossrecord.go
-@@ -163,0 +164,7 @@
+@@ -161,6 +161,13 @@
+ } else if r.OpID == "" {
+ return ErrLossRecord
+ }
+ intent, intentErr := canonicalRaw(r.ConfirmedConsequences.RecoveryIntent)
+ if intentErr == nil && intent == `{"kind":"destroyed-local-only"}` && !destroyedLocalOnly(r.ConfirmedConsequences) {
+ return ErrLossRecord
@@ -8626,7 +8737,13 @@ func MoveRollbackLossRebind(g MoveGuard, oldRef, newRef string, now time.Time) f
+ if destroyedLocalOnly(r.ConfirmedConsequences) && (len(r.WriteSources) != 0 || len(r.ArchivedAdmissionLosses) != 0) {
+ return ErrLossRecord
+ }
-@@ -375,0 +383,4 @@
+ if _, err := Key(r.Host); err != nil {
+ return ErrLossRecord
+ }
+@@ -373,3 +380,7 @@
+ }
+ return false
+ }
+
+// IsDestroyedLocalOnly reports the complete attested consequence shape, not
+// merely its intent marker. Owners and durable records use the same predicate.
@@ -8638,7 +8755,10 @@ func MoveRollbackLossRebind(g MoveGuard, oldRef, newRef string, now time.Time) f
```diff
--- a/internal/hostclaim/lost.go
+++ b/internal/hostclaim/lost.go
-@@ -258,2 +258,5 @@
+@@ -255,8 +255,11 @@
+ return err
+ }
+
- if c.Op.Kind == KindServing {
- if in.OpID != "" || in.OpPhase != "" || !destroyedLocalOnly(in.Consequences) {
+ if c.Op.Kind == KindServing || destroyedLocalOnly(in.Consequences) {
@@ -8646,6 +8766,9 @@ func MoveRollbackLossRebind(g MoveGuard, oldRef, newRef string, now time.Time) f
+ return ErrWrongState
+ }
+ if (c.Op.Kind == KindServing && (in.OpID != "" || in.OpPhase != "")) || !destroyedLocalOnly(in.Consequences) {
+ return ErrLossRecord
+ }
+ // No operation journal, authority, regional reassignment or holder
```
#### New file: `internal/hostclaim/move_local_loss_test.go`
@@ -8782,11 +8905,17 @@ func TestPlan10MoveLocalDischargeRefusesExecutingBoxAndWidening(t *testing.T) {
```diff
--- a/internal/hostfence/challenge.go
+++ b/internal/hostfence/challenge.go
-@@ -126 +126,3 @@
+@@ -123,7 +123,9 @@
+ return fmt.Errorf("%w: incomplete or incorrectly timed challenge", ErrChallenge)
+ }
+ if c.Op.Kind == hostclaim.KindServing || c.Consequences.Transition == "destroyed-local-only" {
- if c.Op.Kind != hostclaim.KindServing || c.Op.ID != "" || c.Op.Phase != "" ||
+ serving := c.Op.Kind == hostclaim.KindServing
+ move := c.Op.Kind == hostclaim.KindMoving || c.Op.Kind == hostclaim.KindLanding || c.Op.Kind == hostclaim.KindReverting || c.Op.Kind == hostclaim.KindRestoring
+ if (!serving && !move) || (serving && (c.Op.ID != "" || c.Op.Phase != "")) || (move && c.Op.ID == "") ||
+ c.Consequences.Transition != "destroyed-local-only" || c.Consequences.PossibleWriteLoss ||
+ c.Consequences.PossibleTimerLoss || len(c.Consequences.Chores) != 0 ||
+ len(c.Consequences.DischargeLocal) == 0 ||
```
#### New file: `internal/hostops/move_local_loss.go`
@@ -9205,9 +9334,9 @@ func TestJ4LegacyJournalRefusedBeforeAcceptance(t *testing.T) {
### Task E: reconcile the reviewed writer inventories
-Invariant: every production declaration that can name a record write remains explicitly inventoried, and every record writer still passes through the sequenced provision primitive. Do not weaken or regenerate the census policy. Run its existing tests first: the combined base and Tasks C/D introduce unlisted sites. After reviewing the sources and their callers, apply only the inventory patch below and rerun the same tests. The provision inventory is a maintenance patch to an existing test, so the extractor applies that file in the implementation stage. The console source census first adds the three new typed method names to its selection, then refuses their unlisted call sites. Its reviewed fixture is applied in the implementation stage.
+Invariant: every production declaration that can name a record write remains explicitly inventoried, and every record writer still passes through the sequenced provision primitive. Do not weaken or regenerate the census policy. Run its existing tests first: Tasks C/D introduce unlisted sites. After reviewing the sources and their callers, apply only the inventory patch below and rerun the same tests. The provision inventory is a maintenance patch to an existing test, so the extractor applies that file in the implementation stage. The console source census first adds the three new typed method names to its selection, then refuses their unlisted call sites. Its reviewed fixture is applied in the implementation stage.
-The moves integration adds typed record transitions in `sourceFinal.stampParked`, `installRun.transition`, `recoveryInstallSource`, the `recoverRun` phase/probe/stuck/finalize methods, `movesWorker.rollBackLanding`, and the rollback phase/stuck methods. Each delegates record writes to `SetRecordMove`, which retains `WriteRecord` sequencing. These entries classify existing upstream writers; they do not grant a console a box writer's authority. `sourceFinal.configAck` writes only a move report's config-ack copy. `rollbackRun.laneMoveJSON` writes only the rollback staging lane through the guarded mirror. Those two are non-record writers. `layout.platformNames` is a local-directory exclusion list, classified as a reference. Task C's `ConsoleMove` is a record writer; its observation and claim-check helpers construct keys but do not write. Task D's `PrepareLoss` and `FinishLoss` are sequenced record writers. Its key/intent helpers are key sites; `readOnlyStore.Put` refuses and `claimSimulation.Put` captures bytes only. `lossScopedStore.Put` is a record transport: after validating the exact typed write, it calls the existing `putSequencedRecord` for record keys and forwards only the other allowed objects. Nested `WriteRecord` context preserves the single increment. The original direct forwarding failed the census policy; the implementation fixes the writer rather than relaxing the policy.
+The merged base already inventories the typed record transitions in `sourceFinal.stampParked`, `installRun.transition`, `recoveryInstallSource`, the `recoverRun` phase/probe/stuck/finalize methods, `movesWorker.rollBackLanding`, and the rollback phase/stuck methods. Each delegates record writes to `SetRecordMove`, which retains `WriteRecord` sequencing. Keep these existing upstream inventory rows once each; they do not grant a console a box writer's authority. `sourceFinal.configAck` writes only a move report's config-ack copy. `rollbackRun.laneMoveJSON` writes only the rollback staging lane through the guarded mirror. Those two are non-record writers. `layout.platformNames` is a local-directory exclusion list, classified as a reference. Task C's `ConsoleMove` is a record writer; its observation and claim-check helpers construct keys but do not write. Task D's `PrepareLoss` and `FinishLoss` are sequenced record writers. Its key/intent helpers are key sites; `readOnlyStore.Put` refuses and `claimSimulation.Put` captures bytes only. `lossScopedStore.Put` is a record transport: after validating the exact typed write, it calls the existing `putSequencedRecord` for record keys and forwards only the other allowed objects. Nested `WriteRecord` context preserves the single increment. The original direct forwarding failed the census policy; the implementation fixes the writer rather than relaxing the policy.
The console authority census now watches `ConsoleMove`, `InspectMoveLoss` and `DeclareMoveLoss`. Its fixture adds only calls from the existing operator-gated doors and the production authority wrapper; none constructs a new writer, signer or raw store in a door. The HTTP tests exercise those callers.
@@ -9240,20 +9369,11 @@ flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go build -p 1 ./...
```diff
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
-@@ -474,0 +475,31 @@
-+ // Plan 10: reviewed combined moves base and typed console loss writers.
-+ {"internal/host/movesfinal.go", "sourceFinal.configAck", "nonrecord", "sel:Put", "cca53109e6945a73b7f04705d8965b3b0997f2b7d2a82fa6d86cffa4b4174e93"},
-+ {"internal/host/movesfinal.go", "sourceFinal.stampParked", "record-writer", "key:SetRecordMove", "8af033d29c8e2275d996df9a4e53227da30a15e8fd4c198a4ab40865db7fabb9"},
-+ {"internal/host/movesinstall.go", "installRun.transition", "record-writer", "key:SetRecordMove", "5679de7898730c831906e5350529a67259d85ad0a94132ac86175a1102e21753"},
-+ {"internal/host/movesinstall.go", "recoveryInstallSource", "record-writer", "key:SetRecordMove", "b4bb0798a032c1599b9063a165656ab76c00b6ea349cb17b5f6a95769432bd5f"},
-+ {"internal/host/movesrecover.go", "recoverRun.finalize", "record-writer", "key:SetRecordMove", "fff2e28c78db68bccb16da6ab70ca877b51243b0aa56b9cb2c513c6f5660f7f7"},
-+ {"internal/host/movesrecover.go", "recoverRun.phase", "record-writer", "key:SetRecordMove", "d67132b6e797a88a9677d962c5c7aa30fc7f85fdbdff06ce8318562397a9ee78"},
-+ {"internal/host/movesrecover.go", "recoverRun.probing", "record-writer", "key:SetRecordMove", "a0144975bf52b29bbc6c2354636d3665487365d24838d3ef4baef1c665ce52ab"},
-+ {"internal/host/movesrecover.go", "recoverRun.stuck", "record-writer", "key:SetRecordMove", "e2942947ddb1559e44b494e9900fa82ebbe3782a86333167797f4a94375a4a6c"},
-+ {"internal/host/movesrollback.go", "movesWorker.rollBackLanding", "record-writer", "key:SetRecordMove", "40ff87900fbfacb5a4dff481db37ae7dbed41e5727f13185ba8623daf3e1681e"},
-+ {"internal/host/movesrollback.go", "rollbackRun.laneMoveJSON", "nonrecord", "sel:PutIfAbsentVersioned", "5fd541f4a1daf2600ff266583fc9627cc0cab35e276f1d9c8f097f128c2c4e53"},
-+ {"internal/host/movesrollback.go", "rollbackRun.phase", "record-writer", "key:SetRecordMove", "76f1efb1076029e5da4c0cc0d750fbac5e1c94a6014c2b1e4af241797350827b"},
-+ {"internal/host/movesrollback.go", "rollbackRun.stuck", "record-writer", "key:SetRecordMove", "8421f9feb384491bdb9d9cf3f722887b522bb70c41892526f7578e85654b0541"},
+@@ -472,6 +472,24 @@
+
+ // BEGIN REVIEWED INVENTORY
+ var plan9B8CensusInventory = []censusEntry{
++ // Plan 10: reviewed typed console move and loss writers.
+ {"internal/hostops/console_move_grant.go", "ControlWriter.ConsoleMove", "record-writer", "key:WriteRecord", "21e7a6ddc4dbe5b3bf06e65b39a228e2eb588af1edc43beb09d91dd72570dd44"},
+ {"internal/hostops/console_move_grant.go", "ControlWriter.consoleMoveClaim", "key-site", "key:RecordKey", "265291bd356de061663d89e5ebe33b74ea32c086f8238736d6f971a5afcab722"},
+ {"internal/hostops/console_move_grant.go", "ObserveConsoleMove", "key-site", "key:RecordKey", "296077e790e210e9f15eabdfb30f4ba09acc46825aee27824ca333bd470664a4"},
@@ -9271,7 +9391,18 @@ flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go build -p 1 ./...
+ {"internal/hostops/move_loss_owner.go", "readSuperseded", "key-site", "key:LossRecordKey", "c6e947a3d2eb726bbc7688fd5ea1320efd4092475c45b7e6a0af3f0cb956b2c4"},
+ {"internal/hostops/move_loss_owner.go", "sameMoveTarget", "key-site", "key:RecordKey", "ede06bcfca80df25af7c438629863e711f95eeb17c7c47f6f9d73459ce7ae8eb"},
+ {"internal/hostops/move_loss_owner.go", "type moveLossIntent", "key-site", "key:RecordKey", "ff9200092a5e3b55e6b422501f121c51613b8922c201ec29a6012b7bcb0e01ff"},
-+ {"internal/layout/extras.go", "var platformNames", "reference", "lit:instances", "0e98837e22835749384ee57a1c1e3ccd687f5099057af7cb1ad56ffc415512c4"},
+ // Hibernate generation markers use a different bucket and key grammar from deployment records.
+ {"internal/host/claimproduction_marker.go", "productionClaimMarkers.PutMarker", "foreign-recordkey", "lit:instances", "69301cdddce2bc61a24f0dd8297534814521adc1ddc76a7d3054ed007444a0f5"},
+ {"accounts.go", "putOverwrite", "nonrecord", "sel:Put", "a85f380434a2d297f14c8068083f41ca7f9c9b735efd7d3abf8d91520fe6453c"},
+@@ -506,7 +524,7 @@
+ // Advances the source recovery phase through SetRecordMove and its sequenced writer.
+ {"internal/host/movesrecover.go", "recoverRun.phase", "record-writer", "key:SetRecordMove", "d67132b6e797a88a9677d962c5c7aa30fc7f85fdbdff06ce8318562397a9ee78"},
+ // Records probe inputs through SetRecordMove; phase and stuck helpers use the same writer.
+- {"internal/host/movesrecover.go", "recoverRun.probing", "record-writer", "key:SetRecordMove", "7184b393fc356deb85181e25e4cafd61079598bf7ea4dafc28f90cbe26d50a31"},
++ {"internal/host/movesrecover.go", "recoverRun.probing", "record-writer", "key:SetRecordMove", "a0144975bf52b29bbc6c2354636d3665487365d24838d3ef4baef1c665ce52ab"},
+ // Marks recovery stuck through SetRecordMove; event and serial writes are not record objects.
+ {"internal/host/movesrecover.go", "recoverRun.stuck", "record-writer", "key:SetRecordMove", "e2942947ddb1559e44b494e9900fa82ebbe3782a86333167797f4a94375a4a6c"},
+ // Records revert inputs through SetRecordMove; stuck uses the reviewed record helper and journals are local.
```
#### Patch: `internal/console/testdata/claim-authority-sites.txt`
@@ -9327,106 +9458,2368 @@ flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go build -p 1 ./...
claim_purge.go|teardown|w.SavePurgeJournal(ctx, j, tag)
```
-## Integration and rollout
+## Moves cleanup authority
-Keep `CARLOS_HOST_CLAIM` and `CARLOS_MOVES_PHASE1` unset while landing these changes. This plan does not change the release floor, roster, cloud permissions, production configuration or a running service. Any later production experiment requires the separately scoped approval described by `AGENTS.md`.
+This group supplies the claim-side contracts requested by moves Plan 5's preflight K1, K2, K3, K10, K12 and K13. Moves still owns its park views, retirement history, lease inventory and recovery policy. Missing providers refuse; these contracts do not arm incomplete production owners.
-Land the pinned moves preflight and the complete Plan 9 base first. Replay this plan against their reviewed merge result if it differs from the export above. Keep B8's uncertain record-start settlement and `record_seq` regressions green. Cut/abort/retry authority is independent of SQL permits and wake-reset receipts; B3 and B4 must retain their existing refusal tests.
+### Task F: register move parks independently and require sweep evidence
-Moves Plan 4 Task 9 consumes Task A's concrete adapter and activator entry. The adapter records clean and dirty recovery inputs at `probing` before the held render. Task 9 still supplies the durable nine-attempt `OperationAttempts` provider, original first-start time, restoration/admission evidence and production owner registration. Each reservation binds the admitted session and launch digest, and the settle request uses the same role-cgroup readiness check. `KeepWriterSession` wraps each adjacent recovery phase CAS. The moves policy must continue admitting that exact session at the later row phases while refusing changed identities. Do not claim production recovery from a table test: the concrete entry is wired, while its park leases, prepared-generation/open evidence, retirement and production owner registration remain rollout prerequisites.
+Invariant: a moves pass uses the same park engine and lease inventory as the verified claim assembly. Wake-owner or C8 manifest availability cannot substitute for park evidence or prevent separately registered parks from being constructed. A missing view, retirement provider or lease inventory refuses by its contract name.
-Exercise a clean return, a dirty return, a probe restart and budget exhaustion/retry before rollout. For a declared target loss, exercise the target still executing, a dirty release without a completed return, a partially published return lane, a record commit racing the declaration, and retries at each durable boundary. Verify loss accounting through the later admission and retained signed record. A successful declaration by itself does not prove that an instance recovered. Target-local chores still require local retirement or exact destroyed-storage evidence, and the final close still waits for the target park stamp.
+The API is:
-Implementation must run the repository's full gate once before landing: `go build ./...`, `go vet ./...`, `go test ./... -count=1` and a clean whole-tree `gofmt -l .`, plus the required cross-compiles. Push and describe the reviewed branch on amadan, squash its implementation history, require green CI on that exact tip, and land with `amadan branch merge -expect`. Read main's CI after landing. The authoring checks below are focused overlay checks, not a claim that this complete landing gate or a production witness ran.
+```go
+func RegisterProductionClaimMoveParks(
+ view func(context.Context, hostclaim.Observed) (park.View, error),
+ retirementEvidence hostbox.RegionalEvidence,
+ leases hostbox.LeaseSet,
+) error
-## Copy review inventory
+func (p ClaimProviders) MoveParks(context.Context) (park.Engine, hostbox.LeaseSet, error)
+```
-This is implementation text, not shipped product copy. Keep the existing HC5-N1 refusal byte-for-byte until the typed authority is connected. Existing flag-off responses, including their punctuation, remain unchanged. Any proposed visible text listed here requires Paul's review before it is applied to product files in a later implementation. This plan does not grant copy approval.
+Registration is atomic and one-shot. The existing combined registration remains compatible: it fills absent park sources, or installs the wake source without replacing independently registered parks. With both flags on, a missing or failed wake owner yields a named wake refusal while the regional assembly remains usable. Feature-off construction retains its existing error path. The accessor is populated only after `Identity.Verify` and returns the final engine wrapped by `NewClaimRegionalWorker`, including its retirement authorization. Its data, view, lease and authorization calls recheck the verified identity. There is no caller-supplied engine, raw store or permissive park fallback. Moves Plan 5 Task 9 consumes this accessor in `armMovesPass` and must still remove its stand-ins before arming.
-The inventory is deliberately conservative: it includes new diagnostic templates that can be wrapped into an API refusal, not only top-level HTTP responses. `%s`, `%q` and `%w` are substituted at runtime. Existing messages reused by the new route retain their exact text. Machine keys and test assertions are not copy.
+K10 uses the existing regional worker and `park.Engine.SweepObserved`; add no second sweeper or in-memory released-host set. A complete `moves/<host>/` listing may supply `evidence_at` only when a `park.MoveSweepVerifier` verifies the newest retired move's durable fence or release against the exact claim observation and complete version census. Missing or incomplete history, an unresolved writer or a newer unretired move refuses. A timestamp supplied by the sweeping caller is not proof.
-`internal/console/apiinstancemove.go`:
+The engine verifies that no move operation holds the host. Only a free claim, serving claim or holderless `park_sweep: v1` continuation qualifies. It requires the verified retirement strictly before the listing starts and uses the listing's completion time as `evidence_at`. This conservative start check excludes retirement racing a paginated listing. The claim CAS and `SweepObserved` census checks retain their existing concurrency guards. The callback gets a detached claim and copied census, so it cannot alter the CAS input. Instance namespaces retain their original evidence time; every queued moves scope retains its listing time across retry and restart. The normal horizon still runs from that bound. Grouping stays `moves/<host>`, not one chore per lane.
-```text
-"invalid resolution request"
-"console: api instance move %s/%s/%s: declared loss: record reread: %v"
-"console: api instance move %s/%s/%s: declared loss refused: %v"
-"console: api instance move %s/%s/%s: typed move write refused: %v"
-"console: api instance move %s/%s/%s: typed move write: %v"
-```
+Moves Plan 5 supplies `VerifyMovesSweep` through the independently registered retirement-evidence provider. Its implementation must find the newest retired move and verify the original fence/release and all listed versions. An old retained move chosen while a newer writer is unresolved is not acceptable. The claim-side check below validates that contract's binding and time ordering; it does not claim that the moves-owned history provider already exists.
-`internal/hostclaim/move_loss.go`:
+Apply all test files first. On the merged base, missing registration, accessor and proof symbols provide the API red. Keep the no-provider, wrong identity, missing/zero/equal/later retirement, changed claim binding, active-move, mutable-callback, mixed-namespace and listing-end assertions; after the implementation these establish behavioral refusal and the persisted scope. The assembly test builds the real runtime with failed wake owners and exercises the final regional engine's authorization wrapper.
-```text
-"%w: a lost target's release names its rollback, declaration, target and both regions"
-"%w: the declaration is not on the claim"
-"%w: the declaration is not this op's"
-"%w: %q is not this move's declared-lost target"
-"%w: a lost target's lane park is the move's own lane"
-"%w: the return lane is this rollback's own lane"
-"%w: op is %s/%q, a lost target is released from a landing before its commit or from its own rollback"
-"%w: exec is %q"
-"%w: released without the target's evidence"
-"%w: a dirty release that died before its return owes the return lane's park"
-"%w: op is restoring/%q, the lost target's release precedes the source's claim"
+```bash
+python3 "$CLAIM10_SCRATCH/apply.py" "$CLAIM10_SCRATCH/plan.md" F tests
+flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go test -p 1 -timeout 3m -count=1 ./internal/host ./internal/park ./internal/hostbox -run 'TestMoveParks|TestPlan10MovesSweep|TestSweep|TestRegionalWorker|TestPlan9D5|TestPlan9I2'
+# After recording the expected red:
+python3 "$CLAIM10_SCRATCH/apply.py" "$CLAIM10_SCRATCH/plan.md" F implementation
+flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go test -p 1 -timeout 3m -count=1 ./internal/host ./internal/park ./internal/hostbox -run 'TestMoveParks|TestPlan10MovesSweep|TestSweep|TestRegionalWorker|TestPlan9D5|TestPlan9I2'
+flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go build -p 1 ./...
```
-`internal/hostops/move_loss_owner.go`:
+#### New file: `internal/host/claimmoveparks_test.go`
-```text
-"%w: %s"
-"the source box cannot be declared lost by a move"
-"the move's state does not allow a declared loss"
-"%w: write outside the declaration's scope"
-```
+```go
+package host
-`internal/provision/move_loss_rebind.go`:
+import (
+ "context"
+ "errors"
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/genselect"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/hostops"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
-```text
-"provision: malformed loss declaration"
-"%w: another loss is recorded for this target"
-```
+type parkRegistrationEvidence struct{}
-`internal/host/movesrecoveryprobe.go`:
+func (parkRegistrationEvidence) VerifyRetirement(context.Context, hostclaim.Observed, []lease.ObjectVersion) (string, time.Time, error) {
+ return "retired", time.Unix(10, 0), nil
+}
-```text
-"moves: the recovery probe's attempts are spent"
-"moves: the recovery probe request does not match its record"
-"moves: the recovery probe's inputs name no release"
-"CARLOS_HOST_CLAIM Group D recovery admission"
-"activator recovery probe entry"
-"moves recovery probe route and inputs"
-"read the host claim: %w"
-"read the registry row: %w"
-"%w: %s has no registry row on this box"
-"select the recovery inputs: %w"
-"record the recovery inputs: %w"
-"the recovery probe wake: %w"
-"%w: not rollback %s at probing"
-"%w: budget %d, clean %v"
-"%w: a clean return admits a fresh generation restored from src_gen"
-"%w: a dirty return admits its sealed return generation"
-"%w: the claim is not rollback %s's probe on %s"
-"%w: the claim's src_gen is not the request's"
+type parkRegistrationWake struct {
+ parkRegistrationEvidence
+ opens int
+ failure error
+}
+
+func (p *parkRegistrationWake) Open(context.Context, release.Store, lease.VersionedStore, hostbox.Actor) (ClaimMovesOwners, ClaimFilesManifest, error) {
+ p.opens++
+ return ClaimMovesOwners{}, nil, p.failure
+}
+func (*parkRegistrationWake) View(context.Context, hostclaim.Observed) (park.View, error) {
+ return park.View{}, nil
+}
+func (*parkRegistrationWake) LeaseSet(context.Context, string, string) ([]string, error) {
+ return []string{"lease"}, nil
+}
+func (*parkRegistrationWake) Close(context.Context) error { return nil }
+
+func TestMoveParksRegistrationIndependentAndAtomic(t *testing.T) {
+ claimMoveSources.Lock()
+ oldSource, oldParks := claimMoveSources.Source, claimMoveSources.Parks
+ claimMoveSources.Source = nil
+ claimMoveSources.Parks = nil
+ claimMoveSources.Unlock()
+ t.Cleanup(func() {
+ claimMoveSources.Lock()
+ claimMoveSources.Source = oldSource
+ claimMoveSources.Parks = oldParks
+ claimMoveSources.Unlock()
+ })
+ err := RegisterProductionClaimMoveParks(nil, nil, nil)
+ for _, name := range []string{"view", "regional evidence", "lease inventory"} {
+ if err == nil || !strings.Contains(err.Error(), name) {
+ t.Fatalf("missing %s: %v", name, err)
+ }
+ }
+ if claimMoveSources.Parks != nil {
+ t.Fatal("partial registration")
+ }
+ source := &parkRegistrationWake{}
+ if err := RegisterProductionClaimMoveParks(source.View, source, source.LeaseSet); err != nil {
+ t.Fatal(err)
+ }
+ if source.opens != 0 || claimMoveSources.Source != nil {
+ t.Fatal("parks opened or registered wake")
+ }
+ if err := RegisterProductionClaimMoveParks(source.View, source, source.LeaseSet); !errors.Is(err, hostwrite.ErrUnavailable) {
+ t.Fatalf("replacement: %v", err)
+ }
+ parks := claimMoveSources.Parks
+ if err := RegisterProductionClaimMoveSources(source); err != nil {
+ t.Fatal(err)
+ }
+ if claimMoveSources.Parks != parks || claimMoveSources.Source != source {
+ t.Fatal("wake registration replaced parks")
+ }
+ if err := RegisterProductionClaimMoveSources(source); !errors.Is(err, hostwrite.ErrUnavailable) {
+ t.Fatal("duplicate wake registration accepted")
+ }
+}
+
+func TestMoveParksWakeFailureRemainsWakeRefusal(t *testing.T) {
+ boom := errors.New("wake failed")
+ source := &parkRegistrationWake{failure: boom}
+ out := ClaimOwnerBindings{}
+ openProductionClaimMoveWake(t.Context(), nil, hostbox.Actor{}, source, &out)
+ if source.opens != 1 || !errors.Is(out.wakeUnavailable, boom) {
+ t.Fatalf("wake failure lost: %v", out.wakeUnavailable)
+ }
+ w := refusingClaimMoveWake("box", nil, out.wakeUnavailable)
+ if err := w.Prerequisites(t.Context(), registry.Route{}, hostclaim.Observed{}, activator.StartPolicy{}); !errors.Is(err, boom) {
+ t.Fatalf("wake opened: %v", err)
+ }
+ openProductionClaimMoveWake(t.Context(), nil, hostbox.Actor{}, nil, &out)
+ if out.wakeUnavailable == nil || !strings.Contains(out.wakeUnavailable.Error(), "C8") {
+ t.Fatal("missing wake not named")
+ }
+}
+
+func TestMoveParksVerifiedIdentityAndRealEngine(t *testing.T) {
+ identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: "enrolled", Region: "region", ControlBucket: "control", Revision: 1})
+ actor, err := identity.Verify(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ check := claimBoundIdentity(identity.Verify, actor)
+ sentinel := errors.New("real view")
+ calls := 0
+ engine := park.Engine{Bucket: actor.Bucket, Region: actor.Region, View: func(context.Context, hostclaim.Observed) (park.View, error) { calls++; return park.View{}, sentinel }}
+ providers := ClaimProviders{moveParks: &claimMoveParks{engine: engine, check: check, leases: func(context.Context, string, string) ([]string, error) { return []string{"real lease"}, nil }}}
+ got, leases, err := providers.MoveParks(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := got.View(t.Context(), hostclaim.Observed{}); !errors.Is(err, sentinel) || calls != 1 {
+ t.Fatalf("engine replaced: %v", err)
+ }
+ if names, err := leases(t.Context(), actor.Bucket, "scope"); err != nil || len(names) != 1 || names[0] != "real lease" {
+ t.Fatal("leases replaced")
+ }
+ mutations := map[string]func(*hostbox.Actor){"enrollment": func(a *hostbox.Actor) { a.Enrollment = "new" }, "instance": func(a *hostbox.Actor) { a.Cloud.InstanceID = "new" }, "control bucket": func(a *hostbox.Actor) { a.ControlBucket = "new" }, "data bucket": func(a *hostbox.Actor) { a.Bucket = "new" }, "namespace": func(a *hostbox.Actor) { a.Namespace = "new" }, "revision": func(a *hostbox.Actor) { a.Revision++ }, "account": func(a *hostbox.Actor) { a.Cloud.Account = "new" }}
+ for name, mutate := range mutations {
+ t.Run(name, func(t *testing.T) {
+ changed := actor
+ mutate(&changed)
+ providers.moveParks.check = claimBoundIdentity(func(context.Context) (hostbox.Actor, error) { return changed, nil }, actor)
+ if _, _, err := providers.MoveParks(t.Context()); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("identity change accepted: %v", err)
+ }
+ })
+ }
+ if _, _, err := (ClaimProviders{}).MoveParks(t.Context()); !errors.Is(err, hostwrite.ErrUnavailable) {
+ t.Fatal("unbuilt accessor accepted")
+ }
+}
+
+type moveParkNoData struct{ lease.VersionedStore }
+type moveParkNoLocal struct{ hostbox.LocalAuthority }
+
+func TestMoveParksBuildWithFailedWakeUsesRegionalEngine(t *testing.T) {
+ ctx := t.Context()
+ base := t.TempDir()
+ reg, err := registry.Open(filepath.Join(base, "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reg.Close()
+ inventory, err := hostbox.OpenStopInventoryDB(filepath.Join(base, "inventory.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer inventory.Close()
+ store := release.NewDirStore(filepath.Join(base, "store"))
+ identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: "enrolled", Region: "region", ControlBucket: "control", Revision: 1})
+ actor, err := identity.Verify(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ boom := errors.New("configured wake failed")
+ wakeSource := &parkRegistrationWake{failure: boom}
+ owners := ClaimOwnerBindings{}
+ openProductionClaimMoveWake(ctx, store, actor, wakeSource, &owners)
+ viewCalls := 0
+ a := ClaimAssembly{
+ wakeUnavailable: owners.wakeUnavailable, Claims: store, Guard: hostguard.Guard{DataDir: base}, Identity: identity,
+ RetirementAuthority: &hostops.ProductionAuthority{Store: store, Now: time.Now},
+ Local: moveParkNoLocal{}, Data: moveParkNoData{}, Wake: &ClaimWakeAssembly{}, Stop: &ClaimStopCoordinator{},
+ Controllers: productionStopControllers{}, Launches: &hostbox.LaunchJournal{},
+ Reconcile: &hostbox.ReconcileAdapter{Registry: reg, Inventory: inventory, Retirement: hostbox.TypedRetirement{JournalDir: filepath.Join(base, "retirement")}},
+ ManagedRoots: map[string]string{"data": base, "opt": filepath.Join(base, "opt"), "units": filepath.Join(base, "units")},
+ Evidence: parkRegistrationEvidence{},
+ LeaseSet: func(context.Context, string, string) ([]string, error) { return []string{"actual-inventory"}, nil },
+ View: func(context.Context, hostclaim.Observed) (park.View, error) { viewCalls++; return park.View{}, nil },
+ Authorize: func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil },
+ AdmissionEvidence: func(context.Context, hostclaim.Observed, hostclaim.Admission, *genselect.Plan) error { return nil },
+ PublicationEvidence: func(context.Context, hostclaim.Observed) error { return nil }, NewID: func() string { return "id" }, Close: func(context.Context) error { return nil },
+ }
+ providers, err := a.Build(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ engine, leases, err := providers.MoveParks(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := providers.Wake.Prerequisites(ctx, registry.Route{}, hostclaim.Observed{}, activator.StartPolicy{}); !errors.Is(err, boom) {
+ t.Fatalf("wake error lost: %v", err)
+ }
+ if _, err := engine.View(ctx, hostclaim.Observed{}); err != nil || viewCalls != 1 {
+ t.Fatalf("wrong view: %v", err)
+ }
+ names, err := leases(ctx, actor.Bucket, "scope")
+ if err != nil || len(names) != 1 || names[0] != "actual-inventory" {
+ t.Fatalf("wrong inventory: %v %v", names, err)
+ }
+ // NewClaimRegionalWorker strengthens Authorize. The accessor must return
+ // that final engine, not its earlier permissive construction value.
+ if err := engine.Authorize(ctx, hostclaim.Observed{}, hostclaim.Obligation{}); err == nil {
+ t.Fatal("accessor bypassed retirement evidence guard")
+ }
+ regional := providers.Regional.(hostbox.RegionalWorker)
+ if engine.Store.(hostbox.RequestStore).Store != regional.Regional.Engine.Store.(hostbox.RequestStore).Store || engine.Claims != regional.Regional.Engine.Claims {
+ t.Fatal("accessor reconstructed another engine")
+ }
+ if err := providers.Close(ctx); err != nil {
+ t.Fatal(err)
+ }
+}
```
-`internal/activator/recoveryprobe.go`:
+#### Patch: `internal/hostbox/regionalworker_test.go`
-```text
-"%w: not a recovery probe policy"
-"activator: %s recovery probe healthy, but its fence changed; stopping it"
-"activator: %s recovery probe healthy; held until the source opens"
-"activator: %s recovery probe's session is over (%v); stopping it"
-"activator: %s source opened on the recovery probe's session; serving"
+```diff
+--- a/internal/hostbox/regionalworker_test.go
++++ b/internal/hostbox/regionalworker_test.go
+@@ -79,6 +79,12 @@
+ return nil
+ },
+ }
++ engine.VerifyMovesSweep = func(_ context.Context, o hostclaim.Observed, versions []lease.ObjectVersion) (park.MoveSweepProof, error) {
++ if len(versions) == 0 {
++ t.Fatal("missing census")
++ }
++ return park.MoveSweepProof{Host: o.Host, ClaimETag: o.ETag, MoveID: "retired", Incarnation: inc, RetiredAt: evidenceAt}, nil
++ }
+ worker := RegionalWorker{
+ Regional: Regional{Engine: engine, Owner: func(context.Context, hostclaim.Observed) error { return nil }},
+ Enrollment: enrollmentFunc(func(_ context.Context, region, gotBucket string, limit time.Duration) error {
+@@ -97,8 +103,8 @@
+ return inc, evidenceAt, nil
+ }),
+ }
+- if err := worker.Run(ctx); err != nil {
+- t.Fatal(err)
++ if err := worker.Run(ctx); !errors.Is(err, park.ErrPending) {
++ t.Fatalf("new moves sweep must wait from listing time: %v", err)
+ }
+ if len(limits) == 0 {
+ t.Fatal("authorization did not see a scoped chore")
```
-The displayed transition token `move-target-lost` is new. It identifies the proposed target-loss consequence in the shared inspection. The HTTP paths reuse the existing `nothing to resolve yet`, `could not read the instance`, move-changed, missing-runtime HC5-N1 and fixed write-failure sentences. Their spelling and legacy-path behavior are unchanged.
+#### New file: `internal/park/move_sweep_test.go`
-## Verification and self-review
+```go
+package park
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/lease"
+)
+
+func TestPlan10MovesSweepRequiresPriorRetirement(t *testing.T) {
+ for _, tc := range []string{"missing", "zero", "equal", "later", "host", "etag", "move", "incarnation", "provider-error", "mutation", "valid"} {
+ t.Run(tc, func(t *testing.T) {
+ f := newFixture(t)
+ obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ obs, err = hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error {
+ c.Obligations = nil
+ c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.b.Put("data", "moves/"+f.w.Host+"/old/staging", []byte("old"))
+ listing := f.e.Now()
+ listedAt := listing.Add(time.Second)
+ ticks := 0
+ f.e.Now = func() time.Time {
+ ticks++
+ if ticks == 1 {
+ return listing
+ }
+ return listedAt
+ }
+ f.e.View = func(_ context.Context, o hostclaim.Observed) (View, error) {
+ return View{ClaimETag: o.ETag, Region: "ie", AuthoritativeRegion: "uk"}, nil
+ }
+ calls := 0
+ if tc != "missing" {
+ f.e.VerifyMovesSweep = func(_ context.Context, o hostclaim.Observed, versions []lease.ObjectVersion) (MoveSweepProof, error) {
+ calls++
+ if o.ETag != obs.ETag || len(versions) != 1 {
+ t.Fatal("proof not bound to claim and complete census")
+ }
+ p := MoveSweepProof{Host: o.Host, ClaimETag: o.ETag, MoveID: "old", Incarnation: f.ob.Park.Incarnation, RetiredAt: listing.Add(-time.Second)}
+ switch tc {
+ case "zero":
+ p.RetiredAt = time.Time{}
+ case "equal":
+ p.RetiredAt = listing
+ case "later":
+ p.RetiredAt = listing.Add(time.Second)
+ case "host":
+ p.Host = "other.example"
+ case "etag":
+ p.ClaimETag = "other"
+ case "move":
+ p.MoveID = ""
+ case "incarnation":
+ p.Incarnation = "wrong"
+ case "mutation":
+ o.Claim.Holder.Account = "foreign"
+ o.Claim.Lost = []string{"unrelated"}
+ case "provider-error":
+ return p, ErrPending
+ }
+ return p, nil
+ }
+ }
+ _, err = f.e.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.ob.Park.EvidenceAt, f.ob.Park.RequestLimit)
+ fresh, readErr := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ if tc != "valid" && tc != "mutation" {
+ if err == nil || fresh.ETag != obs.ETag {
+ t.Fatalf("unsafe proof queued: err=%v claim=%+v", err, fresh.Claim)
+ }
+ return
+ }
+ if !errors.Is(err, ErrPending) || calls != 1 || len(fresh.Claim.Obligations) != 1 {
+ t.Fatalf("valid proof: calls=%d err=%v claim=%+v", calls, err, fresh.Claim)
+ }
+ ob := fresh.Claim.Obligations[0]
+ if ob.Park.Location != "moves/"+f.w.Host || !ob.Park.EvidenceAt.Equal(listedAt) {
+ t.Fatalf("scope does not bind listing time: %+v", ob)
+ }
+ if fresh.Claim.Holder.Account != obs.Claim.Holder.Account || len(fresh.Claim.Lost) != len(obs.Claim.Lost) {
+ t.Fatal("proof provider mutated CAS input")
+ }
+ // A retry keeps the queued bound; it does not restart the horizon.
+ f.e.Now = func() time.Time { return listing.Add(20 * time.Minute) }
+ if err := f.e.Run(t.Context(), Work{Host: f.w.Host, ID: ob.ID, Op: ob.Op}); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+}
+
+func TestPlan10MovesSweepNoMoveOperation(t *testing.T) {
+ for _, kind := range []string{hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindReverting, hostclaim.KindRestoring, hostclaim.KindRelocating} {
+ t.Run(kind, func(t *testing.T) {
+ p := MoveSweepProof{Host: "app.example", ClaimETag: "tag", MoveID: "old", Incarnation: "inc", RetiredAt: time.Unix(1, 0)}
+ o := hostclaim.Observed{Host: p.Host, ETag: p.ClaimETag, Claim: &hostclaim.Claim{Op: hostclaim.Op{Kind: kind, ID: "active"}}}
+ if err := p.Check(o, p.Incarnation, time.Unix(2, 0)); !errors.Is(err, ErrUnsafe) {
+ t.Fatalf("%s accepted: %v", kind, err)
+ }
+ })
+ }
+}
+
+// Existing retirement tests advance the fake clock after listing so they can
+// exercise completion. The precondition tests above keep the horizon pending.
+func allowFixtureMoveSweep(f *fixture) {
+ listing := f.e.Now()
+ calls := 0
+ f.e.Now = func() time.Time {
+ calls++
+ if calls <= 2 {
+ return listing
+ }
+ return listing.Add(20 * time.Minute)
+ }
+ f.e.VerifyMovesSweep = func(_ context.Context, o hostclaim.Observed, _ []lease.ObjectVersion) (MoveSweepProof, error) {
+ return MoveSweepProof{Host: o.Host, ClaimETag: o.ETag, MoveID: "retired", Incarnation: f.ob.Park.Incarnation, RetiredAt: listing.Add(-time.Second)}, nil
+ }
+}
+
+func TestPlan10MovesSweepPreservesInstanceEvidence(t *testing.T) {
+ f := newFixture(t)
+ f.seed()
+ o, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = hostclaim.Update(t.Context(), f.e.Claims, o, func(c *hostclaim.Claim) error { c.Obligations = nil; return hostclaim.FreeAfterPark("op")(c) })
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.b.Put("data", "moves/"+f.w.Host+"/retired/staging", []byte("staged"))
+ listedAt := f.e.Now()
+ f.e.VerifyMovesSweep = func(_ context.Context, o hostclaim.Observed, _ []lease.ObjectVersion) (MoveSweepProof, error) {
+ return MoveSweepProof{Host: o.Host, ClaimETag: o.ETag, MoveID: "retired", Incarnation: f.ob.Park.Incarnation, RetiredAt: listedAt.Add(-time.Second)}, nil
+ }
+ scopes := map[string]time.Time{}
+ f.e.Authorize = func(_ context.Context, _ hostclaim.Observed, ob hostclaim.Obligation) error {
+ scopes[ob.Park.Location] = ob.Park.EvidenceAt
+ return nil
+ }
+ _, err = f.e.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.ob.Park.EvidenceAt, f.ob.Park.RequestLimit)
+ if !errors.Is(err, ErrPending) {
+ t.Fatal(err)
+ }
+ if !scopes[f.ob.Park.Location].Equal(f.ob.Park.EvidenceAt) || !scopes["moves/"+f.w.Host].Equal(listedAt) {
+ t.Fatalf("evidence mixed: %+v", scopes)
+ }
+}
+```
+
+#### Patch: `internal/park/sweep_coverage_test.go`
+
+```diff
+--- a/internal/park/sweep_coverage_test.go
++++ b/internal/park/sweep_coverage_test.go
+@@ -13,6 +13,7 @@
+ func coverageFixture(t *testing.T, foreign bool) *fixture {
+ t.Helper()
+ f := newFixture(t)
++ allowFixtureMoveSweep(f)
+ obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+```
+
+#### Patch: `internal/park/sweep_test.go`
+
+```diff
+--- a/internal/park/sweep_test.go
++++ b/internal/park/sweep_test.go
+@@ -56,6 +56,7 @@
+
+ func TestSweepMovesNamespaceIsSeparateFromLiveInstance(t *testing.T) {
+ f := newFixture(t)
++ allowFixtureMoveSweep(f)
+ obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+```
+
+#### Patch: `internal/host/claimassembly.go`
+
+```diff
+--- a/internal/host/claimassembly.go
++++ b/internal/host/claimassembly.go
+@@ -91,6 +91,7 @@
+ // ClaimAssembly owns the concrete wiring. Dependencies retain their contract
+ // names so a missing implementation cannot silently become permissive policy.
+ type ClaimAssembly struct {
++ wakeUnavailable error
+ Journals *hostbox.JournalService
+ cleanupEnrollment *claimCleanupEnrollment
+ RetirementAuthority *hostops.ProductionAuthority
+@@ -172,7 +173,7 @@
+ return RetireClaimOverSocket(ctx, controlsock.Path, target)
+ }
+ a.Provision = a.Reconcile.Configure
+- check := func(ctx context.Context) error { _, err := a.Identity.Verify(ctx); return err }
++ check := claimBoundIdentity(a.Identity.Verify, actor)
+ data := hostbox.RequestStore{Store: a.Data, Check: check}
+ writer := hostbox.Writer{Claims: a.Claims, Data: data, Bucket: actor.Bucket, Region: actor.Region, Box: actor.Box, AdmissionEvidence: a.AdmissionEvidence, PublicationEvidence: a.PublicationEvidence, NewID: a.NewID}
+ a.bindClaimWakeStop()
+@@ -185,8 +186,11 @@
+ }
+ a.Wake.LeaseRegion = actor.Cloud.Region
+ wake := refusingRootWake(actor.Box, a.Claims)
+- if a.Mode == "agent" || a.Mode == "" {
++ if (a.Mode == "agent" || a.Mode == "") && a.wakeUnavailable == nil {
+ wake, err = a.Wake.Build(ctx, source, writer)
++ }
++ if a.wakeUnavailable != nil && (a.Mode == "agent" || a.Mode == "") {
++ wake = refusingClaimMoveWake(actor.Box, a.Claims, a.wakeUnavailable)
+ }
+ if err != nil {
+ return ClaimProviders{}, err
+@@ -194,14 +198,39 @@
+ if wake == nil || wake.Read == nil || wake.Prerequisites == nil || wake.Admit == nil || wake.Prepare == nil || wake.StopForeign == nil || wake.FailedAdmission == nil {
+ return ClaimProviders{}, hostwrite.ErrUnavailable
+ }
+- enrollment := claimRegionalEnrollment{identity: a.Identity}
++ enrollment := claimRegionalEnrollment{identity: a.Identity, actor: actor}
+ authorize := func(ctx context.Context, o hostclaim.Observed, ob hostclaim.Obligation) error {
+ if err := enrollment.VerifyRegional(ctx, actor.Region, actor.Bucket, hostbox.RequestLimit); err != nil {
+ return err
+ }
+ return a.Authorize(ctx, o, ob)
+ }
+- engine := park.Engine{Store: data, Claims: a.Claims, Bucket: actor.Bucket, Region: actor.Region, Leases: hostbox.AggregateLeases{Leases: hostbox.RenewingLeases{Region: actor.Cloud.Region}, Resolve: a.LeaseSet}, Now: time.Now, NewID: a.NewID, View: a.View, Authorize: authorize}
++ leases := hostbox.LeaseSet(func(ctx context.Context, bucket, scope string) ([]string, error) {
++ if err := check(ctx); err != nil {
++ return nil, err
++ }
++ if bucket != actor.Bucket {
++ return nil, hostbox.ErrChanged
++ }
++ return a.LeaseSet(ctx, bucket, scope)
++ })
++ view := func(ctx context.Context, o hostclaim.Observed) (park.View, error) {
++ if err := check(ctx); err != nil {
++ return park.View{}, err
++ }
++ return a.View(ctx, o)
++ }
++ engine := park.Engine{Store: data, Claims: a.Claims, Bucket: actor.Bucket, Region: actor.Region, Leases: hostbox.AggregateLeases{Leases: hostbox.RenewingLeases{Region: actor.Cloud.Region}, Resolve: leases}, Now: time.Now, NewID: a.NewID, View: view, Authorize: authorize}
++ engine.VerifyMovesSweep = func(ctx context.Context, o hostclaim.Observed, versions []lease.ObjectVersion) (park.MoveSweepProof, error) {
++ if err := check(ctx); err != nil {
++ return park.MoveSweepProof{}, err
++ }
++ verifier, ok := a.Evidence.(park.MoveSweepVerifier)
++ if !ok {
++ return park.MoveSweepProof{}, &ClaimDependencyError{Contracts: []string{"moves Plan 5 newest retired move fence or release evidence"}}
++ }
++ return verifier.VerifyMovesSweep(ctx, o, versions)
++ }
+ regional := hostbox.RegionalWorker{Regional: hostbox.Regional{Engine: engine, Owner: func(ctx context.Context, _ hostclaim.Observed) error {
+ return enrollment.VerifyRegional(ctx, actor.Region, actor.Bucket, hostbox.RequestLimit)
+ }}, Enrollment: enrollment, Evidence: a.Evidence}
+@@ -215,10 +244,13 @@
+ if err := a.startWakeResetWorker(ctx); err != nil {
+ return ClaimProviders{}, err
+ }
+- return ClaimProviders{Delivery: ClaimTimerDelivery(a.Producers, a.NewID), Selector: source, Wake: wake, Regional: regional, Stop: a.Stop, Provision: a.Provision, Close: a.Close}, nil
+-}
+-
+-type claimRegionalEnrollment struct{ identity hostbox.IdentityProvider }
++ return ClaimProviders{moveParks: &claimMoveParks{engine: regional.Regional.Engine, leases: leases, check: check}, Delivery: ClaimTimerDelivery(a.Producers, a.NewID), Selector: source, Wake: wake, Regional: regional, Stop: a.Stop, Provision: a.Provision, Close: a.Close}, nil
++}
++
++type claimRegionalEnrollment struct {
++ identity hostbox.IdentityProvider
++ actor hostbox.Actor
++}
+
+ func (e claimRegionalEnrollment) VerifyRegional(ctx context.Context, region, bucket string, limit time.Duration) error {
+ if limit != hostbox.RequestLimit {
+@@ -228,7 +260,7 @@
+ if err != nil {
+ return err
+ }
+- if a.Region != region || a.Bucket != bucket {
++ if a != e.actor || a.Region != region || a.Bucket != bucket {
+ return hostbox.ErrChanged
+ }
+ return nil
+```
+
+#### Patch: `internal/host/claimconstruction.go`
+
+```diff
+--- a/internal/host/claimconstruction.go
++++ b/internal/host/claimconstruction.go
+@@ -43,6 +43,7 @@
+ // moves integration's owner set for moving, landing, reverting and restoring,
+ // bound only with CARLOS_MOVES_PHASE1 on (claim Plan 9 D5).
+ type ClaimOwnerBindings struct {
++ wakeUnavailable error
+ cleanupEnrollment *claimCleanupEnrollment
+ RetirementAuthority *hostops.ProductionAuthority
+ EvidenceKey ed25519.PrivateKey
+@@ -139,7 +140,8 @@
+ }()
+ movesC1, err := claimMovesC1(claimEntryEnv(ctx), owners.Moves)
+ if err != nil {
+- return nil, err
++ owners.wakeUnavailable = errors.Join(owners.wakeUnavailable, &ClaimDependencyError{Contracts: []string{"moves wake owners and operation attempts"}}, err)
++ movesC1 = false
+ }
+ if owners.Local == nil || owners.Controllers == nil || owners.Data == nil || owners.InputPolicy == nil || owners.MovesPolicy == nil || owners.Evidence == nil || owners.LeaseSet == nil || owners.View == nil || owners.Authorize == nil {
+ return nil, hostwrite.ErrUnavailable
+@@ -312,7 +314,7 @@
+ stop.Claims = hostguard.StoreReader{Store: store}
+ stop.Inventory = inventory
+ stop.NewID = newID
+- assembly := &ClaimAssembly{Journals: journals, cleanupEnrollment: owners.cleanupEnrollment, RetirementAuthority: owners.RetirementAuthority, SharedPaths: claimGlobalSharedPaths(r), Mode: r.Mode, Producers: producers, Launches: launches, ManagedRoots: roots, Reconcile: reconcile, RootJournal: launches, Controllers: owners.Controllers, Claims: store, Guard: guard, Identity: owners.Identity, Local: owners.Local, Policy: hostbox.Policy{Inputs: owners.InputPolicy, Moves: owners.MovesPolicy, Held: hostbox.RegistryHoldPolicy(r.Registry)}, Data: owners.Data, Wake: wake, Stop: stop, Evidence: owners.Evidence, LeaseSet: owners.LeaseSet, View: owners.View, Authorize: owners.Authorize, AdmissionEvidence: admissionEvidence, PublicationEvidence: publicationEvidence, NewID: newID}
++ assembly := &ClaimAssembly{wakeUnavailable: owners.wakeUnavailable, Journals: journals, cleanupEnrollment: owners.cleanupEnrollment, RetirementAuthority: owners.RetirementAuthority, SharedPaths: claimGlobalSharedPaths(r), Mode: r.Mode, Producers: producers, Launches: launches, ManagedRoots: roots, Reconcile: reconcile, RootJournal: launches, Controllers: owners.Controllers, Claims: store, Guard: guard, Identity: owners.Identity, Local: owners.Local, Policy: hostbox.Policy{Inputs: owners.InputPolicy, Moves: owners.MovesPolicy, Held: hostbox.RegistryHoldPolicy(r.Registry)}, Data: owners.Data, Wake: wake, Stop: stop, Evidence: owners.Evidence, LeaseSet: owners.LeaseSet, View: owners.View, Authorize: owners.Authorize, AdmissionEvidence: admissionEvidence, PublicationEvidence: publicationEvidence, NewID: newID}
+ var closeMu sync.Mutex
+ closed := false
+ assembly.Close = func(ctx context.Context) error {
+```
+
+#### New file: `internal/host/claimmoveparks.go`
+
+```go
+package host
+
+import (
+ "context"
+ "errors"
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type claimMoveParkSources struct {
+ View func(context.Context, hostclaim.Observed) (park.View, error)
+ Evidence hostbox.RegionalEvidence
+ Leases hostbox.LeaseSet
+}
+
+// RegisterProductionClaimMoveParks installs the regional contracts independently
+// of the wake owners and C8 files manifest. Registration is atomic and one-shot.
+func RegisterProductionClaimMoveParks(view func(context.Context, hostclaim.Observed) (park.View, error), retirementEvidence hostbox.RegionalEvidence, leases hostbox.LeaseSet) error {
+ var missing []string
+ if view == nil {
+ missing = append(missing, "moves Plan 5 exact-version generation and files-manifest view")
+ }
+ if retirementEvidence == nil {
+ missing = append(missing, "moves Plan 5 archived-incarnation regional evidence")
+ }
+ if leases == nil {
+ missing = append(missing, "moves Plan 5 complete replica and staging lease inventory")
+ }
+ if len(missing) != 0 {
+ return &ClaimDependencyError{Contracts: missing}
+ }
+ claimMoveSources.Lock()
+ defer claimMoveSources.Unlock()
+ if claimMoveSources.Parks != nil {
+ return hostwrite.ErrUnavailable
+ }
+ claimMoveSources.Parks = &claimMoveParkSources{View: view, Evidence: retirementEvidence, Leases: leases}
+ return nil
+}
+
+func openProductionClaimMoveWake(ctx context.Context, store release.Store, actor hostbox.Actor, external ClaimProductionMoveSources, out *ClaimOwnerBindings) {
+ if external == nil {
+ out.wakeUnavailable = &ClaimDependencyError{Contracts: []string{"moves wake owners and C8 origin-qualified files manifest provider"}}
+ return
+ }
+ owners, files, err := external.Open(ctx, store, out.Data, actor)
+ if err != nil {
+ out.wakeUnavailable = errors.Join(&ClaimDependencyError{Contracts: []string{"moves wake owners and C8 origin-qualified files manifest provider"}}, err)
+ return
+ }
+ out.Moves, out.Serving.Files = owners, files
+}
+
+func refusingClaimMoveWake(box string, store release.Store, reason error) *activator.ClaimRuntime {
+ w := refusingRootWake(box, store)
+ w.Prerequisites = func(context.Context, registry.Route, hostclaim.Observed, activator.StartPolicy) error { return reason }
+ w.Admit = func(context.Context, registry.Route, hostclaim.Observed, activator.StartPolicy) (hostclaim.Observed, error) {
+ return hostclaim.Observed{}, reason
+ }
+ w.Prepare = func(context.Context, registry.Route, hostclaim.Observed, activator.StartPolicy) (activator.ClaimStart, error) {
+ return activator.ClaimStart{}, reason
+ }
+ return w
+}
+
+// claimBoundIdentity pins the entire verified enrollment, including its cloud
+// instance, buckets, namespace and directory revision, for a built assembly.
+func claimBoundIdentity(verify func(context.Context) (hostbox.Actor, error), expected hostbox.Actor) func(context.Context) error {
+ return func(ctx context.Context) error {
+ if ctx == nil || verify == nil || expected.Enrollment == "" {
+ return hostwrite.ErrUnavailable
+ }
+ current, err := verify(ctx)
+ if err != nil {
+ return err
+ }
+ if current != expected {
+ return hostbox.ErrChanged
+ }
+ return nil
+ }
+}
+
+type claimMoveParks struct {
+ engine park.Engine
+ leases hostbox.LeaseSet
+ check func(context.Context) error
+}
+
+// MoveParks returns the assembly's regional engine and complete lease inventory.
+// Only Build can populate this capability, after Identity.Verify succeeds.
+func (p ClaimProviders) MoveParks(ctx context.Context) (park.Engine, hostbox.LeaseSet, error) {
+ if p.moveParks == nil || p.moveParks.check == nil || p.moveParks.leases == nil {
+ return park.Engine{}, nil, hostwrite.ErrUnavailable
+ }
+ if err := p.moveParks.check(ctx); err != nil {
+ return park.Engine{}, nil, err
+ }
+ return p.moveParks.engine, p.moveParks.leases, nil
+}
+
+func (p productionClaimRegional) VerifyMovesSweep(ctx context.Context, o hostclaim.Observed, versions []lease.ObjectVersion) (park.MoveSweepProof, error) {
+ if p.Parks == nil {
+ return park.MoveSweepProof{}, &ClaimDependencyError{Contracts: []string{"moves Plan 5 newest retired move fence or release evidence"}}
+ }
+ verifier, ok := p.Parks.Evidence.(park.MoveSweepVerifier)
+ if !ok {
+ return park.MoveSweepProof{}, &ClaimDependencyError{Contracts: []string{"moves Plan 5 newest retired move fence or release evidence"}}
+ }
+ if _, err := p.Identity.Verify(ctx); err != nil {
+ return park.MoveSweepProof{}, err
+ }
+ return verifier.VerifyMovesSweep(ctx, o, versions)
+}
+```
+
+#### Patch: `internal/host/claimproduction.go`
+
+```diff
+--- a/internal/host/claimproduction.go
++++ b/internal/host/claimproduction.go
+@@ -22,6 +22,7 @@
+ "github.com/carlosframework/platform/internal/hostops"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/lease"
++ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+@@ -42,6 +43,7 @@
+ var claimMoveSources struct {
+ sync.Mutex
+ Source ClaimProductionMoveSources
++ Parks *claimMoveParkSources
+ }
+
+ func RegisterProductionClaimMoveSources(s ClaimProductionMoveSources) error {
+@@ -49,6 +51,9 @@
+ defer claimMoveSources.Unlock()
+ if s == nil || claimMoveSources.Source != nil {
+ return hostwrite.ErrUnavailable
++ }
++ if claimMoveSources.Parks == nil {
++ claimMoveSources.Parks = &claimMoveParkSources{View: s.View, Evidence: s, Leases: s.LeaseSet}
+ }
+ claimMoveSources.Source = s
+ return nil
+@@ -140,8 +145,11 @@
+ out.Serving.Markers = marker
+ claimMoveSources.Lock()
+ external := claimMoveSources.Source
++ parks := claimMoveSources.Parks
+ claimMoveSources.Unlock()
+- if external != nil {
++ if hostwrite.Enabled(claimEntryEnv(ctx)) && moves.Enabled(claimEntryEnv(ctx)) {
++ openProductionClaimMoveWake(ctx, store, actor, external, &out)
++ } else if external != nil {
+ out.Moves, out.Serving.Files, err = external.Open(ctx, store, out.Data, actor)
+ if err != nil {
+ return out, err
+@@ -182,7 +190,7 @@
+ return nil
+ }
+ out.MovesPolicy = MovesClaimPolicy(MovesRecordsFrom(store))
+- regional := productionClaimRegional{Identity: out.Identity, External: external, Local: local}
++ regional := productionClaimRegional{Identity: out.Identity, Parks: parks, Local: local}
+ out.Evidence = regional
+ out.LeaseSet = regional.LeaseSet
+ out.View = regional.View
+@@ -278,21 +286,21 @@
+
+ type productionClaimRegional struct {
+ Identity hostbox.IdentityProvider
+- External ClaimProductionMoveSources
++ Parks *claimMoveParkSources
+ Local *productionClaimLocal
+ }
+
+ func (p productionClaimRegional) View(ctx context.Context, o hostclaim.Observed) (park.View, error) {
+- if p.External == nil {
++ if p.Parks == nil || p.Parks.View == nil {
+ return park.View{}, &ClaimDependencyError{Contracts: []string{"moves Plan 5 exact-version generation and files-manifest view"}}
+ }
+ if _, err := p.Identity.Verify(ctx); err != nil {
+ return park.View{}, err
+ }
+- return p.External.View(ctx, o)
++ return p.Parks.View(ctx, o)
+ }
+ func (p productionClaimRegional) LeaseSet(ctx context.Context, bucket, scope string) ([]string, error) {
+- if p.External == nil {
++ if p.Parks == nil || p.Parks.Leases == nil {
+ return nil, &ClaimDependencyError{Contracts: []string{"moves Plan 5 complete replica and staging lease inventory"}}
+ }
+ a, err := p.Identity.Verify(ctx)
+@@ -302,16 +310,16 @@
+ if a.Bucket != bucket {
+ return nil, hostbox.ErrChanged
+ }
+- return p.External.LeaseSet(ctx, bucket, scope)
++ return p.Parks.Leases(ctx, bucket, scope)
+ }
+ func (p productionClaimRegional) VerifyRetirement(ctx context.Context, o hostclaim.Observed, v []lease.ObjectVersion) (string, time.Time, error) {
+- if p.External == nil {
++ if p.Parks == nil || p.Parks.Evidence == nil {
+ return "", time.Time{}, &ClaimDependencyError{Contracts: []string{"moves Plan 5 archived-incarnation regional evidence"}}
+ }
+ if _, err := p.Identity.Verify(ctx); err != nil {
+ return "", time.Time{}, err
+ }
+- return p.External.VerifyRetirement(ctx, o, v)
++ return p.Parks.Evidence.VerifyRetirement(ctx, o, v)
+ }
+ func (p productionClaimRegional) Authorize(ctx context.Context, o hostclaim.Observed, ob hostclaim.Obligation) error {
+ a, err := p.Identity.Verify(ctx)
+```
+
+#### Patch: `internal/host/claimwiring.go`
+
+```diff
+--- a/internal/host/claimwiring.go
++++ b/internal/host/claimwiring.go
+@@ -19,6 +19,7 @@
+ }
+
+ type ClaimProviders struct {
++ moveParks *claimMoveParks
+ Delivery schedule.DeliveryAdmission
+ Selector hostwrite.Selector
+ Wake *activator.ClaimRuntime
+```
+
+#### New file: `internal/park/move_sweep.go`
+
+```go
+package park
+
+import (
+ "context"
+ "encoding/json"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/lease"
+)
+
+// MoveSweepVerifier verifies the newest retired move's durable fence or
+// release, covering every moves/<host> version in the complete census.
+// A missing history, unresolved writer, or newer unretired move must refuse.
+// The caller supplies the current claim and census; a timestamp alone is not
+// evidence. Moves Plan 5 supplies this provider from its retained journals.
+type MoveSweepVerifier interface {
+ VerifyMovesSweep(context.Context, hostclaim.Observed, []lease.ObjectVersion) (MoveSweepProof, error)
+}
+
+type MoveSweepProof struct {
+ Host, ClaimETag, MoveID, Incarnation string
+ RetiredAt time.Time
+}
+
+// Check permits listing-time evidence only after the newest retired move's
+// verified fence or release. Requiring it before listing starts also excludes
+// a retirement racing a paginated listing. The queued EvidenceAt is the end
+// of that listing and is retained by every retry.
+func (p MoveSweepProof) Check(o hostclaim.Observed, incarnation string, listingStarted time.Time) error {
+ if o.Absent || o.Claim == nil || o.ETag == "" || p.Host != o.Host || p.ClaimETag != o.ETag || !token(p.MoveID) || p.Incarnation != incarnation || p.RetiredAt.IsZero() || !p.RetiredAt.Before(listingStarted) {
+ return ErrUnsafe
+ }
+ c := o.Claim
+ if c.Free() || c.Op.Kind == hostclaim.KindServing {
+ return nil
+ }
+ if c.Holder == nil && c.Op.Kind == hostclaim.KindParking && c.Op.ID != "" && c.Op.Exec == "" && c.Op.Intent["park_sweep"] == "v1" {
+ return nil
+ }
+ return ErrUnsafe
+}
+
+// moveSweepObservation keeps a provider's read-only input detached from the CAS.
+func moveSweepObservation(o hostclaim.Observed) (hostclaim.Observed, error) {
+ raw, err := json.Marshal(o.Claim)
+ if err != nil {
+ return hostclaim.Observed{}, err
+ }
+ var c *hostclaim.Claim
+ if err := json.Unmarshal(raw, &c); err != nil {
+ return hostclaim.Observed{}, err
+ }
+ o.Claim = c
+ return o, nil
+}
+```
+
+#### Patch: `internal/park/remote.go`
+
+```diff
+--- a/internal/park/remote.go
++++ b/internal/park/remote.go
+@@ -65,6 +65,8 @@
+ // Authorize checks phase-specific stop evidence/fences and chore ownership.
+ // It must not manufacture stop evidence from a stopped parent alone.
+ Authorize func(context.Context, hostclaim.Observed, hostclaim.Obligation) error
++ // VerifyMovesSweep supplies the precondition for listing-time moves evidence.
++ VerifyMovesSweep func(context.Context, hostclaim.Observed, []lease.ObjectVersion) (MoveSweepProof, error)
+ // After is a test crash point, called after durable effects. Nil in callers.
+ After func(string) error
+ }
+```
+
+#### Patch: `internal/park/sweep.go`
+
+```diff
+--- a/internal/park/sweep.go
++++ b/internal/park/sweep.go
+@@ -242,7 +242,9 @@
+
+ // Sweep queues every decision in one claim CAS; Run is the common executor.
+ // EvidenceAt is genuine prior stop/fence evidence for the residue. A sweep
+-// must not substitute now for evidence, even on a free host.
++// uses listing-time evidence for moves/<host> only after VerifyMovesSweep
++// proves that the newest retired move was fenced or released before listing.
++// Other namespaces still require their original stop/fence evidence.
+ func (e Engine) Sweep(ctx context.Context, host, legacyIncarnation string, evidenceAt time.Time, requestLimit time.Duration) (Decision, error) {
+ return e.sweep(ctx, hostclaim.Observed{Host: host}, nil, legacyIncarnation, evidenceAt, requestLimit, false)
+ }
+@@ -286,7 +288,7 @@
+
+ func (e Engine) sweep(ctx context.Context, expected hostclaim.Observed, expectedVersions []lease.ObjectVersion, legacyIncarnation string, evidenceAt time.Time, requestLimit time.Duration, bound bool) (Decision, error) {
+ var empty Decision
+- if e.View == nil || e.NewID == nil || e.Authorize == nil {
++ if e.View == nil || e.NewID == nil || e.Authorize == nil || e.Now == nil {
+ return empty, ErrUnsafe
+ }
+ host := expected.Host
+@@ -301,7 +303,9 @@
+ if err != nil {
+ return empty, err
+ }
++ listingStarted := e.Now()
+ vs, err := e.Discover(ctx, host)
++ listedAt := e.Now()
+ if err != nil {
+ return empty, err
+ }
+@@ -311,6 +315,27 @@
+ d, err := Decide(obs, v, vs, legacyIncarnation)
+ if err != nil || len(d.Candidates) == 0 {
+ return d, err
++ }
++ var movesEvidence time.Time
++ for _, candidate := range d.Candidates {
++ if candidate.Location != "moves/"+host {
++ continue
++ }
++ if e.VerifyMovesSweep == nil || listedAt.Before(listingStarted) {
++ return d, ErrUnsafe
++ }
++ detached, copyErr := moveSweepObservation(obs)
++ if copyErr != nil {
++ return d, copyErr
++ }
++ proof, verifyErr := e.VerifyMovesSweep(ctx, detached, append([]lease.ObjectVersion(nil), vs...))
++ if verifyErr != nil {
++ return d, verifyErr
++ }
++ if err := proof.Check(obs, candidate.Incarnation, listingStarted); err != nil {
++ return d, err
++ }
++ movesEvidence = listedAt
+ }
+ var op string
+ if obs.Claim.Holder != nil {
+@@ -325,7 +350,11 @@
+ }
+ chores := make([]hostclaim.Obligation, 0, len(d.Candidates))
+ for _, x := range d.Candidates {
+- o := hostclaim.Obligation{ID: e.NewID(), Op: op, Kind: "park-replica", Region: e.Region, Generation: x.Generation, Retirement: e.NewID(), Owner: "region:" + e.Region, Park: &hostclaim.ParkScope{Location: x.Location, Incarnation: x.Incarnation, EvidenceAt: evidenceAt, RequestLimit: requestLimit}}
++ boundAt := evidenceAt
++ if x.Location == "moves/"+host {
++ boundAt = movesEvidence
++ }
++ o := hostclaim.Obligation{ID: e.NewID(), Op: op, Kind: "park-replica", Region: e.Region, Generation: x.Generation, Retirement: e.NewID(), Owner: "region:" + e.Region, Park: &hostclaim.ParkScope{Location: x.Location, Incarnation: x.Incarnation, EvidenceAt: boundAt, RequestLimit: requestLimit}}
+ if x.Generation != "" {
+ o.Kind = "park-generation"
+ }
+```
+
+### Task G: grant one bound recovery action inside RunRecovery
+
+Invariant: only the console's active `ControlWriter.RunRecovery` pass may mint a move-recovery grant. `Recovery.Moves func(context.Context, *MoveRecoveryGrant) error` receives that grant through the existing claim recovery worker. Discovery still uses `hostops.ListClaims`. A missing hook refuses move recovery, and move kinds cannot fall through to the generic five-minute stale-operation authority.
+
+The grant binds the claim ETag, move ID, incarnation, account/app identity, record ETag and `record_seq`. It exposes immutable observation values and closed typed methods, with no store or arbitrary mutation callback:
+
+```go
+func (g *MoveRecoveryGrant) Observation() ConsoleMoveObservation
+func (g *MoveRecoveryGrant) Record(context.Context, MoveRecoveryRecordAction) error
+func (g *MoveRecoveryGrant) Claim(context.Context, MoveRecoveryClaimAction) error
+func (g *MoveRecoveryGrant) ResolveStart(context.Context) (Outcome, error)
+func (g *MoveRecoveryGrant) Convert(context.Context, hostclaim.FenceRef) error
+```
+
+The allowed writes are the guarded completed/rolled-back/reverted/aborted record closes, landing-deadline rollback, record-backed move claim halves, the B8 settlement described below, and `ConvertMovingToRelocating` under a verified fence reference. No release/config/SQL/wake-reset/purge permit is returned. Cut, six-hour auto-abort and operator retry retain Task C's separate `ConsoleMove` contract. Unknown actions, including a standalone `target-parked` stamp, refuse without mutation. Each attempted write consumes its grant. Copies share private lifetime state; callback return revokes that captured state even if the caller overwrites the exported grant value. The callback receives no minting context, and retaining a `RunRecovery` context cannot create another grant after the pass returns.
+
+`Record` chooses the existing provision transition and uses `provision.WriteRecord` under the exact record CAS. Close requires the corresponding claim phase, its record completion stamps and no outstanding chore for that move. Deadline rollback uses the earlier non-nil cut/landing deadline, matching `landingDeadlineOf`, with the existing recorded rollback reason. `Claim` derives flip, commit, rollback and release from the authoritative record, not caller-provided executor or scope values. Commit uses the flip timestamp for the source-location park and the seal bound for staging. Existing B3/B4 refusals remain unchanged.
+
+`Convert` takes only the fence reference. The console assembly binds `Recovery.MoveFenceAuthority` to `hostfence.ClaimFenceAuthority`, so the hook cannot choose a permissive fence or placement verifier. It verifies signed-roster enrollment, source identity and one-box-region placement through the existing C7 contracts. One pass writes `MoveRelocatingDecision`; a fresh pass verifies that terminal decision and performs `hostclaim.ConvertMovingToRelocating`. The final conversion guard rechecks the exact grant record sequence/ETag after the fence and placement reads. It supplies no authority to execute relocation or clear its record.
+
+K3 adds durable evidence to B8's start effect before each possible record Put. `record_base_seq` records the highest attempted base, monotonically, while that exact effect is still `issued`. This is additive to the existing effect Inputs map and live-handle recorder; journal failure prevents the Put. Recording only the first read would be insufficient because `SetRecordMove` may retry from another CAS base.
+
+`ResolveStart` needs no original `StartHandle`. It rereads the exact record and start-effect journal. A matching move in the current record, `LastMove`, or the effect's applied result settles applied. Not-applied requires an `uncertain` effect, no matching move in the record or retained record result, no applied move result in the effect journal, and `record_seq` strictly greater than the durable highest attempted base. Equal/lower sequence, missing base, an issued effect or incomplete evidence remains uncertain. Effects written before this additive capture have no inferred base. Settlement goes through `EffectRunner.resolveUncertain`, its existing settle path. Claim release is a separate fresh grant, after the effect is durably complete/not-applied; absence alone never releases an orphan.
+
+Close/release ordering is a Plan 5 integration prerequisite. This grant closes the record first and releases the claim on a later pass from the matching `LastMove`, so a crash between the halves is replayable. The current box rollback/revert owners release first and close their own records afterward. They must finish those already-released closes under their existing authority; this task does not widen recovery to arbitrary serving claims. Before wiring the new recovery hook, Plan 5 must align competing close paths with record-first ordering or retain the existing owner's scoped completion for them. Likewise, Plan 5 retains responsibility for `StampLostMoveTargetParked` and its evidence before requesting a rolled-back close; this grant does not create a new general stamping action.
+
+The console wiring adds the typed hook to the existing `RunClaimRecoveryPass` body, not a new unguarded worker. With claims off, that worker and hook do nothing. Plan 5 implements the policy callback and selects at most one eligible action per observation; this plan supplies the authority and refusal boundary, not the cleanup policy itself.
+
+Apply tests first. The initial red identifies the missing typed API. Behavioral tests discard the start handle, retain equal/lower and missing-base uncertainty, preserve applied evidence, reject a delayed Put after sequence fencing, refuse another move/sequence, reject grants and contexts retained beyond their scope, and check close/release and two-pass C7 replay. The C7 sequence-only race must refuse even when the rest of the terminal record decision is unchanged.
+
+The inventory patches retain the census policy. `MoveRecoveryGrant.Record` and `Convert` call `provision.WriteRecord` directly after their shared private preparation checks. `moveStartRecorder.Put` remains a reviewed transport and captures durable evidence before forwarding its existing guarded write. The console census additionally selects the recovery entry, durable base capture, effect settlement and relocating conversion; the fixture includes their existing callers as well as the new ones.
+
+```bash
+python3 "$CLAIM10_SCRATCH/apply.py" "$CLAIM10_SCRATCH/plan.md" G tests
+flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go test -p 1 -timeout 3m -count=1 ./internal/hostops ./internal/console ./internal/provision -run 'TestMoveRecovery|TestPlan9B8|TestPlan9C2|TestJ17|TestClaimRuntime|TestClaimAuthoritySourceCensus|TestIngest|TestPreparedSQL'
+# After recording the expected red:
+python3 "$CLAIM10_SCRATCH/apply.py" "$CLAIM10_SCRATCH/plan.md" G implementation
+flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go test -p 1 -timeout 3m -count=1 ./internal/hostops ./internal/console ./internal/provision -run 'TestMoveRecovery|TestPlan9B8|TestPlan9C2|TestJ17|TestClaimRuntime|TestClaimAuthoritySourceCensus|TestIngest|TestPreparedSQL'
+flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go build -p 1 ./...
+```
+
+#### Patch: `internal/console/claim_authority_census_test.go`
+
+```diff
+--- a/internal/console/claim_authority_census_test.go
++++ b/internal/console/claim_authority_census_test.go
+@@ -25,6 +25,7 @@
+ "NewClamAVScanner": true, "NewRoute53Writer": true, "BeginExclusive": true,
+ "ResumeExclusive": true, "RegisterStart": true, "FinishWakeRetryReset": true, "ReservePurge": true,
+ "ConsoleMove": true, "InspectMoveLoss": true, "DeclareMoveLoss": true,
++ "RunRecovery": true, "runMoveRecovery": true, "captureMoveStartBase": true, "resolveUncertain": true, "ConvertMovingToRelocating": true,
+ "BeginPurge": true, "SavePurgeJournal": true, "RemovePurgeJournal": true,
+ "AcquireMailGate": true, "ReleaseMailGate": true, "EnrollNewApp": true,
+ "EnrollBackfillApp": true, "OpenGated": true, "WithAppMutation": true,
+```
+
+#### New file: `internal/hostops/move_recovery_grant_test.go`
+
+```go
+package hostops
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestMoveRecoveryStartDurableFence(t *testing.T) {
+ for _, landed := range []bool{false, true} {
+ t.Run(map[bool]string{false: "absent", true: "applied"}[landed], func(t *testing.T) {
+ r := newResolveFixture(t, landed)
+ ctx := context.Background()
+ if r.effect(t).Inputs["record_base_seq"] != "0" {
+ t.Fatal("no durable base")
+ }
+ // Discard the issuing handle: only the durable evidence survives.
+ r.h = nil
+ run := func() (Outcome, error) {
+ var out Outcome
+ err := r.w.RunRecovery(ctx, func(ctx context.Context) error {
+ return runMoveRecovery(ctx, r.moving, func(ctx context.Context, g *MoveRecoveryGrant) error {
+ var err error
+ out, err = g.ResolveStart(ctx)
+ return err
+ })
+ })
+ return out, err
+ }
+ out, err := run()
+ if landed {
+ if err != nil || out != OutcomeComplete {
+ t.Fatalf("applied=%s,%v", out, err)
+ }
+ return
+ }
+ if !errors.Is(err, ErrMoveStartFenceUnproven) || out != OutcomeUncertain {
+ t.Fatalf("equal base=%s,%v", out, err)
+ }
+ cur, err := ReadRecord(ctx, r.raw.Store, r.raw.recordKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := provision.WriteRecord(provision.WithRecordSequence(ctx), r.raw.Store, r.raw.recordKey, cur.Body, cur.Body, cur.ETag); err != nil {
+ t.Fatal(err)
+ }
+ out, err = run()
+ if err != nil || out != OutcomeNotApplied {
+ t.Fatalf("advanced=%s,%v", out, err)
+ }
+ if _, err := r.lost.Store.Put(ctx, r.raw.recordKey, r.lost.body, r.lost.etag); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("late put=%v", err)
+ }
+ })
+ }
+}
+
+func TestMoveRecoveryGrantCannotEscape(t *testing.T) {
+ r := newResolveFixture(t, false)
+ ctx := context.Background()
+ if err := runMoveRecovery(ctx, r.moving, func(context.Context, *MoveRecoveryGrant) error { return nil }); !errors.Is(err, ErrContract) {
+ t.Fatal(err)
+ }
+ var saved *MoveRecoveryGrant
+ if err := r.w.RunRecovery(ctx, func(ctx context.Context) error {
+ return runMoveRecovery(ctx, r.moving, func(_ context.Context, g *MoveRecoveryGrant) error { saved = g; return nil })
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := saved.ResolveStart(ctx); !errors.Is(err, ErrPending) {
+ t.Fatalf("escaped=%v", err)
+ }
+ if _, err := (&MoveRecoveryGrant{}).ResolveStart(ctx); !errors.Is(err, ErrPending) {
+ t.Fatalf("zero grant=%v", err)
+ }
+ if err := r.w.RunRecovery(ctx, func(ctx context.Context) error { return runMoveRecovery(ctx, r.moving, nil) }); !errors.Is(err, ErrContract) {
+ t.Fatal(err)
+ }
+}
+
+func TestMoveRecoveryRejectsChangedSequence(t *testing.T) {
+ r := newResolveFixture(t, false)
+ ctx := context.Background()
+ err := r.w.RunRecovery(ctx, func(ctx context.Context) error {
+ return runMoveRecovery(ctx, r.moving, func(ctx context.Context, g *MoveRecoveryGrant) error {
+ cur, err := ReadRecord(ctx, r.raw.Store, r.raw.recordKey)
+ if err != nil {
+ return err
+ }
+ if _, _, err := provision.WriteRecord(provision.WithRecordSequence(ctx), r.raw.Store, r.raw.recordKey, cur.Body, cur.Body, cur.ETag); err != nil {
+ return err
+ }
+ _, err = g.ResolveStart(ctx)
+ return err
+ })
+ })
+ if !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("changed sequence=%v", err)
+ }
+}
+
+func TestMoveRecoveryGenericGrantCannotRelease(t *testing.T) {
+ r := newResolveFixture(t, false)
+ ctx := context.Background()
+ err := r.w.RunRecovery(ctx, func(ctx context.Context) error {
+ _, err := hostclaim.Update(ctx, r.data, r.moving, hostclaim.ReleaseMove(moveStartID, "ie-box"))
+ return err
+ })
+ if err == nil {
+ t.Fatal("generic recovery released moving claim")
+ }
+}
+
+func TestMoveRecoveryConvertUsesFenceAndReplaysDecision(t *testing.T) {
+ w := newC7World(t, nil)
+ w.mh = nil
+ ctx := context.Background()
+ run := func(ref hostclaim.FenceRef) error {
+ return w.control.RunRecovery(ctx, func(ctx context.Context) error {
+ return runMoveRecovery(ctx, w.claim(t), func(ctx context.Context, g *MoveRecoveryGrant) error { return g.Convert(ctx, ref) }, w.authority)
+ })
+ }
+ bad := w.ref
+ bad.SHA256 = "wrong"
+ if err := run(bad); err == nil {
+ t.Fatal("bad fence accepted")
+ }
+ if err := run(w.ref); err != nil {
+ t.Fatal(err)
+ }
+ if w.claim(t).Claim.Op.Kind != hostclaim.KindMoving {
+ t.Fatal("decision pass converted claim")
+ }
+ rec, err := provision.GetRecord(ctx, w.raw, "bab", "app", w.host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec.Move.State != provision.MoveRelocating {
+ t.Fatal("decision missing")
+ }
+ if err := run(w.ref); err != nil {
+ t.Fatal(err)
+ }
+ c := w.claim(t).Claim
+ if c.Op.Kind != hostclaim.KindRelocating || c.Op.Phase != "pinning" || c.Op.Exec != "none" {
+ t.Fatalf("conversion=%+v", c.Op)
+ }
+ if err := run(w.ref); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestMoveRecoveryRecordCloseThenRelease(t *testing.T) {
+ w := newC7World(t, func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) {
+ c.Op.Kind, c.Op.Phase, c.Op.Exec = hostclaim.KindLanding, hostclaim.PhaseCommitted, "target"
+ c.Obligations = nil
+ r.Region = "uk"
+ r.Move.State = provision.MoveCommitted
+ now := r.CreatedAt
+ r.Move.CommittedAt = &now
+ r.Move.OpenedAt = &now
+ r.Move.SourceParkedAt = &now
+ })
+ ctx := context.Background()
+ run := func(action func(context.Context, *MoveRecoveryGrant) error) error {
+ return w.control.RunRecovery(ctx, func(ctx context.Context) error { return runMoveRecovery(ctx, w.claim(t), action) })
+ }
+ if err := run(func(ctx context.Context, g *MoveRecoveryGrant) error { return g.Record(ctx, RecoveryMoveClose) }); err != nil {
+ t.Fatal(err)
+ }
+ if err := run(func(ctx context.Context, g *MoveRecoveryGrant) error { return g.Claim(ctx, RecoveryMoveRelease) }); err != nil {
+ t.Fatal(err)
+ }
+ if w.claim(t).Claim.Op.Kind != hostclaim.KindServing {
+ t.Fatal("not released")
+ }
+}
+
+func TestMoveRecoveryStartRequiresDurableBaseAndHonorsAppliedJournal(t *testing.T) {
+ for _, marker := range []string{"missing-base", "applied-effect", "issued"} {
+ t.Run(marker, func(t *testing.T) {
+ r := newResolveFixture(t, false)
+ ctx := context.Background()
+ cur, err := ReadRecord(ctx, r.raw.Store, r.raw.recordKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := provision.WriteRecord(provision.WithRecordSequence(ctx), r.raw.Store, r.raw.recordKey, cur.Body, cur.Body, cur.ETag); err != nil {
+ t.Fatal(err)
+ }
+ _, err = r.w.retryAdmission(ctx, "bab", "app", "canonical", AdmissionObserved{}, func(a *AppAdmission) error {
+ e := a.Effects[moveStartID]
+ switch marker {
+ case "missing-base":
+ delete(e.Inputs, "record_base_seq")
+ case "applied-effect":
+ e.ResultReference = moveStartID
+ case "issued":
+ e.State = "issued"
+ }
+ a.Effects[moveStartID] = e
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out Outcome
+ err = r.w.RunRecovery(ctx, func(ctx context.Context) error {
+ return runMoveRecovery(ctx, r.moving, func(ctx context.Context, g *MoveRecoveryGrant) error {
+ var err error
+ out, err = g.ResolveStart(ctx)
+ return err
+ })
+ })
+ switch marker {
+ case "missing-base":
+ if !errors.Is(err, ErrMoveStartFenceUnproven) {
+ t.Fatal(err)
+ }
+ case "applied-effect":
+ if err != nil || out != OutcomeComplete {
+ t.Fatalf("%s,%v", out, err)
+ }
+ case "issued":
+ if !errors.Is(err, ErrPending) {
+ t.Fatal(err)
+ }
+ }
+ })
+ }
+}
+
+func TestMoveRecoveryContextCannotMintAfterRunReturns(t *testing.T) {
+ r := newResolveFixture(t, false)
+ var saved context.Context
+ if err := r.w.RunRecovery(context.Background(), func(ctx context.Context) error { saved = ctx; return nil }); err != nil {
+ t.Fatal(err)
+ }
+ if err := runMoveRecovery(saved, r.moving, func(context.Context, *MoveRecoveryGrant) error { return nil }); !errors.Is(err, ErrContract) {
+ t.Fatalf("escaped recovery context=%v", err)
+ }
+}
+
+func TestMoveRecoveryCloseWaitsForObligations(t *testing.T) {
+ w := newC7World(t, func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) {
+ c.Op.Kind, c.Op.Phase, c.Op.Exec = hostclaim.KindLanding, hostclaim.PhaseCommitted, "target"
+ r.Region = "uk"
+ r.Move.State = provision.MoveCommitted
+ now := r.CreatedAt
+ r.Move.CommittedAt = &now
+ r.Move.OpenedAt = &now
+ r.Move.SourceParkedAt = &now
+ })
+ err := w.control.RunRecovery(context.Background(), func(ctx context.Context) error {
+ return runMoveRecovery(ctx, w.claim(t), func(ctx context.Context, g *MoveRecoveryGrant) error { return g.Record(ctx, RecoveryMoveClose) })
+ })
+ if !errors.Is(err, ErrPending) {
+ t.Fatalf("outstanding parks=%v", err)
+ }
+}
+
+func TestMoveRecoveryGrantValueCopyExpires(t *testing.T) {
+ r := newResolveFixture(t, false)
+ ctx := context.Background()
+ var saved MoveRecoveryGrant
+ if err := r.w.RunRecovery(ctx, func(ctx context.Context) error {
+ return runMoveRecovery(ctx, r.moving, func(_ context.Context, g *MoveRecoveryGrant) error { saved = *g; return nil })
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := saved.ResolveStart(ctx); !errors.Is(err, ErrPending) {
+ t.Fatalf("copied grant=%v", err)
+ }
+}
+
+func TestMoveRecoveryHookCannotRemint(t *testing.T) {
+ r := newResolveFixture(t, false)
+ err := r.w.RunRecovery(context.Background(), func(ctx context.Context) error {
+ return runMoveRecovery(ctx, r.moving, func(ctx context.Context, g *MoveRecoveryGrant) error {
+ if err := runMoveRecovery(ctx, r.moving, func(context.Context, *MoveRecoveryGrant) error { return nil }); !errors.Is(err, ErrContract) {
+ t.Fatalf("hook reminted=%v", err)
+ }
+ return nil
+ })
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestMoveRecoveryRejectsTargetParkedAction(t *testing.T) {
+ w := newJ17World(t, nil)
+ ctx := context.Background()
+ before, tag, err := w.raw.Get(ctx, w.key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = w.control.RunRecovery(ctx, func(ctx context.Context) error {
+ return runMoveRecovery(ctx, w.claim(t), func(ctx context.Context, g *MoveRecoveryGrant) error {
+ return g.Record(ctx, MoveRecoveryRecordAction("target-parked"))
+ })
+ })
+ if !errors.Is(err, ErrIdentity) {
+ t.Fatalf("unrecognized action=%v", err)
+ }
+ after, afterTag, err := w.raw.Get(ctx, w.key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(before) != string(after) || tag != afterTag {
+ t.Fatal("unrecognized action changed record")
+ }
+}
+
+func TestMoveRecoveryGrantOverwriteCannotRedirectRevocation(t *testing.T) {
+ r := newResolveFixture(t, false)
+ err := r.w.RunRecovery(context.Background(), func(ctx context.Context) error {
+ var saved MoveRecoveryGrant
+ if err := runMoveRecovery(ctx, r.moving, func(_ context.Context, g *MoveRecoveryGrant) error { saved = *g; *g = MoveRecoveryGrant{}; return nil }); err != nil {
+ return err
+ }
+ if _, err := saved.ResolveStart(ctx); !errors.Is(err, ErrPending) {
+ t.Fatalf("overwritten callback grant=%v", err)
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+type moveRecoveryBumpAuthority struct {
+ c7ReplayAuthority
+ bump func()
+}
+
+func (a *moveRecoveryBumpAuthority) CheckOneBoxRegion(ctx context.Context, source, region string) error {
+ if a.bump != nil {
+ f := a.bump
+ a.bump = nil
+ f()
+ }
+ return a.c7ReplayAuthority.CheckOneBoxRegion(ctx, source, region)
+}
+
+func TestMoveRecoveryConvertRefusesSequenceBumpDuringFenceCheck(t *testing.T) {
+ w := newC7World(t, nil)
+ ctx := context.Background()
+ run := func(a hostclaim.FenceAuthority) error {
+ return w.control.RunRecovery(ctx, func(ctx context.Context) error {
+ return runMoveRecovery(ctx, w.claim(t), func(ctx context.Context, g *MoveRecoveryGrant) error { return g.Convert(ctx, w.ref) }, a)
+ })
+ }
+ if err := run(w.authority); err != nil {
+ t.Fatal(err)
+ }
+ authority := &moveRecoveryBumpAuthority{c7ReplayAuthority: w.authority, bump: func() {
+ cur, err := ReadRecord(ctx, w.raw, w.key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := provision.WriteRecord(provision.WithRecordSequence(ctx), w.raw, w.key, cur.Body, cur.Body, cur.ETag); err != nil {
+ t.Fatal(err)
+ }
+ }}
+ if err := run(authority); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("sequence changed during verification=%v", err)
+ }
+ if w.claim(t).Claim.Op.Kind != hostclaim.KindMoving {
+ t.Fatal("converted at stale record sequence")
+ }
+}
+
+func TestMoveRecoveryPassUsesTypedHookOnlyInsideRunRecovery(t *testing.T) {
+ f := newResolveFixture(t, true)
+ noWork := func(context.Context) error { return nil }
+ wrongKind := func(context.Context, hostclaim.Observed) error { t.Fatal("move reached generic recovery"); return nil }
+ calls := 0
+ r := Recovery{Controller: Controller{Store: f.raw, Control: f.w, Authority: &f.authority, Now: func() time.Time { return f.now }, NewID: func() string { return "recovery" }}, Purge: wrongKind, Placement: wrongKind, SweepRecords: noWork, SweepPurges: noWork, SweepAdmissions: noWork}
+ r.Moves = func(ctx context.Context, g *MoveRecoveryGrant) error {
+ calls++
+ if g.Observation().OpID != f.moving.Claim.Op.ID {
+ t.Fatal("wrong move grant")
+ }
+ outcome, err := g.ResolveStart(ctx)
+ if err != nil || outcome != OutcomeComplete {
+ t.Fatalf("settle=%s,%v", outcome, err)
+ }
+ return nil
+ }
+ if err := r.Pass(t.Context()); !errors.Is(err, ErrContract) || calls != 0 {
+ t.Fatalf("outside recovery: %v calls=%d", err, calls)
+ }
+ missing := r
+ missing.Moves = nil
+ if err := f.w.RunRecovery(t.Context(), missing.Pass); !errors.Is(err, ErrContract) {
+ t.Fatalf("missing hook: %v", err)
+ }
+ var saved context.Context
+ if err := f.w.RunRecovery(t.Context(), func(ctx context.Context) error { saved = ctx; return r.Pass(ctx) }); err != nil {
+ t.Fatal(err)
+ }
+ if calls != 1 {
+ t.Fatalf("calls=%d", calls)
+ }
+ if err := r.Pass(saved); !errors.Is(err, ErrContract) || calls != 1 {
+ t.Fatalf("retained pass context: %v calls=%d", err, calls)
+ }
+}
+```
+
+#### Patch: `internal/provision/plan9_b8_census_test.go`
+
+```diff
+--- a/internal/provision/plan9_b8_census_test.go
++++ b/internal/provision/plan9_b8_census_test.go
+@@ -472,6 +472,8 @@
+
+ // BEGIN REVIEWED INVENTORY
+ var plan9B8CensusInventory = []censusEntry{
++ {"internal/hostops/move_recovery_grant.go", "MoveRecoveryGrant.Convert", "record-writer", "key:WriteRecord", "794963a24a0a50d980505f83ac912973a10267b21f74bd41c56584ab9b0b25a1"},
++ {"internal/hostops/move_recovery_grant.go", "MoveRecoveryGrant.Record", "record-writer", "key:WriteRecord", "6b8bd4131842d8f22a6ab55aaae736329df40e34abb348d3d68cbd0a713503ad"},
+ // Plan 10: reviewed typed console move and loss writers.
+ {"internal/hostops/console_move_grant.go", "ControlWriter.ConsoleMove", "record-writer", "key:WriteRecord", "21e7a6ddc4dbe5b3bf06e65b39a228e2eb588af1edc43beb09d91dd72570dd44"},
+ {"internal/hostops/console_move_grant.go", "ControlWriter.consoleMoveClaim", "key-site", "key:RecordKey", "265291bd356de061663d89e5ebe33b74ea32c086f8238736d6f971a5afcab722"},
+@@ -706,7 +708,7 @@
+ {"internal/hostops/mail_gate.go", "ControlWriter.ReleaseMailGate", "nonrecord", "sel:Put", "aea8bc583a0a28cd61e8c81c6d466b02e4fb6a086ba921f01fdd7dfdb286191c"},
+ {"internal/hostops/mail_gate.go", "ControlWriter.RecordMailGateMember", "nonrecord", "sel:Put", "ed87514c0cfd15664765deb5aa0051c37b14ecebf0ba1a77459da4f8b7e7465f"},
+ {"internal/hostops/mail_reconcile.go", "ControlWriter.ReconcileMailGate", "nonrecord", "sel:Put", "6f9d68daf39704885ada14b7dc9f3e8e5a0424455489aed45fc7b166bdb8e726"},
+- {"internal/hostops/move_start_resolve.go", "moveStartRecorder.Put", "record-transport", "def:Put,sel:Put", "33b119350809eea087f21e124f541549c63b4bd51cb44d5e04295ac55e1e3f65"},
++ {"internal/hostops/move_start_resolve.go", "moveStartRecorder.Put", "record-transport", "def:Put,sel:Put", "a8c7bd673b4af12624ce0f12198c51eef1fc1dc658e9aec7e45bfc2f72124b95"},
+ {"internal/hostops/move_start_resolve.go", "decideMoveStart", "record-writer", "key:WriteRecord", "11a22d2cfd9cc4efb6afc3417e034fa881c1ff1491fba11a4dd04dfb3d9dcdea"},
+ {"internal/hostops/mutation_boundary.go", "mutationBoundary.Put", "record-transport", "def:Put,sel:Put,str:Put", "c4222199cdbfa8cce1f97f91bf41fe94857f4d833e30b17636c40f1aae39ff67"},
+ {"internal/hostops/mutation_boundary_putfrom.go", "mutationBoundary.PutFrom", "record-transport", "def:PutFrom,sel:Put,sel:PutObjectFrom,str:Put", "8d015a924901f0c41e142181f986cb9d2a3bdfa6a9e078dfda05a2d1e7cf9b24"},
+```
+
+#### Patch: `internal/console/claim_runtime.go`
+
+```diff
+--- a/internal/console/claim_runtime.go
++++ b/internal/console/claim_runtime.go
+@@ -10,6 +10,7 @@
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostclaim"
++ "github.com/carlosframework/platform/internal/hostfence"
+ "github.com/carlosframework/platform/internal/hostops"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+@@ -109,8 +110,10 @@
+ s.claimOwner = owner
+ s.claimRuntimeConfigured = true
+ s.cfg.HostClaimRecovery = &hostops.Recovery{
+- Controller: c,
+- SweepRecords: func(ctx context.Context) error { return c.SweepAllRecords(ctx) },
++ Moves: s.cfg.ClaimMoveRecovery,
++ MoveFenceAuthority: hostfence.ClaimFenceAuthority{Directory: a.Directory, Now: c.Now},
++ Controller: c,
++ SweepRecords: func(ctx context.Context) error { return c.SweepAllRecords(ctx) },
+ SweepPurges: func(ctx context.Context) error {
+ scanErr := hostops.SweepAllPurges(ctx, c.Store, func(ctx context.Context, app hostops.App, p hostops.Purge) error {
+ adm, err := hostops.ReadCanonicalAdmission(ctx, c.Store, app.Account, app.Name)
+```
+
+#### Patch: `internal/console/server.go`
+
+```diff
+--- a/internal/console/server.go
++++ b/internal/console/server.go
+@@ -472,6 +472,7 @@
+ type Config struct {
+ HostClaimOps *hostops.Controller
+ HostClaimRecovery *hostops.Recovery
++ ClaimMoveRecovery func(context.Context, *hostops.MoveRecoveryGrant) error
+ ClaimAuthority *hostops.ProductionAuthority
+ ClaimPurge *ClaimPurgeCoordinator
+ ClaimPlacement *ClaimPlacementCoordinator
+```
+
+#### Patch: `internal/console/testdata/claim-authority-sites.txt`
+
+```diff
+--- a/internal/console/testdata/claim-authority-sites.txt
++++ b/internal/console/testdata/claim-authority-sites.txt
+@@ -9,9 +9,15 @@
+ ../hostops/ingest.go|RecoverSQLTransaction|local.FenceSQL(ctx, sc)
+ ../hostops/ingest.go|RecoverSQLTransaction|o.FenceSQLOutcome(ctx, sc)
+ ../hostops/ingest.go|RunSQLIngest|b.control.RegisterIngest(ctx, account, app, appID, id, owner)
++../hostops/lifecycle_grant.go|RecoveryPass|w.RunRecovery(ctx, r.Pass)
+ ../hostops/move_enrollment.go|RegisterMove|a.Control.RegisterStart(ctx, h.Account, h.App, appID, id, reg, a.Now())
++../hostops/move_enrollment.go|StartClaimMoveRecord|h.captureMoveStartBase(ctx, seq)
+ ../hostops/move_loss_authority.go|DeclareMoveLoss|a.Control.DeclareMoveLoss(ctx, a.Signer, a.Directory.Bucket, actor, account, app, host, box, in)
+ ../hostops/move_loss_authority.go|InspectMoveLoss|a.Control.InspectMoveLoss(ctx, a.Signer, a.Directory.Bucket, actor, account, app, host, box)
++../hostops/move_recovery_bridge.go|RecoverTerminatedMovingHost|hostclaim.ConvertMovingToRelocating(mh.Lifecycle(ctx), s, fresh, c.Op.ID, ref, authority, guard)
++../hostops/move_recovery_grant.go|Convert|hostclaim.ConvertMovingToRelocating(ctx, g.w.raw, o, g.o.OpID, ref, authority, guard)
++../hostops/move_recovery_grant.go|ResolveStart|runner.resolveUncertain(ctx, e, result)
++../hostops/move_start_resolve.go|ResolveMoveStart|h.Runner(h.w.now).resolveUncertain(ctx, e, result)
+ ../hostops/mutation_boundary.go|NewMutationBoundary|NewObservedMutationBoundary(enabled, raw, check, nil)
+ ../hostops/production_held_start.go|startAppLifecycle|BeginPurge(id, by, a.Now(), regions, boxes)
+ ../hostops/purge_control.go|DrainPurgeRegistrations|w.SavePurgeJournal(ctx, j, tag)
+@@ -23,6 +29,7 @@
+ ../hostops/purge_journal.go|DeletePurgeSweep|w.SavePurgeJournal(ctx, fresh, tag)
+ ../hostops/purge_journal.go|PreparePurgeSweep|w.SavePurgeJournal(ctx, j, tag)
+ ../hostops/purge_journal.go|VerifyPurgeSweep|w.SavePurgeJournal(ctx, j, tag)
++../hostops/recovery.go|Pass|runMoveRecovery(ctx, obs, r.Moves, r.MoveFenceAuthority)
+ ../hostops/sidecarwake.go|TouchSidecarWake|a.Control.RegisterStart(ctx, account, app, appID, id, reg, a.Now())
+ ../mail/aws.go|NewAWSProvisioner|iam.NewFromConfig(cfg)
+ ../mail/aws.go|NewAWSProvisioner|sesv2.NewFromConfig(cfg)
+@@ -85,6 +92,7 @@
+ claim_sql_receipts.go|finishPreparedSQL|s.FenceSQL(proofCtx, p.Receipt())
+ economicspass.go|sizeProvisionedStore|objstore.S3ClientFor(ctx, p.Region, p.Endpoint)
+ geoippublish.go|Commit|os.Open(st.path)
++hostclaimbackfill.go|RunClaimRecoveryPass|a.Control.RunRecovery(ctx, s.runClaimRecoveryPassBody)
+ hostwakereset.go|registerWakeRetryReset|a.Control.RegisterStart(ctx, h.Account, h.App, reg.AppID, id, reg, registeredAt)
+ hostwakereset.go|registerWakeRetryReset|start.FinishWakeRetryReset(cleanup)
+ scanpass.go|storeLister|objstore.S3ClientFor(ctx, p.Region, p.Endpoint)
+```
+
+#### Patch: `internal/hostops/lifecycle_grant.go`
+
+```diff
+--- a/internal/hostops/lifecycle_grant.go
++++ b/internal/hostops/lifecycle_grant.go
+@@ -102,6 +102,10 @@
+ if body == nil {
+ return ErrContract
+ }
++ scope := &moveRecoveryPassScope{}
++ scope.live.Store(true)
++ defer scope.live.Store(false)
++ ctx = context.WithValue(ctx, moveRecoveryPassKey{}, scope)
+ return body(withLifecycleGrant(ctx, lifecycleGrant{kind: "recovery", w: w}))
+ }
+
+@@ -252,6 +256,9 @@
+ func recoveryGrantAllows(ctx context.Context, raw release.Store, w *ControlWriter, host string, old, next *hostclaim.Claim) (bool, error) {
+ oh, nh := holderOf(old), holderOf(next)
+ if old != nil {
++ if moveRecoveryKind(old.Op.Kind) {
++ return false, nil
++ }
+ op := old.Op
+ stale := op.Kind == hostclaim.KindParking && len(old.Obligations) == 0 ||
+ op.Kind != "" && op.Kind != hostclaim.KindServing && !op.StartedAt.IsZero() && w.now().After(op.StartedAt.Add(5*time.Minute))
+```
+
+#### Patch: `internal/hostops/move_enrollment.go`
+
+```diff
+--- a/internal/hostops/move_enrollment.go
++++ b/internal/hostops/move_enrollment.go
+@@ -145,7 +145,7 @@
+ // carry, so ResolveMoveStart can fence one whose answer was lost.
+ puts := &moveStartPuts{id: p.ID, incarnation: p.Incarnation, key: c.Holder.Key, holder: *c.Holder}
+ h.moveStart.puts.Store(puts)
+- rec, callErr = setLifecycleRecordMove(ctx, moveStartRecorder{s: s, puts: puts}, moving, provision.MoveStartTransition(p))
++ rec, callErr = setLifecycleRecordMove(ctx, moveStartRecorder{s: s, puts: puts, capture: func(ctx context.Context, seq uint64) error { return h.captureMoveStartBase(ctx, seq) }}, moving, provision.MoveStartTransition(p))
+ if callErr != nil {
+ return "", callErr
+ }
+```
+
+#### New file: `internal/hostops/move_recovery_grant.go`
+
+```go
+package hostops
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// MoveRecoveryGrant is one callback's authority over an exact move and record.
+// It exposes no store or arbitrary transition. Mutations consume the observation;
+// another action requires another pass. The grant expires when the hook returns.
+// Plan 5 must align its owners to close the record before releasing the claim
+// before wiring this hook. Existing release-first owners must finish their own
+// closes: a serving claim with a live move record cannot acquire this grant.
+type MoveRecoveryGrant struct {
+ state *moveRecoveryGrantState
+ w *ControlWriter
+ o ConsoleMoveObservation
+ authority hostclaim.FenceAuthority
+ scope *moveRecoveryPassScope
+}
+
+type moveRecoveryGrantState struct {
+ mu sync.Mutex
+ active bool
+}
+
+func moveRecoveryKind(kind string) bool {
+ switch kind {
+ case hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindRestoring, hostclaim.KindReverting, hostclaim.KindRelocating:
+ return true
+ }
+ return false
+}
+
+type moveRecoveryPassKey struct{}
+type moveRecoveryPassScope struct{ live atomic.Bool }
+
+func recoveryWriter(ctx context.Context) *ControlWriter {
+ scope, _ := ctx.Value(moveRecoveryPassKey{}).(*moveRecoveryPassScope)
+ if scope == nil || !scope.live.Load() {
+ return nil
+ }
+ grants, _ := ctx.Value(lifecycleGrantKey{}).([]lifecycleGrant)
+ for _, g := range grants {
+ if g.kind == "recovery" && g.w != nil {
+ return g.w
+ }
+ }
+ return nil
+}
+
+func runMoveRecovery(ctx context.Context, obs hostclaim.Observed, hook func(context.Context, *MoveRecoveryGrant) error, authorities ...hostclaim.FenceAuthority) error {
+ w := recoveryWriter(ctx)
+ if w == nil || hook == nil {
+ return ErrContract
+ }
+ if obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil || !moveRecoveryKind(obs.Claim.Op.Kind) {
+ return ErrIdentity
+ }
+ h, c := obs.Claim.Holder, obs.Claim
+ if err := moveAdmission(ctx, w.raw, obs.Host, c); err != nil {
+ return err
+ }
+ r, err := ReadRecord(ctx, w.raw, h.Key)
+ if err != nil {
+ return err
+ }
+ if !matches(r, h) {
+ return ErrIdentity
+ }
+ _, seq, err := recordSequence(r.Body)
+ if err != nil {
+ return err
+ }
+ scope, _ := ctx.Value(moveRecoveryPassKey{}).(*moveRecoveryPassScope)
+ var authority hostclaim.FenceAuthority
+ if len(authorities) == 1 {
+ authority = authorities[0]
+ }
+ g := &MoveRecoveryGrant{state: &moveRecoveryGrantState{active: true}, w: w, scope: scope, authority: authority, o: ConsoleMoveObservation{Account: h.Account, App: h.App, AppID: c.Op.Intent["app_id"], Host: obs.Host, Incarnation: h.Incarnation, OpID: c.Op.ID, ClaimETag: obs.ETag, RecordETag: r.ETag, RecordSeq: seq}}
+ state := g.state
+ defer func() { state.mu.Lock(); state.active = false; state.mu.Unlock() }()
+ // The owner receives only this capability, never the minting context.
+ ctx = context.WithValue(ctx, lifecycleGrantKey{}, []lifecycleGrant(nil))
+ ctx = context.WithValue(ctx, moveRecoveryPassKey{}, (*moveRecoveryPassScope)(nil))
+ return hook(ctx, g)
+}
+
+func (g *MoveRecoveryGrant) current(ctx context.Context) (hostclaim.Observed, Record, provision.Record, error) {
+ if g == nil || g.state == nil || !g.state.active || g.w == nil || g.scope == nil || !g.scope.live.Load() {
+ return hostclaim.Observed{}, Record{}, provision.Record{}, ErrPending
+ }
+ o, err := g.w.consoleMoveClaim(ctx, g.o)
+ if err != nil {
+ return o, Record{}, provision.Record{}, err
+ }
+ r, err := ReadRecord(ctx, g.w.raw, o.Claim.Holder.Key)
+ if err != nil {
+ return o, r, provision.Record{}, err
+ }
+ _, seq, err := recordSequence(r.Body)
+ if err != nil {
+ return o, r, provision.Record{}, err
+ }
+ if r.ETag != g.o.RecordETag || seq != g.o.RecordSeq {
+ return o, r, provision.Record{}, release.ErrCASConflict
+ }
+ if !matches(r, o.Claim.Holder) {
+ return o, r, provision.Record{}, ErrIdentity
+ }
+ rec, err := provision.DecodeRecord(r.Body)
+ return o, r, rec, err
+}
+
+// Observation contains values, never authority.
+func (g *MoveRecoveryGrant) Observation() ConsoleMoveObservation { return g.o }
+
+type MoveRecoveryRecordAction string
+
+const (
+ RecoveryMoveClose MoveRecoveryRecordAction = "close"
+ RecoveryMoveRolledBackClose MoveRecoveryRecordAction = "rolled-back-close"
+ RecoveryMoveRevertClose MoveRecoveryRecordAction = "revert-close"
+ RecoveryMoveAbortComplete MoveRecoveryRecordAction = "abort-complete"
+ RecoveryMoveDeadlineRollback MoveRecoveryRecordAction = "deadline-rollback"
+)
+
+func (g *MoveRecoveryGrant) Record(ctx context.Context, action MoveRecoveryRecordAction) error {
+ if g == nil || g.state == nil {
+ return ErrPending
+ }
+ g.state.mu.Lock()
+ defer g.state.mu.Unlock()
+ o, r, rec, err := g.current(ctx)
+ if err != nil {
+ return err
+ }
+ guard := provision.MoveGuard{ID: g.o.OpID, Incarnation: g.o.Incarnation}
+ if rec.Move == nil || rec.Move.ID != guard.ID || rec.Move.Incarnation != guard.Incarnation {
+ return ErrIdentity
+ }
+ switch action {
+ case RecoveryMoveClose, RecoveryMoveRolledBackClose, RecoveryMoveRevertClose, RecoveryMoveAbortComplete:
+ for _, chore := range o.Claim.Obligations {
+ if chore.Op == g.o.OpID {
+ return ErrPending
+ }
+ }
+ }
+ now := g.w.now().UTC()
+ var mutate func(*provision.Record) error
+ switch action {
+ case RecoveryMoveClose:
+ if o.Claim.Op.Kind != hostclaim.KindLanding || o.Claim.Op.Phase != hostclaim.PhaseCommitted {
+ return ErrPending
+ }
+ mutate = provision.MoveClose(guard, now)
+ case RecoveryMoveRolledBackClose:
+ if o.Claim.Op.Kind != hostclaim.KindRestoring || o.Claim.Op.Phase != hostclaim.PhaseOpened {
+ return ErrPending
+ }
+ mutate = provision.MoveRolledBackClose(guard, now)
+ case RecoveryMoveRevertClose:
+ if o.Claim.Op.Kind != hostclaim.KindReverting || o.Claim.Op.Phase != hostclaim.PhaseOpened {
+ return ErrPending
+ }
+ mutate = provision.MoveRevertClose(guard, now)
+ case RecoveryMoveAbortComplete:
+ if o.Claim.Op.Kind != hostclaim.KindMoving || o.Claim.Op.Phase != hostclaim.PhaseAborting || len(o.Claim.Obligations) != 0 {
+ return ErrPending
+ }
+ mutate = provision.MoveGuardedAbortComplete(guard, now)
+ case RecoveryMoveDeadlineRollback:
+ if o.Claim.Op.Kind != hostclaim.KindLanding && !(o.Claim.Op.Kind == hostclaim.KindMoving && o.Claim.Op.Phase == hostclaim.PhaseSealed) {
+ return ErrPending
+ }
+ deadline := rec.Move.CutDeadline
+ if d := rec.Move.LandingDeadline; d != nil && (deadline == nil || d.Before(*deadline)) {
+ deadline = d
+ }
+ if deadline == nil || !now.After(*deadline) {
+ return ErrPending
+ }
+ id, err := moves.NewRollbackID(rand.Reader)
+ if err != nil {
+ return err
+ }
+ mutate = provision.MoveRollBack(guard, id, provision.RollbackLandingDeadline, now)
+ default:
+ return ErrIdentity
+ }
+ if err := mutate(&rec); err != nil {
+ return err
+ }
+ body, err := g.prepareRecord(ctx, rec)
+ if err != nil {
+ return err
+ }
+ _, _, err = provision.WriteRecord(provision.WithRecordSequence(ctx), g.w.raw, r.Holder.Key, r.Body, body, r.ETag)
+ return err
+}
+
+func (g *MoveRecoveryGrant) prepareRecord(ctx context.Context, rec provision.Record) ([]byte, error) {
+ if rec.Move != nil {
+ rec.Move.UpdatedAt = g.w.now().UTC()
+ }
+ body, err := json.Marshal(rec)
+ if err != nil {
+ return nil, err
+ }
+ if err := CheckCreateFreeze(ctx, g.w.raw, g.w.pub); err != nil {
+ return nil, err
+ }
+ if _, _, _, err := g.current(ctx); err != nil {
+ return nil, err
+ }
+ g.state.active = false
+ return body, nil
+}
+
+// captureMoveStartBase records the highest attempted CAS base before forwarding
+// that Put. A lost journal answer never permits the Put. Only the issuing effect
+// can extend this proof; an uncertain effect can never acquire another base.
+func (h *StartHandle) captureMoveStartBase(ctx context.Context, seq uint64) error {
+ _, err := h.w.retryAdmission(ctx, h.account, h.app, h.appID, AdmissionObserved{}, func(a *AppAdmission) error {
+ e, ok := a.Effects[h.id]
+ if !ok || e.State != "issued" || e.Action != moveStartAction || e.Owner != h.owner || e.OperationID != h.id || e.AppID != h.appID {
+ return ErrPending
+ }
+ if old, ok := e.Inputs["record_base_seq"]; ok {
+ n, err := strconv.ParseUint(old, 10, 64)
+ if err != nil {
+ return ErrIdentity
+ }
+ if n >= seq {
+ return nil
+ }
+ }
+ if e.Inputs == nil {
+ e.Inputs = map[string]string{}
+ }
+ e.Inputs["record_base_seq"] = strconv.FormatUint(seq, 10)
+ a.Effects[h.id] = e
+ return nil
+ })
+ return err
+}
+
+// ResolveStart settles only an uncertain start. Unlike the live-handle path it
+// never manufactures a fence: absence proves failure only above the durable base.
+func (g *MoveRecoveryGrant) ResolveStart(ctx context.Context) (Outcome, error) {
+ if g == nil || g.state == nil {
+ return OutcomeUncertain, ErrPending
+ }
+ g.state.mu.Lock()
+ defer g.state.mu.Unlock()
+ o, _, rec, err := g.current(ctx)
+ if err != nil {
+ return OutcomeUncertain, err
+ }
+ if o.Claim.Op.Kind != hostclaim.KindMoving {
+ return OutcomeUncertain, ErrIdentity
+ }
+ a, err := g.w.read(ctx, g.o.Account, g.o.App, g.o.AppID)
+ if err != nil {
+ return OutcomeUncertain, err
+ }
+ e, ok := a.Value.Effects[g.o.OpID]
+ if !ok || e.ID != g.o.OpID || e.Action != moveStartAction || e.OperationID != g.o.OpID || e.AppID != g.o.AppID || e.Resource != o.Claim.Holder.Key || e.Inputs["move_id"] != g.o.OpID || e.Inputs["incarnation"] != g.o.Incarnation {
+ return OutcomeUncertain, ErrIdentity
+ }
+ if e.State == "complete" {
+ return settledMoveStart(e, g.o.OpID)
+ }
+ if e.State != "uncertain" {
+ return OutcomeUncertain, ErrPending
+ }
+ result := ""
+ if rec.Move != nil && rec.Move.ID == g.o.OpID && rec.Move.Incarnation == g.o.Incarnation || rec.LastMove != nil && rec.LastMove.ID == g.o.OpID || e.ResultReference == g.o.OpID {
+ result = g.o.OpID
+ }
+ if result == "" {
+ base, err := strconv.ParseUint(e.Inputs["record_base_seq"], 10, 64)
+ if err != nil || g.o.RecordSeq <= base {
+ return OutcomeUncertain, ErrMoveStartFenceUnproven
+ }
+ if e.ResultReference != "" && e.ResultReference != "not-applied" {
+ return OutcomeUncertain, ErrIdentity
+ }
+ result = "not-applied"
+ }
+ if _, _, _, err := g.current(ctx); err != nil {
+ return OutcomeUncertain, err
+ }
+ runner := EffectRunner{w: g.w, account: g.o.Account, app: g.o.App, appID: g.o.AppID, operation: g.o.OpID, owner: e.Owner, now: g.w.now}
+ g.state.active = false
+ if err := runner.resolveUncertain(ctx, e, result); err != nil {
+ if errors.Is(err, errSettledElsewhere) {
+ a, rerr := g.w.read(ctx, g.o.Account, g.o.App, g.o.AppID)
+ if rerr == nil {
+ return settledMoveStart(a.Value.Effects[g.o.OpID], g.o.OpID)
+ }
+ }
+ return OutcomeUncertain, err
+ }
+ if result == g.o.OpID {
+ return OutcomeComplete, nil
+ }
+ return OutcomeNotApplied, nil
+}
+
+type MoveRecoveryClaimAction string
+
+const (
+ RecoveryMoveFlip MoveRecoveryClaimAction = "flip"
+ RecoveryMoveCommit MoveRecoveryClaimAction = "commit"
+ RecoveryMoveRollback MoveRecoveryClaimAction = "rollback"
+ RecoveryMoveRelease MoveRecoveryClaimAction = "release"
+ RecoveryMoveReleaseNotApplied MoveRecoveryClaimAction = "release-not-applied"
+)
+
+// Claim catches up a claim half from its authoritative record. No caller
+// supplies executor identity, scopes, generation, or an arbitrary transition.
+func (g *MoveRecoveryGrant) Claim(ctx context.Context, action MoveRecoveryClaimAction) error {
+ if g == nil || g.state == nil {
+ return ErrPending
+ }
+ g.state.mu.Lock()
+ defer g.state.mu.Unlock()
+ o, _, rec, err := g.current(ctx)
+ if err != nil {
+ return err
+ }
+ c, h, m := o.Claim, o.Claim.Holder, rec.Move
+ var transition func(*hostclaim.Claim) error
+ if action == RecoveryMoveReleaseNotApplied {
+ if m != nil || rec.LastMove != nil && rec.LastMove.ID == g.o.OpID || c.Op.Kind != hostclaim.KindMoving || c.Op.Phase != hostclaim.PhaseMoving {
+ return ErrPending
+ }
+ a, err := g.w.read(ctx, g.o.Account, g.o.App, g.o.AppID)
+ if err != nil {
+ return err
+ }
+ e, ok := a.Value.Effects[g.o.OpID]
+ if !ok || e.Action != moveStartAction || e.OperationID != g.o.OpID || e.AppID != g.o.AppID || e.Resource != h.Key || e.State != "complete" || e.ResultReference != "not-applied" {
+ return ErrPending
+ }
+ transition = hostclaim.ReleaseMove(g.o.OpID, c.Op.Intent["source"])
+ } else if action == RecoveryMoveRelease {
+ // Close first, then release. A delayed close cannot strand a serving
+ // claim with a live move; a crash here replays from LastMove.
+ if m != nil || rec.LastMove == nil || rec.LastMove.ID != g.o.OpID {
+ return ErrPending
+ }
+ switch rec.LastMove.Outcome {
+ case "done", "rolled_back", "aborted":
+ default:
+ return ErrIdentity
+ }
+ if c.Op.Kind == hostclaim.KindMoving && c.Op.Phase != hostclaim.PhaseAborting {
+ return ErrPending
+ }
+ transition = hostclaim.ReleaseMove(g.o.OpID, c.Op.Exec)
+ } else {
+ if m == nil || m.ID != g.o.OpID || m.Incarnation != g.o.Incarnation {
+ return ErrIdentity
+ }
+ switch action {
+ case RecoveryMoveFlip:
+ if m.State != provision.MoveLanding || m.FlippedAt == nil || rec.Region != m.To {
+ return ErrPending
+ }
+ transition = hostclaim.Flip(g.o.OpID, c.Op.Intent["target"], h.Stamp, h.Generation)
+ case RecoveryMoveRollback:
+ if m.State != provision.MoveRollingBack || m.Rollback == nil || rec.Region != m.From {
+ return ErrPending
+ }
+ if c.Op.Kind == hostclaim.KindMoving {
+ transition = hostclaim.RollBackFlipped(g.o.OpID, m.Rollback.ID, c.Op.Intent["target"], h.Stamp, h.Generation)
+ } else {
+ transition = hostclaim.RollBack(g.o.OpID, m.Rollback.ID)
+ }
+ case RecoveryMoveCommit:
+ if m.State != provision.MoveCommitted || m.CommittedAt == nil || m.FlippedAt == nil || rec.Region != m.To {
+ return ErrPending
+ }
+ var seal moves.Seal
+ if json.Unmarshal([]byte(c.Op.Journal[hostclaim.JournalSeal]), &seal) != nil || seal.Validate() != nil {
+ return ErrIdentity
+ }
+ source := hostclaim.ParkScope{Location: strings.TrimSuffix(h.Location, "/"), Incarnation: h.Incarnation, EvidenceAt: *m.FlippedAt, RequestLimit: 120 * time.Second}
+ staging := hostclaim.ParkScope{Location: "moves/" + o.Host + "/" + g.o.OpID, Incarnation: h.Incarnation, EvidenceAt: seal.Bound(), RequestLimit: 120 * time.Second}
+ id, err := operationToken()
+ if err != nil {
+ return err
+ }
+ n := 0
+ transition = hostclaim.Commit(g.o.OpID, m.From, c.Op.Intent["source"], m.To, source, staging, func() string { n++; return id + strconv.Itoa(n) })
+ default:
+ return ErrIdentity
+ }
+ }
+ if err := CheckCreateFreeze(ctx, g.w.raw, g.w.pub); err != nil {
+ return err
+ }
+ g.state.active = false
+ _, err = hostclaim.Update(ctx, g.w.raw, o, func(c *hostclaim.Claim) error {
+ // Re-read the exact record immediately before the claim CAS.
+ r, err := ReadRecord(ctx, g.w.raw, h.Key)
+ if err != nil {
+ return err
+ }
+ if r.ETag != g.o.RecordETag {
+ return release.ErrCASConflict
+ }
+ if err := moveAdmission(ctx, g.w.raw, o.Host, c); err != nil {
+ return err
+ }
+ return transition(c)
+ })
+ return err
+}
+
+// Convert binds C7's existing conversion to the grant and a verified fence.
+// The first pass writes the terminal record decision; a replay verifies it
+// and performs the one claim CAS. It never authorizes relocation execution.
+func (g *MoveRecoveryGrant) Convert(ctx context.Context, ref hostclaim.FenceRef) error {
+ if g == nil || g.state == nil {
+ return ErrPending
+ }
+ authority := g.authority
+ if authority == nil {
+ return ErrContract
+ }
+ g.state.mu.Lock()
+ defer g.state.mu.Unlock()
+ o, r, rec, err := g.current(ctx)
+ if err != nil {
+ return err
+ }
+ fence, err := hostclaim.VerifyFence(ctx, g.w.raw, authority, ref)
+ if err != nil {
+ return err
+ }
+ placement, ok := authority.(oneBoxRegionAuthority)
+ if !ok {
+ return ErrContract
+ }
+ guard := recoveryMovingDecision{movingRecordDecision: movingRecordDecision{store: g.w.raw, placement: placement}, observation: g.o}
+ c, h := o.Claim, o.Claim.Holder
+ if c.Op.Kind == hostclaim.KindRelocating {
+ if _, err := guard.CheckMovingBeforeFlip(ctx, o, c.Op.ID, ref); err != nil {
+ return err
+ }
+ return hostclaim.VerifyOperationFence(ctx, g.w.raw, authority, c.Op, c.Op.Intent["source_box"])
+ }
+ if c.Op.Kind != hostclaim.KindMoving || c.Op.Intent["source_enrollment"] != ref.Enrollment || fence.Box() != c.Op.Intent["source"] || fence.Region() != rec.Region {
+ return hostclaim.ErrFence
+ }
+ if err := placement.CheckOneBoxRegion(ctx, fence.Box(), rec.Region); err != nil {
+ return err
+ }
+ if rec.Move == nil || rec.Move.ID != g.o.OpID || rec.Move.Incarnation != h.Incarnation {
+ return ErrIdentity
+ }
+ if rec.Move.State != provision.MoveRelocating {
+ raw, err := json.Marshal(ref)
+ if err != nil {
+ return err
+ }
+ moving, holder, err := movingIdentities(c)
+ if err != nil {
+ return err
+ }
+ if err := provision.MoveRelocatingDecision(provision.MoveGuard{ID: g.o.OpID, Incarnation: h.Incarnation}, fence.Box(), ref.Enrollment, rec.Region, string(raw), c.Gen, o.ETag, moving, holder, g.w.now())(&rec); err != nil {
+ return err
+ }
+ body, err := g.prepareRecord(ctx, rec)
+ if err != nil {
+ return err
+ }
+ _, _, err = provision.WriteRecord(provision.WithRecordSequence(ctx), g.w.raw, r.Holder.Key, r.Body, body, r.ETag)
+ return err
+ }
+ if err := CheckCreateFreeze(ctx, g.w.raw, g.w.pub); err != nil {
+ return err
+ }
+ g.state.active = false
+ _, err = hostclaim.ConvertMovingToRelocating(ctx, g.w.raw, o, g.o.OpID, ref, authority, guard)
+ return err
+}
+
+// recoveryMovingDecision retains the recovery grant's exact record binding
+// through the final pre-CAS guard, including after fence authority reads.
+type recoveryMovingDecision struct {
+ movingRecordDecision
+ observation ConsoleMoveObservation
+}
+
+func (g recoveryMovingDecision) CheckMovingBeforeFlip(ctx context.Context, o hostclaim.Observed, id string, ref hostclaim.FenceRef) (string, error) {
+ region, err := g.movingRecordDecision.CheckMovingBeforeFlip(ctx, o, id, ref)
+ if err != nil {
+ return "", err
+ }
+ r, err := ReadRecord(ctx, g.store, o.Claim.Holder.Key)
+ if err != nil {
+ return "", err
+ }
+ _, seq, err := recordSequence(r.Body)
+ if err != nil {
+ return "", err
+ }
+ if r.ETag != g.observation.RecordETag || seq != g.observation.RecordSeq {
+ return "", release.ErrCASConflict
+ }
+ return region, nil
+}
+```
+
+#### Patch: `internal/hostops/move_start_resolve.go`
+
+```diff
+--- a/internal/hostops/move_start_resolve.go
++++ b/internal/hostops/move_start_resolve.go
+@@ -67,8 +67,9 @@
+ // record store, raw bucket) either refuses the Put or issues it with that
+ // same ETag, so the noted set covers every Put that could still land.
+ type moveStartRecorder struct {
+- s release.Store
+- puts *moveStartPuts
++ s release.Store
++ puts *moveStartPuts
++ capture func(context.Context, uint64) error
+ }
+
+ var _ release.Store = moveStartRecorder{}
+@@ -103,6 +104,11 @@
+ r.puts.mu.Unlock()
+ if !ok || etag == "" {
+ return "", ErrIdentity
++ }
++ if r.capture != nil {
++ if err := r.capture(ctx, seq); err != nil {
++ return "", err
++ }
+ }
+ }
+ return r.s.Put(ctx, key, body, etag)
+```
+
+#### Patch: `internal/hostops/recovery.go`
+
+```diff
+--- a/internal/hostops/recovery.go
++++ b/internal/hostops/recovery.go
+@@ -36,8 +36,10 @@
+ }
+
+ type Recovery struct {
+- SweepAdmissions func(context.Context) error
+- Controller Controller
++ MoveFenceAuthority hostclaim.FenceAuthority
++ Moves func(context.Context, *MoveRecoveryGrant) error
++ SweepAdmissions func(context.Context) error
++ Controller Controller
+ // These are required integrations, not no-op defaults.
+ Purge func(context.Context, hostclaim.Observed) error
+ Placement func(context.Context, hostclaim.Observed) error
+@@ -73,7 +75,9 @@
+ op := obs.Claim.Op
+ // Zero time is unknown age, never old enough. Freeing empty parking is safe
+ // independently of age; this includes Plan 4's holderless sweep retries.
+- if op.Kind == hostclaim.KindParking && len(obs.Claim.Obligations) == 0 {
++ if moveRecoveryKind(op.Kind) {
++ err = runMoveRecovery(ctx, obs, r.Moves, r.MoveFenceAuthority)
++ } else if op.Kind == hostclaim.KindParking && len(obs.Claim.Obligations) == 0 {
+ err = c.Free(ctx, host, op.ID)
+ } else if op.Kind != "" && op.Kind != hostclaim.KindServing && !op.StartedAt.IsZero() && c.Now().After(op.StartedAt.Add(5*time.Minute)) {
+ switch op.Kind {
+```
+
+## Integration and rollout
+
+Keep `CARLOS_HOST_CLAIM` and `CARLOS_MOVES_PHASE1` unset while landing these changes. This plan does not change the release floor, roster, cloud permissions, production configuration or a running service. Any later production experiment requires the separately scoped approval described by `AGENTS.md`.
+
+Claim Plan 9 and moves Plan 4 are already on the required main base. Apply this plan directly there, then let moves Plan 5 supply and register the real park views, newest-retired evidence and complete lease inventory. Wire its console policy through `Recovery.Moves` in the existing claim recovery worker, inside `RunRecovery`; do not add an unguarded second worker. Keep B8's uncertain record-start settlement and `record_seq` regressions green. Cut/abort/retry authority is independent of SQL permits and wake-reset receipts; B3 and B4 must retain their existing refusal tests.
+
+Moves Plan 4 Task 9 consumes Task A's concrete adapter and activator entry. The adapter records clean and dirty recovery inputs at `probing` before the held render. Task 9 still supplies the durable nine-attempt `OperationAttempts` provider, original first-start time, restoration/admission evidence and production owner registration. Each reservation binds the admitted session and launch digest, and the settle request uses the same role-cgroup readiness check. `KeepWriterSession` wraps each adjacent recovery phase CAS. The moves policy must continue admitting that exact session at the later row phases while refusing changed identities. Do not claim production recovery from a table test: the concrete entry is wired, while its park leases, prepared-generation/open evidence, retirement and production owner registration remain rollout prerequisites.
+
+Exercise a clean return, a dirty return, a probe restart and budget exhaustion/retry before rollout. For a declared target loss, exercise the target still executing, a dirty release without a completed return, a partially published return lane, a record commit racing the declaration, and retries at each durable boundary. Verify loss accounting through the later admission and retained signed record. A successful declaration by itself does not prove that an instance recovered. Target-local chores still require local retirement or exact destroyed-storage evidence, and the final close still waits for the target park stamp.
+
+Implementation must run the repository's full gate once before landing: `go build ./...`, `go vet ./...`, `go test ./... -count=1` and a clean whole-tree `gofmt -l .`, plus the required cross-compiles. Push and describe the reviewed branch on amadan, squash its implementation history, require green CI on that exact tip, and land with `amadan branch merge -expect`. Read main's CI after landing. The authoring checks below are focused source-export checks, not a claim that this complete landing gate or a production witness ran.
+
+## Copy review inventory
+
+This is implementation text, not shipped product copy. Keep the existing HC5-N1 refusal byte-for-byte until the typed authority is connected. Existing flag-off responses, including their punctuation, remain unchanged. Any proposed visible text listed here requires Paul's review before it is applied to product files in a later implementation. This plan does not grant copy approval.
+
+The inventory is deliberately conservative: it includes new diagnostic templates that can be wrapped into an API refusal, not only top-level HTTP responses. `%s`, `%q` and `%w` are substituted at runtime. Existing messages reused by the new route retain their exact text. Machine keys and test assertions are not copy.
+
+`internal/host/claimmoveparks.go`, `claimconstruction.go` and `claimassembly.go` add these dependency names, rendered through the existing missing-provider error:
+
+```text
+"moves wake owners and C8 origin-qualified files manifest provider"
+"moves wake owners and operation attempts"
+"moves Plan 5 newest retired move fence or release evidence"
+```
+
+Tasks F/G add no other product error prose. Their action tokens and `record_base_seq` are machine keys. Existing contract errors and the three existing park-provider names retain their text. These additions still require copy review before implementation ships.
+
+`internal/console/apiinstancemove.go`:
+
+```text
+"invalid resolution request"
+"console: api instance move %s/%s/%s: declared loss: record reread: %v"
+"console: api instance move %s/%s/%s: declared loss refused: %v"
+"console: api instance move %s/%s/%s: typed move write refused: %v"
+"console: api instance move %s/%s/%s: typed move write: %v"
+```
+
+`internal/hostclaim/move_loss.go`:
+
+```text
+"%w: a lost target's release names its rollback, declaration, target and both regions"
+"%w: the declaration is not on the claim"
+"%w: the declaration is not this op's"
+"%w: %q is not this move's declared-lost target"
+"%w: a lost target's lane park is the move's own lane"
+"%w: the return lane is this rollback's own lane"
+"%w: op is %s/%q, a lost target is released from a landing before its commit or from its own rollback"
+"%w: exec is %q"
+"%w: released without the target's evidence"
+"%w: a dirty release that died before its return owes the return lane's park"
+"%w: op is restoring/%q, the lost target's release precedes the source's claim"
+```
+
+`internal/hostops/move_loss_owner.go`:
+
+```text
+"%w: %s"
+"the source box cannot be declared lost by a move"
+"the move's state does not allow a declared loss"
+"%w: write outside the declaration's scope"
+```
+
+`internal/provision/move_loss_rebind.go`:
+
+```text
+"provision: malformed loss declaration"
+"%w: another loss is recorded for this target"
+```
+
+`internal/host/movesrecoveryprobe.go`:
+
+```text
+"moves: the recovery probe's attempts are spent"
+"moves: the recovery probe request does not match its record"
+"moves: the recovery probe's inputs name no release"
+"CARLOS_HOST_CLAIM Group D recovery admission"
+"activator recovery probe entry"
+"moves recovery probe route and inputs"
+"read the host claim: %w"
+"read the registry row: %w"
+"%w: %s has no registry row on this box"
+"select the recovery inputs: %w"
+"record the recovery inputs: %w"
+"the recovery probe wake: %w"
+"%w: not rollback %s at probing"
+"%w: budget %d, clean %v"
+"%w: a clean return admits a fresh generation restored from src_gen"
+"%w: a dirty return admits its sealed return generation"
+"%w: the claim is not rollback %s's probe on %s"
+"%w: the claim's src_gen is not the request's"
+```
+
+`internal/activator/recoveryprobe.go`:
+
+```text
+"%w: not a recovery probe policy"
+"activator: %s recovery probe healthy, but its fence changed; stopping it"
+"activator: %s recovery probe healthy; held until the source opens"
+"activator: %s recovery probe's session is over (%v); stopping it"
+"activator: %s source opened on the recovery probe's session; serving"
+```
+
+The displayed transition token `move-target-lost` is new. It identifies the proposed target-loss consequence in the shared inspection. The HTTP paths reuse the existing `nothing to resolve yet`, `could not read the instance`, move-changed, missing-runtime HC5-N1 and fixed write-failure sentences. Their spelling and legacy-path behavior are unchanged.
+
+## Verification and self-review
+
+Rev 4 was extracted onto a fresh `git archive origin/main` at `abcc732dbe9d21dd2fe8e7edcd3d28ab0ebac020`, with no combined-branch bootstrap or source normalization. All 66 embedded file targets match the validated source byte-for-byte. Tasks F/G were installed tests-first and produced the expected missing-API failures before implementation. The final focused ten-package command passed, including the merged moves policy rows, B8/console writer censuses and flag-off regressions. `go build -p 1 ./...` passed; whole-tree `gofmt -l .` was empty. The move-recovery and moves-sweep tests also passed under `-race`. The default cache was read-only, so checks used the permitted shared cache and retained it.
+
+The cleanup-authority review found and closed grant-copy, retained-context and overwritten-grant revocation escapes. The C7 verifier is assembly-bound, and its final guard refuses even a sequence-only record change during fence verification. The sweep proof provider receives a detached observation; tests preserve the claim against callback mutation and distinguish listing-start, listing-end and instance evidence times. Record writes call the existing sequenced primitive directly; no census policy was relaxed. The J4 interface remains byte-identical to rev 3. These are local authoring checks, not a full landing gate or a production recovery witness.
+
+The following rev 2/rev 3 notes are historical validation, not verification of rev 4.
-Rev 3 was re-extracted into a fresh export of the pinned inputs and replayed in task order. Every task A–E produced its expected red, then passed its listed tests and `go build -p 1 ./...` before the next task. The final eight-package integration command, the complete `hostclaim`, `hostresolve` and `hostfence` packages, and the activator recovery/hold tests under `-race` passed. Whole-tree `gofmt -l .` was empty. All extracted source and test targets match the validated overlay byte-for-byte. The default Go cache was read-only; validation used the permitted shared fallback without deleting it.
+Rev 3 was re-extracted into a fresh export of the pinned inputs and replayed in task order. Every task A–E produced its expected red, then passed its listed tests and `go build -p 1 ./...` before the next task. The final eight-package integration command, the complete `hostclaim`, `hostresolve` and `hostfence` packages, and the activator recovery/hold tests under `-race` passed. Whole-tree `gofmt -l .` was empty. All rev 3 extracted source and test targets matched its validated export byte-for-byte. The default Go cache was read-only; validation used the permitted shared fallback without deleting it.
-Rev 3 reproduces both round 2 failures before the fixes. Against rev 2, `TestRecoveryProbeSweepRetiresFailedBehindCutFence` leaves exited and lease-failed entries resident on sweep and join; `TestRecoveryProbeSweepOpensOnlyExactSessionAndFence` never reaches its claim read with the cut fence present. `TestRecoveryProbeRefusesAnotherOperationsFence` reaches admission under the foreign fence. This behavioral red run adds only the new `StartPolicy.RecoveryFence` field to compile the new fixtures; it leaves rev 2's wake, join and sweep behavior intact. The revised tests retain the actual fence, exercise `sweepOnce`, prove retirement permits another admission attempt, and prove exact opening serves without waiting for move closure.
+Rev 3 reproduced both round 2 failures before the fixes. Against rev 2, `TestRecoveryProbeSweepRetiresFailedBehindCutFence` leaves exited and lease-failed entries resident on sweep and join; `TestRecoveryProbeSweepOpensOnlyExactSessionAndFence` never reaches its claim read with the cut fence present. `TestRecoveryProbeRefusesAnotherOperationsFence` reaches admission under the foreign fence. This behavioral red run adds only the new `StartPolicy.RecoveryFence` field to compile the new fixtures; it leaves rev 2's wake, join and sweep behavior intact. The revised tests retain the actual fence, exercise `sweepOnce`, prove retirement permits another admission attempt, and prove exact opening serves without waiting for move closure.
The four legacy declaration cases fail against the rev 2 owner: before and after the first target admission, with the source cut uncovered or covered. The revised owner accepts the paired legacy values; the tests read back the committed signed loss record and check the exact write sources. Missing cut or required source-generation keys, malformed cuts and mismatched uncovered identities still refuse without acceptance or quarantine. The J4 interface and its implementation declaration are byte-identical to rev 2.
@@ -9436,12 +11829,12 @@ Rev 2 was checked in an external export of the two pinned commits. No git state
Task A adds adapter and production-entry tests. A separate Opus review found the changed-input replay that could strand an older held probe; its regression failed first and passes with cancellation/retirement and late-success refusal. Caller input maps are copied, and the mutation test failed before that copy was added. Clean and dirty returns record recovery inputs before rendering and launching; a failed record write prevents both. The Group D tests retain operation/session bindings, attempt accounting and role-cgroup readiness. The production recovery hook is connected only with the claim flag; missing dependencies refuse instead of reporting recovery. Failing-first replay installs every task's tests before its code. Initial missing-symbol failures establish new API dependencies; the rev 1 HTTP and discharge regressions above establish behavioral failures.
-The final focused run passed all eight packages below, including the writer census and flag-off cases. `go build -p 1 ./...` passed and whole-tree `gofmt -l .` was empty. The complete `hostclaim`, `hostresolve` and `hostfence` test packages also passed, as did the activator recovery/hold tests under `-race`. Extraction into a fresh pinned export reproduced the implementation and test files byte-for-byte.
+The rev 3 final focused run passed all eight packages below, including the writer census and flag-off cases. `go build -p 1 ./...` passed and whole-tree `gofmt -l .` was empty. The complete `hostclaim`, `hostresolve` and `hostfence` test packages also passed, as did the activator recovery/hold tests under `-race`. Extraction into a fresh pinned export reproduced the implementation and test files byte-for-byte.
-Run the focused integration check serially, including the census and flag-off regressions, then build and check formatting:
+Run the focused integration check serially, including cleanup authority, census and flag-off regressions, then build and check formatting:
```bash
-flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go test -p 1 -timeout 3m -count=1 ./internal/hostclaim ./internal/host ./internal/activator ./internal/hostresolve ./internal/hostops ./internal/console ./internal/provision ./internal/hostfence -run 'TestRecoveryProbe|TestRecoveryHold|TestOrdinaryClaimWake|TestPlan10|TestPlan9D[123]|TestPlan9Fix1Session|TestPlan9Fix2Observe|TestPlan9B8|TestPlan9C2|TestJ17|TestJ4|TestReleaseLostTarget|TestMoveRollbackLossRebind|TestServingDestroyedLocal|TestClaimAuthoritySourceCensus|TestMoveDoorSwitchOffIsLegacy|TestMoveDoorLegacy|TestMoveDoorPhase1RefusedUnderHostClaim|TestResolveRetryRefusedUnderHostClaim|TestClaimPublishMovesConsoleIsInertWithTheSwitchOff|TestIngest|TestPreparedSQL'
+flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go test -p 1 -timeout 3m -count=1 ./internal/hostclaim ./internal/host ./internal/activator ./internal/hostresolve ./internal/hostops ./internal/console ./internal/provision ./internal/hostfence ./internal/park ./internal/hostbox -run 'TestMoveParks|TestMoveRecovery|TestPlan10MovesSweep|TestSweep|TestRegionalWorker|TestMovesClaimPolicy|TestClaimRuntime|TestPlan9D5|TestPlan9I2|TestRecoveryProbe|TestRecoveryHold|TestOrdinaryClaimWake|TestPlan10|TestPlan9D[123]|TestPlan9Fix1Session|TestPlan9Fix2Observe|TestPlan9B8|TestPlan9C2|TestJ17|TestJ4|TestReleaseLostTarget|TestMoveRollbackLossRebind|TestServingDestroyedLocal|TestClaimAuthoritySourceCensus|TestMoveDoorSwitchOffIsLegacy|TestMoveDoorLegacy|TestMoveDoorPhase1RefusedUnderHostClaim|TestResolveRetryRefusedUnderHostClaim|TestClaimPublishMovesConsoleIsInertWithTheSwitchOff|TestIngest|TestPreparedSQL'
flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go build -p 1 ./...
gofmt -l .
```
@@ -9452,7 +11845,7 @@ The healthy-process lifecycle tests supply the instance a successful start would
Self-review checks each authority separately: the console gate identity survives the second read; a compute fence cannot erase local retirement; local-only discharge cannot invoke move rollback or completion; the current executor cannot use it; the signed claim revision binds original chore identities; retry retains evidence without reopening data authority. The J4 interface above is byte-identical to rev 1. Reviewed source digests must be updated only after inspecting the changed writers; no census policy is weakened.
-The pinned moves additions, the isolated root launcher (`RoleLaunch.Start` still refuses in this base), production recovery owners and evidence, and Paul's review of proposed product copy remain rollout dependencies. These authoring checks do not claim the full repository landing gate or a production recovery witness.
+The isolated root launcher (`RoleLaunch.Start` still refuses in this base), production recovery owners and evidence, and Paul's review of proposed product copy remain rollout dependencies. These authoring checks do not claim the full repository landing gate or a production recovery witness.
Remove the replay only after all Go tests and delegates have exited. Cleanup deletes the created source exports, generated scripts and logs, and the short socket directory. It retains the shared Go cache. From a directory outside the scratch tree: