carlos / platform Public

plan: claim Plan 10 rev 6 (review r3: recovery probe keeps its generation, held probes survive adopted bounce and convergence, abort recovery releases with ReleaseAbort; re-anchored on main 75618480)

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 b577afb7f635465db19058408b689c9f5b0ab082 parent b561645
1 file changed, +324 −157
  • docs/superpowers/plans/2026-10-09-host-lifecycle-claim-10-moves-recovery-grants.md +324 −157

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 37a8fcb14..a154ff513 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,6 +1,6 @@
# Host lifecycle claim, Plan 10: moves recovery and console grants
-Implementation plan, 2026-10-09, rev 5. 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), plus moves Plan 3 G2 and committed-wake input dispatch. It authorizes no production change.
+Implementation plan, 2026-10-09, rev 6. 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), plus moves Plan 3 G2 and committed-wake input dispatch. 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.
@@ -10,6 +10,8 @@ Implementation plan, 2026-10-09, rev 5. This plan extends the claim's recovery-p
**Rev 5:** raises the minimum main base to `da76647b` and rebases the embedded patches onto `origin/main` at `cd987f6c`. Task H adds G2 beside K1: `ClaimProductionMoveSources.Open` receives identity-bound, read-only held-work selection, registry-row and role-launch/environment-report accessors. They remain unavailable until the verified assembly binds its real source. Production input checks dispatch through Plan 9's `ClaimOwnerDispatch`; every move-kind input check goes to `(*movesProbeOwners).InputPolicy`, including a cold wake at `landing/committed`, with no serving fallback. The committed `Policy.Moves` callback is wired too. Failing tests cover the access boundary and matching/mismatching committed pins through cold admission. The J4 `OperationOwner` signature remains byte-for-byte unchanged.
+**Rev 6:** answers all three review round 3 P2s: Task A retains and publishes the recovered generation, guards the common bounce path and adoption convergence while held, and tests socket routing and both adoption APIs at `probed`; Task G uses `ReleaseAbort` for `moving/aborting` and tests record close, a fresh grant and release. Every patch is re-anchored on `origin/main` at `75618480`, consuming the moves Plan 3 Tasks 5–7 implementation and preserving #267's `rootaws` credential boundary.
+
## 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.
@@ -34,9 +36,13 @@ The write order is signed acceptance, global roster quarantine, prepared loss re
## Base and scope
-Build on `origin/main` at `da76647b` or later. The rev 5 replay is pinned to `cd987f6ca876572ee718566d48e3431f796fd8a4`. This merged base carries claim Plan 9, its review fixes and moves Plans 4 and 5. 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.
+Build on `origin/main` at `75618480` or later. The rev 6 replay is pinned to `756184804d8c76654b4ef9230a6fd8e3fd93b3f9`. This merged base carries claim Plan 9, moves Plans 4 and 5, moves Plan 3 Tasks 5–7 at `a34623bd`, and #267's root AWS credential changes. 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.
+
+The patches below are rebased onto the rev 6 replay 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.
+
+Main already supplies `(*movesProbeOwners).InputPolicy`, `MovesClaimPolicyWithInputs`, `movesCommittedSpawn`, `ProbeWake` and its `probeEntry`, `OperationAttempts` and the landing/committed `movesProbeAttempts`, `appconfig.RenderHeldUnder`, and `release.MaterializeHeldUnder`. Consume those implementations. Task A extends the existing claim wake glue with a separate `recoveryPolicy` field, preserving landing probe admission, hold checks, failure classification and retirement. Task H binds the existing input checker and policy constructor; it does not recreate them. Recovery-specific input owners, attempt accounting and production registration remain the moves rollout dependencies stated below; the landed landing owner is not a recovery provider.
-The patches below are rebased onto the rev 5 replay 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.
+Keep #267's `rootaws.LoadDefaultConfig` calls intact. This plan adds no AWS client; any client added during integration must load through `rootaws.LoadDefaultConfig`, including root-side paths, rather than calling the SDK loader directly.
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.
@@ -48,7 +54,7 @@ Flag-off compatibility means byte-identical existing responses and storage on th
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` 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.
+Use one scratch directory under the original `$TMPDIR`. Socket fixtures use a separately created short directory under that same parent, set as their `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/export.py`:
@@ -62,7 +68,7 @@ import tarfile
repo, scratch = map(pathlib.Path, sys.argv[1:])
base = subprocess.check_output(["git", "-C", str(repo), "rev-parse", "origin/main"]).decode().strip()
subprocess.run(["git", "-C", str(repo), "merge-base", "--is-ancestor",
- "da76647b", base], check=True)
+ "75618480", base], check=True)
(scratch / "base.txt").write_text(base + "\n")
target = scratch / "source"
target.mkdir()
@@ -115,12 +121,13 @@ only those exact directories.
```bash
set -euo pipefail
CLAIM10_REPO=/home/paulca/amadan.net/carlos/platform/.claude/worktrees/replica-ownership
-export CLAIM10_SCRATCH=$(mktemp -d /home/paulca/.cache/tmp/claude-1001/claim10-replay-XXXXXX)
-export CLAIM10_SOCKETS=$(mktemp -d /tmp/c10.XXXX)
+export CLAIM10_TMP_PARENT="$TMPDIR"
+export CLAIM10_SCRATCH=$(mktemp -d "$CLAIM10_TMP_PARENT/claim10-replay-XXXXXX")
+export CLAIM10_SOCKETS=$(mktemp -d "$CLAIM10_TMP_PARENT/c10.XXXX")
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
+# Leave the default Go cache in use. Only if it is read-only, enable:
+# export GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache
# 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"
@@ -143,6 +150,10 @@ Task A supplies `recoveryProbeAdapter` and the production binding `armRecoveryPr
The adapter passes the recorded inputs in `StartPolicy.Inputs`, sets `Probe`, and uses a 30-second readiness window with at most three starts in one wake. The moves `OperationAttempts` provider owns the durable nine-start recovery budget across wakes and the original first-start time; its exhausted result maps to `recoveryProbeExhausted`. A failed wake window or changed authority leaves `probing` for another pass. The readiness predicate checks runtime wiring before input recording. An in-flight or held probe with superseded inputs is cancelled or retired, including a late healthy result after cancellation; it cannot strand the newly recorded inputs behind the old process.
+`finishRecoveryProbe` retains `w.gen`, using ordinary wake's `canonicalGen(e.route, nextGen(), w.spawnVersion)` fallback when the launcher supplied no generation. Keep it held without publishing current-generation state. Once the exact admitted session opens, `reviewRecoveryHold` calls `noteCurrent` and publishes the canonical socket link under `a.mu`, alongside releasing the owning fence and the hold. `TestRecoveryProbeGenerationRoutingAfterOpening` covers supplied generation sockets and the canonical fallback through both `opened` and release to `serving`: `ServingGen`, `AppSocket` and `CacheGen` name the same retained process's handle after opening; neither the cache generation nor a canonical link is published while held.
+
+Keep the waking-entry hold guard in `Bounce`, and guard the shared `bounceLocked` path so `BounceAdopted` also preserves the held process. `ConvergeAdopted` checks `probeHeld` independently of the fence, including after the moves worker lifts its own fence. `TestRecoveryHoldSurvivesAdoptionAtProbed` runs `Bounce`, `BounceAdopted` and `ConvergeAdopted` against both recovery kinds, with and without the cut fence. A changed adopted build cannot stop or replace that admitted session at `probed`; a matching probe call still joins it and traffic stays refused. These tests must fail against the rev 5 behavior before applying the fixes.
+
Edge reloads preserve held operation-owned probes. The actual sweep supervises these entries before its ordinary fenced-host skip: a dead process, lost replication lease or replaced fence retires the probe and retains the installed fence for a retry. Joining a failed held probe also starts retirement.
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.
@@ -153,10 +164,10 @@ A clean return records inputs **at `Rollback.SourcePhase == probing`, before ren
```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|TestMovesClaimPolicy'
+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|TestProbeWake|TestProbeEntry|TestMovesProbe'
# 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|TestMovesClaimPolicy'
+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|TestProbeWake|TestProbeEntry|TestMovesProbe'
flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go build -p 1 ./...
```
@@ -920,17 +931,17 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/instancectl"
"github.com/carlosframework/platform/internal/registry"
-@@ -375,6 +376,18 @@
-
- type actEntry struct {
- claimResult *wokenInstance
-+ // Recovery probe entries (RecoveryProbe, recoveryprobe.go). probe is the
+@@ -379,6 +380,18 @@
+ // claim path runs with the probe's policy, and the entry is held for
+ // the move while its registry row is.
+ probe *probeEntry
++ // RecoveryProbe retains its separate recovery policy. recoveryPolicy 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
+ // admission the probe started under, written by the wake before the
+ // awake transition. probeHeld keeps requests, bounces and the idle clock
+ // off the entry until the claim says the source opened on that session.
-+ probe *StartPolicy
++ recoveryPolicy *StartPolicy
+ probeAdmitted hostclaim.Observed
+ probeHeld bool
+ probeReviewing bool
@@ -939,7 +950,7 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
// 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
-@@ -1047,6 +1060,11 @@
+@@ -1051,6 +1064,11 @@
a.entries[rt.Host] = e
go a.wake(e)
}
@@ -951,7 +962,7 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
switch e.state {
case actAwake:
-@@ -1132,6 +1150,10 @@
+@@ -1136,6 +1154,10 @@
if e == nil {
a.mu.Unlock()
return errSidecarAsleep
@@ -962,7 +973,7 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
}
switch e.state {
case actAwake:
-@@ -1828,6 +1850,11 @@
+@@ -1837,6 +1859,11 @@
a.mu.Lock()
defer a.mu.Unlock()
e := a.entries[host]
@@ -974,7 +985,27 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
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
-@@ -3286,6 +3313,18 @@
+@@ -1856,6 +1883,10 @@
+ // bounceLocked is Bounce's stop for an entry the caller has found awake,
+ // under a.mu.
+ func (a *Service) bounceLocked(e *actEntry) bool {
++ // Both Bounce and BounceAdopted must preserve the admitted recovery.
++ if e.probeHeld {
++ return false
++ }
+ msg := "instance restarting (carlos restart)"
+ if e.worker {
+ msg = "sidecar restarting (carlos restart)"
+@@ -2085,7 +2116,7 @@
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ e := a.entries[host]
+- if e == nil || e.worker || e.state != actAwake || a.fences[host] != 0 {
++ if e == nil || e.worker || e.probeHeld || e.state != actAwake || a.fences[host] != 0 {
+ return false
+ }
+ if ver == "" || e.spawnVersion == "" || e.spawnVersion == ver {
+@@ -3295,6 +3326,18 @@
// with the recycles below that converge instead (recycleWorkerLocked).
a.convergeBudget = maxConvergeCyclesPerSweep
for _, e := range a.entries {
@@ -993,7 +1024,7 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
if a.fences[e.host] != 0 {
continue
}
-@@ -3717,7 +3756,9 @@
+@@ -3757,7 +3800,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.
@@ -1032,39 +1063,38 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
// Pin is a hold swap's chosen build (zero-downtime spec §7.8). Prepare
// must build a start that execs exactly Pin.StagedPath after checking
// Pin.Digest. startAdmitted refuses a ClaimStart whose Version is not
-@@ -166,8 +168,17 @@
- if e.route.WakeIncarnation != "" && (observed.Claim == nil || observed.Claim.Holder == nil || observed.Claim.Holder.Incarnation != e.route.WakeIncarnation) {
- return nil, hostbox.ErrChanged
+@@ -169,11 +171,14 @@
+ // A probe (ProbeWake, moves Plan 3 Task 5a) is admitted only at its
+ // op's probing phase (K6's probe row) and runs under its own policy.
+ probe := e.probe
+- if err := hostbox.WakePhase(observed.Claim, r.Box, probe != nil); err != nil {
++ if err := hostbox.WakePhase(observed.Claim, r.Box, probe != nil || e.recoveryPolicy != nil); err != nil {
+ return nil, probeRefused(e, err)
}
-- 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.
-+ policy := startPolicy{MaxStarts: 5, Window: 30 * time.Second}
-+ if e.probe != nil {
-+ policy = *e.probe
+ if probe != nil && observed.Claim.Op.ID != probe.op {
+ return nil, probeRefused(e, errProbeOp)
+ }
-+ if err := hostbox.WakePhase(observed.Claim, r.Box, policy.Probe); err != nil {
- return nil, err
-+ }
-+ if e.probe != nil && (!policy.Probe || !recoveryProbeKind(observed.Claim.Op.Kind)) {
++ if e.recoveryPolicy != nil && (!e.recoveryPolicy.Probe || !recoveryProbeKind(observed.Claim.Op.Kind)) {
+ return nil, hostbox.ErrChanged
}
if r.Prerequisites == nil || r.Admit == nil || r.Prepare == nil || r.FailedAdmission == nil {
return nil, hostwrite.ErrUnavailable
-@@ -183,7 +194,7 @@
- }
+@@ -190,7 +195,9 @@
rt = *fresh
}
-- policy := startPolicy{MaxStarts: 5, Window: 30 * time.Second, Pin: e.pin}
-+ policy.Pin = e.pin
- if e.pin != nil && e.pinCheck != nil {
- // A claims-on hold's pinned wake: the attempt gate before anything
- // is admitted, and again in the launch's before hook at every exec,
-@@ -213,6 +224,9 @@
+ policy := startPolicy{MaxStarts: 5, Window: 30 * time.Second, Pin: e.pin}
+- if probe != nil {
++ if e.recoveryPolicy != nil {
++ policy = *e.recoveryPolicy
++ } else if probe != nil {
+ // K1: the row is still held by the probe's hold op.
+ if !probeHeld(rt, probe.holdOp) {
+ return nil, probeRefused(e, errProbeRow)
+@@ -225,6 +232,9 @@
return nil, hostbox.ErrChanged
}
e.route = rt
-+ if e.probe != nil {
++ if e.recoveryPolicy != nil {
+ e.probeAdmitted = admitted
+ }
if err := a.startAdmitted(ctx, e, admitted, policy); err != nil {
@@ -1160,7 +1190,7 @@ func (a *Service) RecoveryProbe(ctx context.Context, rt registry.Route, p StartP
inputs := *p.Inputs
inputs.BundleETags = maps.Clone(p.Inputs.BundleETags)
policy.Inputs = &inputs
- e := &actEntry{host: rt.Host, route: rt, state: actWaking, ready: make(chan struct{}), probe: &policy, probeHeld: true, wakeCancel: cancel}
+ e := &actEntry{host: rt.Host, route: rt, state: actWaking, ready: make(chan struct{}), recoveryPolicy: &policy, probeHeld: true, wakeCancel: cancel}
if a.entries == nil {
a.entries = make(map[string]*actEntry)
}
@@ -1216,11 +1246,11 @@ func sameRecoveryProbe(x, y StartPolicy) bool {
func (a *Service) joinRecoveryProbe(ctx context.Context, e *actEntry, p StartPolicy) error {
for {
a.mu.Lock()
- if a.entries[e.host] != e || e.probe == nil {
+ if a.entries[e.host] != e || e.recoveryPolicy == nil {
a.mu.Unlock()
return hostbox.ErrChanged
}
- if !sameRecoveryProbe(*e.probe, p) {
+ if !sameRecoveryProbe(*e.recoveryPolicy, p) {
// A new input selection must not strand an older held probe.
// Cancel a pending start, including a late healthy result, or
// retire the held process before the next pass can start anew.
@@ -1306,6 +1336,12 @@ func (a *Service) finishRecoveryProbe(e *actEntry, fence uint64, w *wokenInstanc
e.probeFence = fence
e.rep, e.proc = w.rep, w.proc
e.spawnVersion = w.spawnVersion
+ // Retain the launcher's socket; legacy claim launchers use the same
+ // canonical fallback as an ordinary wake. Publish only when opened.
+ e.gen = w.gen
+ if e.gen.N == 0 && !e.worker {
+ e.gen = canonicalGen(e.route, nextGen(), w.spawnVersion)
+ }
e.lastActive, e.spawnedAt = now, now
e.bounceAfterWake = false
if e.probeObsolete {
@@ -1392,10 +1428,18 @@ func (a *Service) reviewRecoveryHold(e *actEntry) {
// The entry retains both the observed fence and the adapter's
// owning generation. Check and remove under the same lock as the
// hold transition; never lift a replacement operation's fence.
- if e.probe.RecoveryFence != 0 && a.fences[e.host] == e.probe.RecoveryFence {
+ if e.recoveryPolicy.RecoveryFence != 0 && a.fences[e.host] == e.recoveryPolicy.RecoveryFence {
delete(a.fences, e.host)
}
log.Printf("activator: %s source opened on the recovery probe's session; serving", e.host)
+ if e.gen.N != 0 {
+ a.noteCurrent(e.host, e.gen.N)
+ }
+ if a.gens && linksCanonical(e.route, e.gen) {
+ if err := swapCanonicalLink(e.route.Socket, e.gen.Socket); err != nil {
+ log.Printf("activator: %s: link %s to %s: %v (processes dialling the canonical path will not reach it)", e.host, e.route.Socket, e.gen.Socket, err)
+ }
+ }
e.probeHeld = false
e.lastActive = a.now()
}
@@ -1418,10 +1462,10 @@ func (a *Service) retireRecoveryProbeLocked(e *actEntry) bool {
// verdict, keeping probeHeld in force while the read is outstanding.
func (a *Service) recoveryFenceChangedLocked(e *actEntry) bool {
fence := a.fences[e.host]
- if fence == 0 && e.probe != nil && e.probeFence == e.probe.RecoveryFence {
+ if fence == 0 && e.recoveryPolicy != nil && e.probeFence == e.recoveryPolicy.RecoveryFence {
return false
}
- return fence != e.probeFence || (fence != 0 && (e.probe == nil || fence != e.probe.RecoveryFence))
+ return fence != e.probeFence || (fence != 0 && (e.recoveryPolicy == nil || fence != e.recoveryPolicy.RecoveryFence))
}
```
@@ -1433,7 +1477,10 @@ package activator
import (
"context"
"errors"
+ "fmt"
+ "os"
"os/exec"
+ "path/filepath"
"reflect"
"sync"
"testing"
@@ -1817,7 +1864,7 @@ func heldProbe(t *testing.T, a *Service, fence uint64, admitted hostclaim.Observ
t.Helper()
p := probePolicy(in)
p.RecoveryFence = fence
- e := &actEntry{host: probeHost, route: registry.Route{Host: probeHost}, state: actWaking, ready: make(chan struct{}), probe: &p, probeHeld: true}
+ e := &actEntry{host: probeHost, route: registry.Route{Host: probeHost}, state: actWaking, ready: make(chan struct{}), recoveryPolicy: &p, probeHeld: true}
a.mu.Lock()
a.entries[probeHost] = e
a.mu.Unlock()
@@ -1853,17 +1900,17 @@ func TestRecoveryProbeRetainsTheHealthyProcessHeldUntilOpened(t *testing.T) {
// A replayed call for the same session answers from the retained process.
r.Read = func(context.Context, string) (hostclaim.Observed, error) { return admitted, nil }
- if err := a.RecoveryProbe(t.Context(), registry.Route{Host: probeHost}, *e.probe); err != nil {
+ if err := a.RecoveryProbe(t.Context(), registry.Route{Host: probeHost}, *e.recoveryPolicy); err != nil {
t.Fatalf("replay: %v", err)
}
moved := probeAdmitted(admitted)
r.Read = func(context.Context, string) (hostclaim.Observed, error) { return moved, nil }
- if err := a.RecoveryProbe(t.Context(), registry.Route{Host: probeHost}, *e.probe); !errors.Is(err, hostbox.ErrChanged) {
+ if err := a.RecoveryProbe(t.Context(), registry.Route{Host: probeHost}, *e.recoveryPolicy); !errors.Is(err, hostbox.ErrChanged) {
t.Fatalf("replay on another session: %v", err)
}
proc.exit()
r.Read = func(context.Context, string) (hostclaim.Observed, error) { return admitted, nil }
- if err := a.RecoveryProbe(t.Context(), registry.Route{Host: probeHost}, *e.probe); !errors.Is(err, hostbox.ErrChanged) {
+ if err := a.RecoveryProbe(t.Context(), registry.Route{Host: probeHost}, *e.recoveryPolicy); !errors.Is(err, hostbox.ErrChanged) {
t.Fatalf("replay on an exited process: %v", err)
}
}
@@ -2072,7 +2119,7 @@ func TestRecoveryProbeChangedInputsCancelWakingEntry(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p := probePolicy(probeInputs())
- e := &actEntry{host: probeHost, probe: &p, probeHeld: true, state: actWaking, wakeCancel: cancel, ready: make(chan struct{})}
+ e := &actEntry{host: probeHost, recoveryPolicy: &p, probeHeld: true, state: actWaking, wakeCancel: cancel, ready: make(chan struct{})}
a.entries[probeHost] = e
in := probeInputs()
in.Release = "new"
@@ -2107,7 +2154,7 @@ func TestRecoveryProbeLateHealthyAfterInputChangeStops(t *testing.T) {
p := probePolicy(probeInputs())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- e := &actEntry{host: probeHost, probe: &p, probeHeld: true, state: actWaking, wakeCancel: cancel, ready: make(chan struct{}), probeAdmitted: probeAdmitted(obs)}
+ e := &actEntry{host: probeHost, recoveryPolicy: &p, probeHeld: true, state: actWaking, wakeCancel: cancel, ready: make(chan struct{}), probeAdmitted: probeAdmitted(obs)}
a.entries[probeHost] = e
in := probeInputs()
in.Release = "new"
@@ -2129,6 +2176,133 @@ func TestRecoveryProbeLateHealthyAfterInputChangeStops(t *testing.T) {
t.Fatal("obsolete process remains")
}
}
+
+// Recovery must retain the handle returned by the launcher, and only publish
+// it as current after the exact admitted session opens.
+func TestRecoveryProbeGenerationRoutingAfterOpening(t *testing.T) {
+ for _, generated := range []bool{false, true} {
+ for _, phase := range []string{hostclaim.PhaseOpened, hostclaim.KindServing} {
+ t.Run(fmt.Sprint(generated)+"/"+phase, func(t *testing.T) {
+ obs := probeObserved(probeClaim(hostclaim.KindRestoring, hostclaim.PhaseProbing, "box"))
+ admitted := probeAdmitted(obs)
+ a, r, _ := probeService(t, obs)
+ a.gens = generated
+ rt := registry.Route{Host: probeHost, Socket: filepath.Join(t.TempDir(), "app.sock")}
+ p := probePolicy(probeInputs())
+ e := &actEntry{host: probeHost, route: rt, state: actWaking, ready: make(chan struct{}), recoveryPolicy: &p, probeHeld: true, probeAdmitted: admitted}
+ a.entries[probeHost] = e
+ proc, rep := &fakeProc{}, &fakeRep{}
+ w := &wokenInstance{proc: proc, rep: rep, spawnVersion: "recovered", stopOutcome: &stopObservation{}}
+ if generated {
+ w.gen = Gen{N: nextGen(), Socket: filepath.Join(filepath.Dir(rt.Socket), "generation.sock"), Build: "recovered"}
+ }
+ if err := a.finishRecoveryProbe(e, 0, w, nil); err != nil {
+ t.Fatal(err)
+ }
+ if e.gen.N == 0 {
+ t.Fatal("recovery lost its generation")
+ }
+ want := w.gen
+ if !generated {
+ want = canonicalGen(rt, e.gen.N, w.spawnVersion)
+ }
+ if !reflect.DeepEqual(e.gen, want) {
+ t.Fatalf("retained %+v, want %+v", e.gen, want)
+ }
+ if _, ok := a.CacheGen(probeHost); ok {
+ t.Fatal("held generation became current")
+ }
+ if generated {
+ if _, err := os.Lstat(rt.Socket); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("held generation linked: %v", err)
+ }
+ }
+ opened := admitted
+ c := *admitted.Claim
+ c.Op.Phase = phase
+ if phase == hostclaim.KindServing {
+ c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"}
+ }
+ opened.Claim = &c
+ r.Read = func(context.Context, string) (hostclaim.Observed, error) { return opened, nil }
+ a.reviewRecoveryHold(e)
+ if e.probeHeld || e.proc != Proc(proc) || proc.isStopped() {
+ t.Fatal("opening replaced the recovered process")
+ }
+ got, ok := a.ServingGen(probeHost)
+ if !ok || got.N != want.N || got.Socket != want.Socket || got.Build != want.Build {
+ t.Fatalf("ServingGen = %+v, %v", got, ok)
+ }
+ if socket, ok := a.AppSocket(probeHost); !ok || socket != want.Socket {
+ t.Fatalf("AppSocket = %q, %v", socket, ok)
+ }
+ if n, ok := a.CacheGen(probeHost); !ok || n != want.N {
+ t.Fatalf("CacheGen = %d, %v", n, ok)
+ }
+ if generated {
+ if target, err := os.Readlink(rt.Socket); err != nil || target != want.Socket {
+ t.Fatalf("canonical link = %q, %v", target, err)
+ }
+ }
+ release, err := a.beginRequest(t.Context(), rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ release()
+ })
+ }
+ }
+}
+
+func TestRecoveryHoldSurvivesAdoptionAtProbed(t *testing.T) {
+ for _, kind := range []string{hostclaim.KindReverting, hostclaim.KindRestoring} {
+ for _, fenced := range []bool{false, true} {
+ for _, api := range []string{"Bounce", "BounceAdopted", "ConvergeAdopted"} {
+ t.Run(kind+"/"+fmt.Sprint(fenced)+"/"+api, func(t *testing.T) {
+ obs := probeObserved(probeClaim(kind, hostclaim.PhaseProbing, "box"))
+ admitted := probeAdmitted(obs)
+ a, r, _ := probeService(t, obs)
+ a.fences = map[string]uint64{probeHost: 9}
+ e, proc, rep, err := heldProbe(t, a, 9, admitted, probeInputs())
+ if err != nil {
+ t.Fatal(err)
+ }
+ probed := admitted
+ c := *admitted.Claim
+ c.Op.Phase = hostclaim.PhaseProbed
+ probed.Claim = &c
+ r.Read = func(context.Context, string) (hostclaim.Observed, error) { return probed, nil }
+ if !fenced {
+ delete(a.fences, probeHost)
+ }
+ a.reviewRecoveryHold(e)
+ a.liveVer = func(string) (string, error) { return "adopted", nil }
+ var bounced bool
+ switch api {
+ case "Bounce":
+ bounced = a.Bounce(probeHost)
+ case "BounceAdopted":
+ bounced = a.BounceAdopted(probeHost)
+ case "ConvergeAdopted":
+ bounced = a.ConvergeAdopted(probeHost, "adopted")
+ }
+ a.mu.Lock()
+ held, state, same := e.probeHeld, e.state, a.entries[probeHost] == e
+ a.mu.Unlock()
+ if bounced || !held || state != actAwake || !same || proc.isStopped() || rep.isClosed() {
+ t.Fatalf("adoption disturbed probed session: bounced=%v held=%v state=%v same=%v", bounced, held, state, same)
+ }
+ if err := a.RecoveryProbe(t.Context(), e.route, *e.recoveryPolicy); err != nil {
+ t.Fatalf("cannot rejoin held session: %v", err)
+ }
+ if _, err := a.beginRequest(t.Context(), e.route); !errors.Is(err, ErrFenced) {
+ t.Fatalf("probed session served: %v", err)
+ }
+ })
+ }
+ }
+ }
+}
```
#### Patch: `internal/host/movespass.go`
@@ -2148,7 +2322,7 @@ func TestRecoveryProbeLateHealthyAfterInputChangeStops(t *testing.T) {
}
}
-@@ -599,8 +604,9 @@
+@@ -616,8 +621,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
@@ -2833,7 +3007,7 @@ func sweptProbe(t *testing.T) (*Service, *ClaimRuntime, *actEntry, *fakeProc, *f
if err != nil {
t.Fatal(err)
}
- e.probe.RecoveryFence = 9
+ e.recoveryPolicy.RecoveryFence = 9
r.Read = func(context.Context, string) (hostclaim.Observed, error) { return admitted, nil }
return a, r, e, proc, rep, admitted
}
@@ -2863,7 +3037,7 @@ func TestRecoveryProbeSweepRetiresFailedBehindCutFence(t *testing.T) {
rep.setErr(errors.New("lease lost"))
}
if mode == "join-exit" || mode == "join-lease" {
- if err := a.joinRecoveryProbe(t.Context(), e, *e.probe); !errors.Is(err, hostbox.ErrChanged) {
+ if err := a.joinRecoveryProbe(t.Context(), e, *e.recoveryPolicy); !errors.Is(err, hostbox.ErrChanged) {
t.Fatalf("join: %v", err)
}
} else {
@@ -2888,7 +3062,7 @@ func TestRecoveryProbeSweepRetiresFailedBehindCutFence(t *testing.T) {
r.Admit = func(context.Context, registry.Route, hostclaim.Observed, StartPolicy) (hostclaim.Observed, error) {
return next, nil
}
- if err := a.RecoveryProbe(t.Context(), registry.Route{Host: probeHost}, *e.probe); !errors.Is(err, ErrClaimIsolationUnavailable) {
+ if err := a.RecoveryProbe(t.Context(), registry.Route{Host: probeHost}, *e.recoveryPolicy); !errors.Is(err, ErrClaimIsolationUnavailable) {
t.Fatalf("retry did not reach the launcher: %v", err)
}
})
@@ -9397,7 +9571,7 @@ flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go build -p 1 ./...
// 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 @@
+@@ -510,7 +528,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.
@@ -10509,6 +10683,8 @@ K3 adds durable evidence to B8's start effect before each possible record Put. `
`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.
+For `RecoveryMoveRelease`, a closed record with a `moving/aborting` claim dispatches to `hostclaim.ReleaseAbort`; other permitted terminal move claims keep `hostclaim.ReleaseMove`. Preserve the existing `LastMove` identity/outcome and phase checks. `TestMoveRecoveryAbortCloseFreshGrantThenRelease` closes an aborting record, checks that its spent grant cannot release, verifies `LastMove.Outcome == "aborted"`, then takes a fresh recovery grant and releases to serving on the source without changing the writer identity. Against rev 5 it fails at the fresh grant's release because `ReleaseMove` refuses `moving/aborting`.
+
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.
@@ -10522,7 +10698,7 @@ 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 test -p 1 -timeout 3m -count=1 ./internal/hostops ./internal/console ./internal/provision -run 'TestMoveRecovery|TestPlan9B8|TestPlan9C2|TestJ17|TestClaimRuntime|TestClaimAuthoritySourceCensus|TestIngest|TestPreparedSQL|TestProbeWake|TestProbeEntry|TestMovesProbe'
flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go build -p 1 ./...
```
@@ -10958,6 +11134,55 @@ func TestMoveRecoveryPassUsesTypedHookOnlyInsideRunRecovery(t *testing.T) {
t.Fatalf("retained pass context: %v calls=%d", err, calls)
}
}
+
+func TestMoveRecoveryAbortCloseFreshGrantThenRelease(t *testing.T) {
+ w := newC7World(t, func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) {
+ c.Op.Kind, c.Op.Phase, c.Op.Exec = hostclaim.KindMoving, hostclaim.PhaseAborting, "source"
+ c.Obligations = nil
+ r.Move.State = provision.MoveAborting
+ })
+ 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) })
+ }
+ before := *w.claim(t).Claim.Holder
+ if err := run(func(ctx context.Context, g *MoveRecoveryGrant) error {
+ if err := g.Record(ctx, RecoveryMoveAbortComplete); err != nil {
+ return err
+ }
+ // Closing spends the grant and advances record_seq. A new grant is required.
+ if err := g.Claim(ctx, RecoveryMoveRelease); !errors.Is(err, ErrPending) {
+ t.Fatalf("spent close grant = %v", err)
+ }
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ rec, err := provision.GetRecord(ctx, w.raw, "bab", "app", w.host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec.Move != nil || rec.LastMove == nil || rec.LastMove.ID != "move" || rec.LastMove.Outcome != "aborted" {
+ t.Fatalf("abort not closed: %+v", rec)
+ }
+ if c := w.claim(t).Claim; c.Op.Kind != hostclaim.KindMoving || c.Op.Phase != hostclaim.PhaseAborting {
+ t.Fatal("close released claim")
+ }
+ // A later recovery pass mints a new grant over the closed record.
+ if err := run(func(ctx context.Context, g *MoveRecoveryGrant) error { return g.Claim(ctx, RecoveryMoveRelease) }); err != nil {
+ t.Fatal(err)
+ }
+ c := w.claim(t).Claim
+ if c.Op.Kind != hostclaim.KindServing || c.Op.Exec != "source" {
+ t.Fatalf("abort not released: %+v", c.Op)
+ }
+ if c.Holder.Incarnation != before.Incarnation || c.Holder.Stamp != before.Stamp || c.Holder.Session != before.Session || c.Holder.Generation != before.Generation {
+ t.Fatal("release changed writer identity")
+ }
+ if err := hostclaim.RequireReleasedMove("move")(c); err != nil {
+ t.Fatal(err)
+ }
+}
```
#### Patch: `internal/provision/plan9_b8_census_test.go`
@@ -10974,7 +11199,7 @@ func TestMoveRecoveryPassUsesTypedHookOnlyInsideRunRecovery(t *testing.T) {
// 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 @@
+@@ -710,7 +712,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"},
@@ -11485,10 +11710,14 @@ func (g *MoveRecoveryGrant) Claim(ctx context.Context, action MoveRecoveryClaimA
default:
return ErrIdentity
}
- if c.Op.Kind == hostclaim.KindMoving && c.Op.Phase != hostclaim.PhaseAborting {
- return ErrPending
+ if c.Op.Kind == hostclaim.KindMoving {
+ if c.Op.Phase != hostclaim.PhaseAborting {
+ return ErrPending
+ }
+ transition = hostclaim.ReleaseAbort(g.o.OpID, c.Op.Exec)
+ } else {
+ transition = hostclaim.ReleaseMove(g.o.OpID, c.Op.Exec)
}
- 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
@@ -11731,13 +11960,13 @@ The string after context is always a host; `HeldWork` also takes an action. Only
`Open` runs after `Identity.Verify`, before the assembly has built its final `WorkSource`. It must retain the access object without using it synchronously. Until `Build` binds it, all methods refuse. Bind it once, after `local.bind` and source preparation wiring, immediately before `Wake.Build`. The selected source is a copy of that actual source with only `Prepare` cleared: `SelectAt` otherwise invokes preparation and writes participation state. Keep its guard, local checks, policy and continuation table. Returned `Work.With` rechecks the actor and retains the ordinary mutation boundary. Read-only selection itself grants no effect outside `With`.
-Moves Plan 3 Task 5 (`d5407d02`) consumes these methods: bind `movesProbeOwners.Works` to `HeldWork`, its route reader to `Route` under the provider's live context, and its `Launch` callback to `Launch(ctx, rt.Host)`. The callback uses the authoritative registry row, not caller-supplied paths. Preserve the returned environment when constructing the probe role; add the probe-only marker only after checking the pin. The owner compares its report over that same environment. K1's park registration and engine accessor remain independent.
+The moves Plan 3 Task 5 owner is already on main at `a34623bd`; its production source consumes these methods: bind `movesProbeOwners.Works` to `HeldWork`, its route reader to `Route` under the provider's live context, and its `Launch` callback to `Launch(ctx, rt.Host)`. The callback uses the authoritative registry row, not caller-supplied paths. Preserve the returned environment when constructing the probe role; add the probe-only marker only after checking the pin. The owner compares its report over that same environment. K1's park registration and engine accessor remain independent.
-For input checks, `ClaimInputPolicyOwner` has exactly `ClaimOwnerBindings.InputPolicy`'s shape. `ClaimOwnerDispatch.InputPolicy` selects by operation kind and invokes `d.Moves.Inputs.(ClaimInputPolicyOwner).InputPolicy`. With the moves source registered, that is **`(*movesProbeOwners).InputPolicy` in `internal/host`**, supplied by moves Plan 3 Tasks 5–7 at `d56170d0`. Do not create a second claim-owned implementation of the move pin check. Serving keeps the existing production closure, unchanged. Moving, landing (including committed), reverting and restoring never use serving inputs. Missing methods or owners refuse. The concrete owner at `d56170d0` accepts committed landing spawn; its other kinds/phases may refuse until their moves-owned policies are implemented. Recovery rows continue using their own recovery pins, as Task A requires.
+For input checks, `ClaimInputPolicyOwner` has exactly `ClaimOwnerBindings.InputPolicy`'s shape. `ClaimOwnerDispatch.InputPolicy` selects by operation kind and invokes `d.Moves.Inputs.(ClaimInputPolicyOwner).InputPolicy`. With the moves source registered, that is **`(*movesProbeOwners).InputPolicy` in `internal/host`**, already supplied by moves Plan 3 Tasks 5–7, merged at `a34623bd`. Do not create a second claim-owned implementation of the move pin check. Serving keeps the existing production closure, unchanged. Moving, landing (including committed), reverting and restoring never use serving inputs. Missing methods or owners refuse. The concrete owner on this base accepts committed landing spawn; its other kinds/phases may refuse until their moves-owned policies are implemented. Recovery rows continue using their own recovery pins, as Task A requires.
-`hostbox.Policy.Check` sends move actions directly to `Policy.Moves`, so assigning `InputPolicy` alone is insufficient. Build `MovesClaimPolicyWithInputs(read, dispatch.InputPolicy)` too. The main base used here does not yet have that constructor or the concrete probe owner. The narrow constructor and committed-spawn helper below are the seam from moves Plan 3 Task 7; they preserve every existing phase row and nil-callback refusal. When Tasks 5–7 merge, retain their single implementation of this seam and their probing/continuation rows. Reconcile overlapping hunks rather than overwriting the moves implementation or duplicating its symbols. Both feature flags gate the new production callbacks. Flag-off serving checks and moves refusals remain those of the base.
+`hostbox.Policy.Check` sends move actions directly to `Policy.Moves`, so assigning `InputPolicy` alone is insufficient. Build `MovesClaimPolicyWithInputs(read, dispatch.InputPolicy)` too. Main already supplies that constructor and `movesCommittedSpawn`, together with the concrete owner. Consume them unchanged, including their probing/continuation rows and nil-callback refusal. There is no Task H patch to `movespolicy.go`. Both feature flags gate the new production callbacks. Flag-off serving checks and moves refusals remain those of the base.
-Apply tests first. The absent G2 types and dispatcher give the initial API failure. With the APIs present, temporarily leave `bindProductionClaimInputPolicy` inert to reproduce the behavioral failure in the matching committed cold-admission case; restore the binding before the green run. `TestPlan10CommittedColdAdmissionInputs` uses the real `ClaimRuntime.Admit`, a cold session/generation change and the production `hostbox.Policy.Check` path, with local lease/launch fixtures. Matching pins pass; mismatching pins refuse before the prelaunch write. The record-backed input owner is a contract double because the concrete moves owner has not landed on this base. Repeat this test through the registered `movesProbeOwners` during integration; this authoring check is not a production spawn witness.
+Apply tests first. The absent G2 types and dispatcher give the initial API failure. With the APIs present, temporarily leave `bindProductionClaimInputPolicy` inert to reproduce the behavioral failure in the matching committed cold-admission case; restore the binding before the green run. `TestPlan10CommittedColdAdmissionInputs` uses the real `ClaimRuntime.Admit`, a cold session/generation change and the production `hostbox.Policy.Check` path, with local lease/launch fixtures. Matching pins pass; mismatching pins refuse before the prelaunch write. Both dispatch and cold-admission tests now call the concrete `movesProbeOwners.InputPolicy` over a record reader; a thin counting wrapper records calls without implementing the pin check. They use `pinnedLaunchIdentity` with `Move.Inputs.EnvReport` and `Release`, matching the landed owner. Local launch fixtures remain, so this is not a production spawn witness.
```bash
python3 "$CLAIM10_SCRATCH/apply.py" "$CLAIM10_SCRATCH/plan.md" H tests
@@ -11955,38 +12184,22 @@ func TestPlan10G2LaunchReadOnlyAndSameReport(t *testing.T) {
}
}
-// This record-backed contract double exposes the same method shape as
-// (*movesProbeOwners).InputPolicy. The owner itself lands with moves Plan 3.
+// Count dispatches while using main's concrete move pin checker.
type plan10InputOwner struct {
- ClaimWakeInputs
- read MovesRecordReader
+ *movesProbeOwners
calls int
}
func (m *plan10InputOwner) InputPolicy(ctx context.Context, c *hostclaim.Claim, s hostbox.Selection, id hostwrite.Identity) error {
m.calls++
- if c.Op.Kind != hostclaim.KindLanding || c.Op.Phase != hostclaim.PhaseLandingCommitted || s.Action != "spawn" {
- return hostbox.ErrChanged
- }
- rec, err := m.read(ctx, c.Holder.Account, c.Holder.App, c.Host)
- if err != nil {
- return err
- }
- if rec == nil || rec.Move == nil || rec.Move.ID != c.Op.ID || rec.Move.Incarnation != c.Holder.Incarnation || rec.Move.State != provision.MoveCommitted || rec.Move.Inputs == nil {
- return hostbox.ErrChanged
- }
- pin := HeldConfigIdentity(rec.Move.Inputs.BundleETags, rec.Move.Inputs.OverlayETag)
- pin.Release = rec.Move.Inputs.Release
- if pin != id {
- return hostbox.ErrChanged
- }
- return nil
+ return m.movesProbeOwners.InputPolicy(ctx, c, s, id)
}
func TestPlan10CommittedInputDispatch(t *testing.T) {
reader := &mpReader{rec: mpRecord()}
reader.rec.Move.State = provision.MoveCommitted
- owner := &plan10InputOwner{read: reader.read}
+ reader.rec.Move.Inputs.EnvReport = "report"
+ owner := &plan10InputOwner{movesProbeOwners: &movesProbeOwners{Records: reader.read}}
serving := 0
legacy := func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error {
serving++
@@ -11998,8 +12211,7 @@ func TestPlan10CommittedInputDispatch(t *testing.T) {
c := mpClaim(hostclaim.KindLanding, hostclaim.PhaseLandingCommitted)
c.Holder.WriterBox = mpTarget
s := mpSelection(c, "spawn")
- id := HeldConfigIdentity(mpPins().BundleETags, mpPins().OverlayETag)
- id.Release = mpPins().Release
+ id := pinnedLaunchIdentity(reader.rec.Move.Inputs)
if err := hostbox.WakePhase(c, mpTarget, false); err != nil {
t.Fatal("cold wake phase", err)
}
@@ -12077,8 +12289,9 @@ func TestPlan10CommittedColdAdmissionInputs(t *testing.T) {
t.Fatal(err)
}
rec := &provision.Record{Host: recoveryHost, Move: &provision.MoveState{ID: "m1", Incarnation: o.Claim.Holder.Incarnation, State: provision.MoveCommitted, Inputs: mpPins()}}
+ rec.Move.Inputs.EnvReport = "report"
read := MovesRecordReader(func(context.Context, string, string, string) (*provision.Record, error) { return rec, nil })
- owner := &plan10InputOwner{read: read}
+ owner := &plan10InputOwner{movesProbeOwners: &movesProbeOwners{Records: read}}
bindings := ClaimOwnerBindings{InputPolicy: func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error {
t.Fatal("serving fallback")
return nil
@@ -12086,8 +12299,7 @@ func TestPlan10CommittedColdAdmissionInputs(t *testing.T) {
bindProductionClaimInputPolicy(func(string) string { return "1" }, &bindings, read)
policy := hostbox.Policy{Inputs: bindings.InputPolicy, Moves: bindings.MovesPolicy, Held: func(context.Context, string) (string, error) { return "", nil }}
c := f.controller("boot-1", "controller-1", 4101, "start-1")
- id := HeldConfigIdentity(mpPins().BundleETags, mpPins().OverlayETag)
- id.Release = mpPins().Release
+ id := pinnedLaunchIdentity(rec.Move.Inputs)
if mismatch {
id.Release = "different"
}
@@ -12502,63 +12714,8 @@ func bindProductionClaimInputPolicy(getenv func(string) string, out *ClaimOwnerB
if (a.Mode == "agent" || a.Mode == "") && a.wakeUnavailable == nil {
```
-#### Patch: `internal/host/movespolicy.go`
+Main at `75618480` already supplies `MovesClaimPolicyWithInputs` and `movesCommittedSpawn`. Consume them unchanged, including the probing and continuation rows; this task adds no `movespolicy.go` patch.
-```diff
---- a/internal/host/movespolicy.go
-+++ b/internal/host/movespolicy.go
-@@ -210,6 +210,10 @@
- // MovesClaimPolicy builds hostbox.Policy.Moves for the moves plans over a
- // record reader: see the file comment for what it grants.
- func MovesClaimPolicy(read MovesRecordReader) func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error {
-+ return MovesClaimPolicyWithInputs(read, nil)
-+}
-+
-+func MovesClaimPolicyWithInputs(read MovesRecordReader, committed func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error) func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error {
- return func(ctx context.Context, c *hostclaim.Claim, s hostbox.Selection, id hostwrite.Identity) error {
- refuse := func(reason string, cause error) error {
- r := &MovesPolicyRefusal{Host: s.Host, Op: s.Op, Action: s.Action, Box: s.Box, Reason: reason, Cause: cause}
-@@ -267,6 +271,9 @@
- if lost == s.Box {
- return refuse("this box is declared lost", nil)
- }
-+ }
-+ if s.Phase == hostclaim.PhaseLandingCommitted && s.Action == movesActionSpawn {
-+ return movesCommittedSpawn(ctx, read, committed, c, s, id, refuse)
- }
- if !movesPhaseRights[c.Op.Phase][s.Action] {
- return refuse("the phase does not permit the action", nil)
-@@ -503,3 +510,29 @@
- return schedule.SelectedTimers{Store: reg, Work: work, Context: ctx, Host: host}.ReplaceTimers(host, rows)
- }
- }
-+
-+// movesCommittedSpawn is the committed wake's spawn (Plan 3 Task 7): the
-+// ordinary launch identity, over a committed record of this move, accepted by
-+// the committed input check.
-+func movesCommittedSpawn(ctx context.Context, read MovesRecordReader, committed func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error, c *hostclaim.Claim, s hostbox.Selection, id hostwrite.Identity, refuse func(string, error) error) error {
-+ if committed == nil {
-+ return refuse("the committed launch has no input check", nil)
-+ }
-+ if !recoveryIdentityFits(s.Action, id) {
-+ return refuse("the identity does not fit the action", nil)
-+ }
-+ if read == nil {
-+ return refuse("no record reader", nil)
-+ }
-+ rec, err := read(ctx, c.Holder.Account, c.Holder.App, c.Host)
-+ if err != nil {
-+ return refuse("the record cannot be read", err)
-+ }
-+ if rec == nil || rec.Host != c.Host || rec.Move == nil || rec.Move.ID != s.Op || rec.Move.Incarnation != c.Holder.Incarnation || rec.Move.State != provision.MoveCommitted {
-+ return refuse("the record is not this committed move", nil)
-+ }
-+ if err := committed(ctx, c, s, id); err != nil {
-+ return refuse("the launch is not the committed one", err)
-+ }
-+ return nil
-+}
-```
## Integration and rollout
@@ -12574,7 +12731,7 @@ Implementation must run the repository's full gate once before landing: `go buil
## Copy review inventory
-Rev 5 adds no CLI or UI copy. The committed-policy seam carries these proposed refusal reasons from moves Task 7: `the committed launch has no input check`, `the record is not this committed move`, and `the launch is not the committed one`. Keep them in the moves copy review before product implementation; G2 reuses typed errors. This is not approval to ship those strings.
+Rev 6 adds no CLI or UI copy. The generation-link diagnostic reuses ordinary wake's existing text. Main already owns these committed-policy refusal reasons from moves Task 7: `the committed launch has no input check`, `the record is not this committed move`, and `the launch is not the committed one`. Task H reuses that implementation and its copy; G2 reuses typed errors. This is not approval to ship those strings.
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.
@@ -12669,6 +12826,16 @@ The displayed transition token `move-target-lost` is new. It identifies the prop
## Verification and self-review
+Rev 6 was replayed from `git archive origin/main` at `756184804d8c76654b4ef9230a6fd8e3fd93b3f9`. All 78 embedded code blocks, covering 69 file targets, apply in A–H tests-first order with zero patch fuzz and zero offsets; a clean replay matches the checked source byte-for-byte. The removed target is `movespolicy.go`, whose constructor and committed helper are already on main. The merged `ProbeWake`, moves owners and attempt journal, held render/materialize helpers, and #267 root AWS loader are preserved.
+
+The three round 3 regressions were reproduced against the rebased rev 5 behavior before their fixes: the recovered generation was zero, adoption stopped the held process at `probed`, and a fresh grant's abort release was rejected by `ReleaseMove`. After the changes, recovery/hold tests and move-recovery tests passed. The focused host run passed G2, concrete-owner committed input dispatch/cold admission, park access, Group D, recovery adapters, and the landed moves probe/policy regressions. `go build -p 1 ./...` passed and whole-tree `gofmt -l .` was empty. The console authority census passed separately.
+
+The broader ten-package integration command was attempted, but is not green in this sandbox. Three existing host readiness tests cannot open Unix sockets (`setsockopt: operation not permitted`): `TestPlan9D3ProbeReadinessAcceptsTheSpawnedTreeIn2xx`, `TestPlan9D3ProbeReadinessRefusesOutsideTheContract`, and `TestPlan9D3ServingReadinessIsUnchanged`. The console run stops in `TestMoveDoorPhase1RefusedUnderHostClaim` when its fake Keymail server cannot listen on `[::1]:0`; later console cases are unverified in this run. The other eight packages passed their selected tests. The generation regression checks the retained process and routing handle, cache generation and canonical symlink; it is not a real socket-spawn witness. Full suites, race tests and cross-compiles were not run for this plan revision.
+
+Checks ran serially under `nice`; the final replay used a shared `flock` and a short socket directory under the original `$TMPDIR`. The default Go cache was read-only, so checks used the brief's existing shared fallback, which is retained. No subagents, external reviewers or commits were used for rev 6. Scratch exports, scripts, logs and the socket directory are removed by their exact paths after checking; no other repository file changes. The J4 interface remains byte-for-byte unchanged.
+
+The rev 5 and earlier validation notes below are historical; their old bases, owner doubles and reviews do not describe rev 6.
+
Rev 5 was extracted from `origin/main` at `cd987f6ca876572ee718566d48e3431f796fd8a4`, a descendant of the required `da76647b` base. A second clean extraction applied A–H in order with zero patch fuzz; all 70 embedded file targets matched the tested export byte-for-byte. The new G2/dispatch tests failed with the APIs absent. With the APIs present but the production binding disabled, the matching committed cold-admission regression failed; restoring the binding passed matching and mismatching cases. The focused ten-package integration command below passed. After the final verification-at-bind check, the affected host tests passed again. `go build -p 1 ./...` passed and whole-tree `gofmt -l .` was empty. Tests ran serially under `nice`, using a short socket directory and the permitted shared Go cache because the default was read-only. Scratch exports and the socket directory were removed; the shared cache was retained.
A GPT-6 Astra read-only review checked G2's authority boundary and the production policy wiring. It found no blocker and requested an existing foreign-executor regression, which was added. Access remains absent after failed identity verification, selection skips participation preparation, returned work rechecks identity, registry reads cannot mutate the handle, and the launch report describes the returned role environment. The committed regression uses real cold admission and production policy dispatch with a record-backed owner double. Running it through the concrete `movesProbeOwners` remains a moves Plan 3 integration check; no full landing gate or production recovery witness is claimed. The rev 4 and earlier results below are historical.
@@ -12697,7 +12864,7 @@ The rev 3 final focused run passed all eight packages below, including the write
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 ./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 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|TestProbeWake|TestProbeEntry|TestMovesProbe'
flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go build -p 1 ./...
gofmt -l .
```
@@ -12710,7 +12877,7 @@ Self-review checks each authority separately: the console gate identity survives
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:
+Remove the replay only after all Go tests 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:
```bash
cd "$CLAIM10_REPO"
@@ -12719,8 +12886,8 @@ import os
import pathlib
import shutil
for name, parent, prefix in (
- ("CLAIM10_SCRATCH", "/home/paulca/.cache/tmp/claude-1001", "claim10-replay-"),
- ("CLAIM10_SOCKETS", "/tmp", "c10."),
+ ("CLAIM10_SCRATCH", os.environ["CLAIM10_TMP_PARENT"], "claim10-replay-"),
+ ("CLAIM10_SOCKETS", os.environ["CLAIM10_TMP_PARENT"], "c10."),
):
path = pathlib.Path(os.environ[name])
if path.parent != pathlib.Path(parent) or not path.name.startswith(prefix) or path.is_symlink():