carlos / platform Public

plan: claim Plan 11 rev 4 (review r3 plus 9-CG: root-derived containment parent, prefix-aware writers-stopped, strict sidecar inspection, mask identity) and Plan 10 rev 5 (G2 accessors, committed-wake input dispatch)

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 b561645597803c0234a2a0492b320fa59e05b7c7 parent 9ed7483
2 files changed, +3264 −85
  • docs/superpowers/plans/2026-10-09-host-lifecycle-claim-10-moves-recovery-grants.md +897 −34
  • docs/superpowers/plans/2026-10-09-host-lifecycle-claim-11-region-ha-integration.md +2367 −51

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 34c92cc11..37a8fcb14 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 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.
+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.
**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.
@@ -8,6 +8,8 @@ Implementation plan, 2026-10-09, rev 4. This plan extends the claim's recovery-p
**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.
+**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.
+
## 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.
@@ -32,9 +34,9 @@ The write order is signed acceptance, global roster quarantine, prepared loss re
## Base and scope
-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.
+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.
-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.
+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.
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.
@@ -60,7 +62,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",
- "abcc732dbe9d21dd2fe8e7edcd3d28ab0ebac020", base], check=True)
+ "da76647b", base], check=True)
(scratch / "base.txt").write_text(base + "\n")
target = scratch / "source"
target.mkdir()
@@ -124,7 +126,7 @@ python3 "$CLAIM10_SCRATCH/export.py" "$CLAIM10_REPO" "$CLAIM10_SCRATCH"
cd "$CLAIM10_SCRATCH/source"
```
-Apply A, B, C, D, E, F and G in order. In each task, apply `tests`, run its command and
+Apply A, B, C, D, E, F, G and H 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 exporter makes no source edits. Each implementation task applies directly to the exported main plus the preceding tasks.
@@ -910,7 +912,7 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
```diff
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
-@@ -69,6 +69,7 @@
+@@ -70,6 +70,7 @@
"time"
"github.com/carlosframework/platform/internal/edge"
@@ -918,7 +920,7 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/instancectl"
"github.com/carlosframework/platform/internal/registry"
-@@ -338,6 +339,18 @@
+@@ -375,6 +376,18 @@
type actEntry struct {
claimResult *wokenInstance
@@ -937,7 +939,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
-@@ -808,6 +821,11 @@
+@@ -1047,6 +1060,11 @@
a.entries[rt.Host] = e
go a.wake(e)
}
@@ -949,7 +951,7 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
switch e.state {
case actAwake:
-@@ -893,6 +911,10 @@
+@@ -1132,6 +1150,10 @@
if e == nil {
a.mu.Unlock()
return errSidecarAsleep
@@ -960,7 +962,7 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
}
switch e.state {
case actAwake:
-@@ -1524,6 +1546,11 @@
+@@ -1828,6 +1850,11 @@
a.mu.Lock()
defer a.mu.Unlock()
e := a.entries[host]
@@ -972,7 +974,7 @@ 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
-@@ -2566,6 +2593,18 @@
+@@ -3286,6 +3313,18 @@
// with the recycles below that converge instead (recycleWorkerLocked).
a.convergeBudget = maxConvergeCyclesPerSweep
for _, e := range a.entries {
@@ -991,7 +993,7 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
if a.fences[e.host] != 0 {
continue
}
-@@ -2952,7 +2991,9 @@
+@@ -3717,7 +3756,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.
@@ -1009,7 +1011,7 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
```diff
--- a/internal/activator/claimwake.go
+++ b/internal/activator/claimwake.go
-@@ -19,12 +19,14 @@
+@@ -23,12 +23,14 @@
var ErrAttemptOver = moves.ErrAttemptOver
type StartPolicy struct {
@@ -1019,7 +1021,7 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
- Probe bool
- Prepared *moves.InstallManifest
- Inputs *provision.MoveInputs
-+ // RecoveryFence is the owning move's cut generation, supplied by its adapter.
++ // RecoveryFence is the owning move cut generation, supplied by its adapter.
+ RecoveryFence uint64
+ MaxStarts int
+ Window time.Duration
@@ -1027,10 +1029,10 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
+ Probe bool
+ Prepared *moves.InstallManifest
+ Inputs *provision.MoveInputs
- }
- type startPolicy = StartPolicy
-
-@@ -102,8 +104,17 @@
+ // 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
}
@@ -1049,15 +1051,16 @@ func TestPlan10J2KeepWriterSessionGuardsARecoveryStep(t *testing.T) {
}
if r.Prerequisites == nil || r.Admit == nil || r.Prepare == nil || r.FailedAdmission == nil {
return nil, hostwrite.ErrUnavailable
-@@ -119,7 +130,6 @@
+@@ -183,7 +194,7 @@
}
rt = *fresh
}
-- policy := startPolicy{MaxStarts: 5, Window: 30 * time.Second}
- if err := r.Prerequisites(ctx, rt, observed, policy); err != nil {
- return nil, err
- }
-@@ -131,6 +141,9 @@
+- 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 @@
return nil, hostbox.ErrChanged
}
e.route = rt
@@ -2009,7 +2012,7 @@ func TestOrdinaryClaimWakeKeepsTheServingPolicy(t *testing.T) {
run.mu.Lock()
defer run.mu.Unlock()
p := run.policies[len(run.policies)-1]
- if p != (StartPolicy{MaxStarts: 5, Window: 30 * time.Second}) {
+ if !reflect.DeepEqual(p, StartPolicy{MaxStarts: 5, Window: 30 * time.Second}) {
t.Fatalf("ordinary policy %+v", p)
}
}
@@ -2133,7 +2136,7 @@ func TestRecoveryProbeLateHealthyAfterInputChangeStops(t *testing.T) {
```diff
--- a/internal/host/movespass.go
+++ b/internal/host/movespass.go
-@@ -390,6 +390,11 @@
+@@ -391,6 +391,11 @@
}
eb.Strict = w.strictHost
eb.Phase1 = true
@@ -2145,7 +2148,7 @@ func TestRecoveryProbeLateHealthyAfterInputChangeStops(t *testing.T) {
}
}
-@@ -591,8 +596,9 @@
+@@ -599,8 +604,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
@@ -2186,7 +2189,7 @@ func TestRecoveryProbeLateHealthyAfterInputChangeStops(t *testing.T) {
// 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 @@
+@@ -625,6 +629,13 @@
req.Coverage = recoveryCoverageClean
}
out, err := cfg.recoveryProbe(ctx, r.s, r.rec, req)
@@ -9401,7 +9404,7 @@ flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go build -p 1 ./...
- {"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"},
+ {"internal/host/movesrecover.go", "recoverRun.stuck", "record-writer", "key:SetRecordMove", "4ea620140bbc75b127a893926eefcf58685b40c8362f86ba94f22931a05b449a"},
// Records revert inputs through SetRecordMove; stuck uses the reviewed record helper and journals are local.
```
@@ -9460,7 +9463,7 @@ flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go build -p 1 ./...
## Moves cleanup authority
-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.
+This group supplies the claim-side contracts requested by moves Plan 5's preflight K1, K2, K3, K10, K12 and K13, and moves Plan 3 Task 5's G2 accessors plus Task 7's committed-wake input dispatch. Moves still owns its park views, retirement history, lease inventory and recovery policy. Missing providers refuse; these contracts do not arm incomplete production owners.
### Task F: register move parks independently and require sweep evidence
@@ -9478,7 +9481,7 @@ func RegisterProductionClaimMoveParks(
func (p ClaimProviders) MoveParks(context.Context) (park.Engine, hostbox.LeaseSet, error)
```
-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.
+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. Task H supplies the adjacent G2 read access through `ClaimProductionMoveSources.Open`; it exposes neither this engine nor general assembly access to the probe.
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.
@@ -11704,11 +11707,864 @@ func (g recoveryMovingDecision) CheckMovingBeforeFlip(ctx context.Context, o hos
switch op.Kind {
```
+### Task H: expose G2 probe reads and dispatch committed input checks
+
+Invariant: moves receives only the verified host's read capabilities; selecting held work performs no preparation or mutation. Every input-policy call for a move kind stays with the moves owner. A committed cold wake must prove the record's `Move.Inputs` before its admitted launch proceeds.
+
+G2 sits beside K1's park accessor. Change the source interface's `Open` signature to:
+
+```go
+Open(context.Context, release.Store, lease.VersionedStore, hostbox.Actor, ClaimMoveReadAccess) (ClaimMovesOwners, ClaimFilesManifest, error)
+```
+
+The existing store arguments belong to the existing source contract. The added capability grants no raw store, writable registry handle or assembly access. Its methods are:
+
+```go
+type ClaimMoveReadAccess interface {
+ HeldWork(context.Context, string, string, hostguard.Captured) (hostwrite.Work, error)
+ Route(context.Context, string) (*registry.Route, error)
+ Launch(context.Context, string) (ClaimServingLaunch, moves.EnvReport, error)
+}
+```
+
+The string after context is always a host; `HeldWork` also takes an action. Only `held-config` and `held-release`, the probe's held-write selections, are exposed. Callers cannot supply a route, path, actor, registry or policy. Each call checks the full verified actor and the host claim's current executor before reaching the registry or launch backend. A foreign or absent host refuses. The registry row and launch slices/report maps are detached values. The launch and report use the same normalized role environment, with `CARLOS_MOVE_PROBE` excluded from the pin; missing launch files or an admin token refuse without creating them.
+
+`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.
+
+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.
+
+`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.
+
+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.
+
+```bash
+python3 "$CLAIM10_SCRATCH/apply.py" "$CLAIM10_SCRATCH/plan.md" H tests
+flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go test -p 1 -timeout 3m -count=1 ./internal/host -run 'TestPlan10G2|TestPlan10Committed|TestMoveParks|TestMovesClaimPolicy|TestPlan9D2'
+# After recording the expected API failure:
+python3 "$CLAIM10_SCRATCH/apply.py" "$CLAIM10_SCRATCH/plan.md" H implementation
+flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go test -p 1 -timeout 3m -count=1 ./internal/host -run 'TestPlan10G2|TestPlan10Committed|TestMoveParks|TestMovesClaimPolicy|TestPlan9D2'
+flock -o "$CLAIM10_SCRATCH/tests.lock" nice -n 10 go build -p 1 ./...
+```
+
+#### New file: `internal/host/claimmoveaccess_test.go`
+
+```go
+package host
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/genselect"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func TestPlan10G2ReadAccess(t *testing.T) {
+ f := newAdminTokenFixture(t, "box-1")
+ ctx := t.Context()
+ o, err := hostclaim.Read(ctx, f.claims, adminTokenHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err = hostclaim.Update(ctx, f.claims, o, func(c *hostclaim.Claim) error {
+ c.Op = hostclaim.Op{Kind: hostclaim.KindLanding, ID: "m1", Phase: hostclaim.PhaseProbing, Exec: "box-1", Participants: []string{"box-0", "box-1"}, Intent: map[string]string{"source": "box-0", "target": "box-1", "to": "ie"}}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = f.tokens.Guard.Activate(ctx, adminTokenHost, "box-1", nil); err != nil {
+ t.Fatal(err)
+ }
+ captured, err := hostguard.Capture(f.data, adminTokenHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reg.Close()
+ if err = reg.Upsert(f.rt); err != nil {
+ t.Fatal(err)
+ }
+ reads, launches, prepared := 0, 0, 0
+ original := ClaimServingLaunch{Args: []string{"arg"}, Env: []string{"A=B"}}
+ report := moves.EnvReport{Vars: map[string]string{"A": "hash"}}
+ a := &claimMoveReadAccess{actor: f.actor, check: claimBoundIdentity(f.verifyActor, f.actor), route: func(host string) (*registry.Route, error) { reads++; return reg.Get(host) }, launch: func(context.Context, registry.Route) (ClaimServingLaunch, moves.EnvReport, error) {
+ launches++
+ return original, report, nil
+ }}
+ checkAll := func(want error) {
+ t.Helper()
+ if _, err := a.Route(ctx, adminTokenHost); !errors.Is(err, want) {
+ t.Fatalf("route: %v", err)
+ }
+ if _, _, err := a.Launch(ctx, adminTokenHost); !errors.Is(err, want) {
+ t.Fatalf("launch: %v", err)
+ }
+ if _, err := a.HeldWork(ctx, adminTokenHost, "held-config", captured); !errors.Is(err, want) {
+ t.Fatalf("work: %v", err)
+ }
+ }
+ checkAll(hostwrite.ErrUnavailable)
+ if reads != 0 || launches != 0 {
+ t.Fatal("access before Build")
+ }
+ source := hostbox.WorkSource{Guard: f.tokens.Guard, Box: f.actor.Box, Region: f.actor.Region, Now: time.Now, Policy: func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error { return nil }, Local: func(context.Context, *hostclaim.Claim, hostbox.Selection) error { return nil }, Prepare: func(context.Context, hostclaim.Observed) error { prepared++; return nil }}
+ f.verify = hostbox.ErrChanged
+ if err = a.bind(ctx, source); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatal("binding without verification", err)
+ }
+ checkAll(hostwrite.ErrUnavailable)
+ f.verify = nil
+ if err = a.bind(ctx, source); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = a.HeldWork(ctx, adminTokenHost, "held-config", captured); err != nil {
+ t.Fatal(err)
+ }
+ if prepared != 0 {
+ t.Fatal("read-only selection called Prepare")
+ }
+ for _, action := range []string{"spawn", "install", "held-timers", "delete"} {
+ if _, err = a.HeldWork(ctx, adminTokenHost, action, captured); !errors.Is(err, hostbox.ErrUnsupported) {
+ t.Fatalf("action %s: %v", action, err)
+ }
+ }
+ rt, err := a.Route(ctx, adminTokenHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rt.DBPath = "foreign"
+ stored, err := reg.Get(adminTokenHost)
+ if err != nil || stored.DBPath != f.rt.DBPath {
+ t.Fatal("route read changed registry", err)
+ }
+ launch, got, err := a.Launch(ctx, adminTokenHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ launch.Env[0] = "changed"
+ got.Vars["A"] = "changed"
+ if original.Env[0] != "A=B" || report.Vars["A"] != "hash" {
+ t.Fatal("accessor aliases owner data")
+ }
+ // A present host executed by another box is foreign too.
+ current, err := hostclaim.Read(ctx, f.claims, adminTokenHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ current, err = hostclaim.Update(ctx, f.claims, current, func(c *hostclaim.Claim) error { c.Op.Exec = "box-2"; return nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ checkAll(hostbox.ErrChanged)
+ if _, err = hostclaim.Update(ctx, f.claims, current, func(c *hostclaim.Claim) error { c.Op.Exec = "box-1"; return nil }); err != nil {
+ t.Fatal(err)
+ }
+ previous := reads
+ if _, err = a.Route(ctx, "foreign.example"); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatal(err)
+ }
+ if _, _, err = a.Launch(ctx, "foreign.example"); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatal(err)
+ }
+ if _, err = a.HeldWork(ctx, "foreign.example", "held-config", captured); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatal(err)
+ }
+ if reads != previous {
+ t.Fatal("foreign host reached registry")
+ }
+ work, err := a.HeldWork(ctx, adminTokenHost, "held-release", captured)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.verify = hostbox.ErrChanged
+ checkAll(hostbox.ErrChanged)
+ if err = work.With(ctx, hostwrite.Identity{}, func() error { t.Fatal("effect after lost identity"); return nil }); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatal(err)
+ }
+ if reads != previous {
+ t.Fatal("unverified identity reached registry")
+ }
+}
+
+func TestPlan10G2LaunchReadOnlyAndSameReport(t *testing.T) {
+ f := newAdminTokenFixture(t, "box-1")
+ source := claimMoveLaunchSource(f.backend)
+ if _, _, err := source(t.Context(), f.rt); err == nil {
+ t.Fatal("missing token accepted")
+ }
+ if _, err := os.Stat(f.envPath()); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("read created token", err)
+ }
+ t.Setenv("CARLOS_SECRETS_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32)))
+ if err := os.WriteFile(f.envPath(), []byte("CARLOS_ADMIN_TOKEN=existing\nSECRET=value\nCARLOS_MOVE_PROBE=1\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ before, err := os.ReadFile(f.envPath())
+ if err != nil {
+ t.Fatal(err)
+ }
+ launch, report, err := source(t.Context(), f.rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ after, _ := os.ReadFile(f.envPath())
+ if !reflect.DeepEqual(before, after) {
+ t.Fatal("launch read wrote environment")
+ }
+ env := map[string]string{}
+ for _, kv := range launch.Env {
+ k, v, ok := strings.Cut(kv, "=")
+ if ok {
+ env[k] = v
+ }
+ }
+ if env["CARLOS_CONTROL_SOCKET"] != f.rt.Socket+".control" {
+ t.Fatal("wrong role environment")
+ }
+ if _, ok := env["CARLOS_MOVE_PROBE"]; ok {
+ t.Fatal("probe-only input included in pin")
+ }
+ key, err := moves.ReportKeyFromSecrets(os.Getenv("CARLOS_SECRETS_KEY"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(report, moves.ReportEnv(key, env)) {
+ t.Fatal("report is not the returned launch environment")
+ }
+}
+
+// This record-backed contract double exposes the same method shape as
+// (*movesProbeOwners).InputPolicy. The owner itself lands with moves Plan 3.
+type plan10InputOwner struct {
+ ClaimWakeInputs
+ read MovesRecordReader
+ 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
+}
+
+func TestPlan10CommittedInputDispatch(t *testing.T) {
+ reader := &mpReader{rec: mpRecord()}
+ reader.rec.Move.State = provision.MoveCommitted
+ owner := &plan10InputOwner{read: reader.read}
+ serving := 0
+ legacy := func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error {
+ serving++
+ return hostbox.ErrChanged
+ }
+ out := ClaimOwnerBindings{InputPolicy: legacy, MovesPolicy: MovesClaimPolicy(reader.read), Moves: ClaimMovesOwners{ClaimKindOwners: ClaimKindOwners{Inputs: owner}}}
+ bindProductionClaimInputPolicy(func(string) string { return "1" }, &out, reader.read)
+ policy := hostbox.Policy{Inputs: out.InputPolicy, Moves: out.MovesPolicy, Held: func(context.Context, string) (string, error) { return "", nil }}
+ c := mpClaim(hostclaim.KindLanding, hostclaim.PhaseLandingCommitted)
+ c.Holder.WriterBox = mpTarget
+ s := mpSelection(c, "spawn")
+ id := HeldConfigIdentity(mpPins().BundleETags, mpPins().OverlayETag)
+ id.Release = mpPins().Release
+ if err := hostbox.WakePhase(c, mpTarget, false); err != nil {
+ t.Fatal("cold wake phase", err)
+ }
+ if err := policy.Check(t.Context(), c, s, id); err != nil {
+ t.Fatal("matching committed cold-spawn inputs", err)
+ }
+ for _, bad := range []hostwrite.Identity{{Config: "different", Release: id.Release}, {Config: id.Config, Release: "different"}} {
+ if err := policy.Check(t.Context(), c, s, bad); err == nil {
+ t.Fatal("mismatching committed inputs accepted")
+ }
+ }
+ if owner.calls != 3 || serving != 0 {
+ t.Fatalf("moves=%d serving=%d", owner.calls, serving)
+ }
+ for _, kind := range []string{hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindReverting, hostclaim.KindRestoring} {
+ c.Op.Kind = kind
+ s.Kind = kind
+ before := owner.calls
+ _ = out.InputPolicy(t.Context(), c, s, id)
+ if owner.calls != before+1 || serving != 0 {
+ t.Fatalf("%s fell back", kind)
+ }
+ }
+ c.Op.Kind = hostclaim.KindServing
+ s.Kind = c.Op.Kind
+ _ = out.InputPolicy(t.Context(), c, s, id)
+ if serving != 1 {
+ t.Fatal("serving lost own check")
+ }
+ c.Op.Kind = hostclaim.KindLanding
+ s.Kind = c.Op.Kind
+ missing := ClaimOwnerDispatch{Serving: ClaimKindOwners{InputPolicy: legacy}}
+ if err := missing.InputPolicy(t.Context(), c, s, id); err == nil || serving != 1 {
+ t.Fatal("missing move owner fell back")
+ }
+ for _, off := range []string{"CARLOS_HOST_CLAIM", "CARLOS_MOVES_PHASE1"} {
+ legacyOut := ClaimOwnerBindings{InputPolicy: legacy, MovesPolicy: MovesClaimPolicy(reader.read), Moves: out.Moves}
+ bindProductionClaimInputPolicy(func(k string) string {
+ if k == off {
+ return ""
+ }
+ return "1"
+ }, &legacyOut, reader.read)
+ if reflect.ValueOf(legacyOut.InputPolicy).Pointer() != reflect.ValueOf(legacy).Pointer() {
+ t.Fatal("flag-off callback changed")
+ }
+ if err := legacyOut.MovesPolicy(t.Context(), c, s, id); err == nil {
+ t.Fatal("flag-off committed spawn granted")
+ }
+ }
+}
+
+type plan10PinnedInputs struct {
+ plan9D2Inputs
+ identity hostwrite.Identity
+}
+
+func (p plan10PinnedInputs) Select(ctx context.Context, rt registry.Route, o hostclaim.Observed, policy activator.StartPolicy) (ClaimLaunchInputs, error) {
+ in, err := p.plan9D2Inputs.Select(ctx, rt, o, policy)
+ in.Identity = p.identity
+ return in, err
+}
+
+func TestPlan10CommittedColdAdmissionInputs(t *testing.T) {
+ for _, mismatch := range []bool{false, true} {
+ t.Run(fmt.Sprint(mismatch), func(t *testing.T) {
+ f := newRecoveryFixture(t)
+ f.recordPrelaunch = true
+ ctx := t.Context()
+ o, err := hostclaim.Update(ctx, f.claims, f.read(), func(c *hostclaim.Claim) error {
+ c.Op = hostclaim.Op{Kind: hostclaim.KindLanding, ID: "m1", Phase: hostclaim.PhaseLandingCommitted, Exec: "box-1", Participants: []string{"box-0", "box-1"}, Intent: map[string]string{"to": "ie", "source": "box-0", "target": "box-1"}}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := &provision.Record{Host: recoveryHost, Move: &provision.MoveState{ID: "m1", Incarnation: o.Claim.Holder.Incarnation, State: provision.MoveCommitted, Inputs: mpPins()}}
+ read := MovesRecordReader(func(context.Context, string, string, string) (*provision.Record, error) { return rec, nil })
+ owner := &plan10InputOwner{read: read}
+ bindings := ClaimOwnerBindings{InputPolicy: func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error {
+ t.Fatal("serving fallback")
+ return nil
+ }, Moves: ClaimMovesOwners{ClaimKindOwners: ClaimKindOwners{Inputs: owner}}}
+ 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
+ if mismatch {
+ id.Release = "different"
+ }
+ c.wake.Inputs = plan10PinnedInputs{plan9D2Inputs{recoveryInputs{f}}, id}
+ c.wake.Continue = hostclaim.SessionContinues
+ c.wake.Operations = &plan9D2Attempts{}
+ c.wake.ProbeReady = func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil }
+ source := hostbox.WorkSource{Guard: f.guard, Box: "box-1", Region: "ie", Now: func() time.Time { return f.now }, Policy: policy.Check, Local: func(context.Context, *hostclaim.Claim, hostbox.Selection) error { return nil }}
+ writer := hostbox.Writer{Claims: f.claims, Box: "box-1", Region: "ie", AdmissionEvidence: func(context.Context, hostclaim.Observed, hostclaim.Admission, *genselect.Plan) error { return nil }}
+ c.wake.mu.Lock()
+ c.wake.pending = nil
+ c.wake.mu.Unlock()
+ runtime, err := c.wake.Build(ctx, source, writer)
+ if err != nil {
+ t.Fatal(err)
+ }
+ live, cancel := context.WithCancel(ctx)
+ defer cancel()
+ done := make(chan struct{})
+ go func() { <-live.Done(); close(done) }()
+ c.wake.mu.Lock()
+ c.wake.pending[recoveryHost] = &claimWakeAttempt{observed: o, held: recoveryHeld{}, ctx: live, cancel: cancel, done: done}
+ c.wake.mu.Unlock()
+ admitted, err := runtime.Admit(ctx, registry.Route{Host: recoveryHost}, o, activator.StartPolicy{MaxStarts: 5, Window: 30 * time.Second})
+ if mismatch {
+ if err == nil {
+ t.Fatal("mismatching committed cold admission accepted")
+ }
+ } else if err != nil {
+ t.Fatal(err)
+ } else if admitted.Claim.Holder.Session <= o.Claim.Holder.Session || admitted.Claim.Holder.Generation == o.Claim.Holder.Generation {
+ t.Fatal("not a cold admission")
+ }
+ if owner.calls == 0 {
+ t.Fatal("admission bypassed moves input check")
+ }
+ })
+ }
+}
+```
+
+#### Patch: `internal/host/claimmoveparks_test.go`
+
+```diff
+--- a/internal/host/claimmoveparks_test.go
++++ b/internal/host/claimmoveparks_test.go
+@@ -32,7 +32,7 @@
+ failure error
+ }
+
+-func (p *parkRegistrationWake) Open(context.Context, release.Store, lease.VersionedStore, hostbox.Actor) (ClaimMovesOwners, ClaimFilesManifest, error) {
++func (p *parkRegistrationWake) Open(context.Context, release.Store, lease.VersionedStore, hostbox.Actor, ClaimMoveReadAccess) (ClaimMovesOwners, ClaimFilesManifest, error) {
+ p.opens++
+ return ClaimMovesOwners{}, nil, p.failure
+ }
+```
+
+#### New file: `internal/host/claimmoveaccess.go`
+
+```go
+package host
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "sort"
+ "sync"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// ClaimMoveReadAccess is retained by Open and becomes usable after Build.
+// Selection reads authority; effects still require the returned Work.With.
+type ClaimMoveReadAccess interface {
+ HeldWork(context.Context, string, string, hostguard.Captured) (hostwrite.Work, error)
+ Route(context.Context, string) (*registry.Route, error)
+ Launch(context.Context, string) (ClaimServingLaunch, moves.EnvReport, error)
+}
+
+type claimMoveReadAccess struct {
+ mu sync.RWMutex
+ bound bool
+ check func(context.Context) error
+ actor hostbox.Actor
+ source hostbox.WorkSource
+ route func(string) (*registry.Route, error)
+ launch func(context.Context, registry.Route) (ClaimServingLaunch, moves.EnvReport, error)
+}
+
+func (a *claimMoveReadAccess) bind(ctx context.Context, source hostbox.WorkSource) error {
+ if a == nil {
+ return hostwrite.ErrUnavailable
+ }
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if a.bound || a.check == nil || a.route == nil || a.launch == nil || source.Box != a.actor.Box || source.Region != a.actor.Region || source.Guard.Claims == nil {
+ return hostwrite.ErrUnavailable
+ }
+ if err := a.check(ctx); err != nil {
+ return err
+ }
+ // SelectAt calls Prepare. Clearing it prevents participation writes while
+ // retaining the same guard, policy, local checks and continuation table.
+ source.Prepare = nil
+ a.source, a.bound = source, true
+ return nil
+}
+
+func (a *claimMoveReadAccess) selected(ctx context.Context, host string) (hostbox.WorkSource, error) {
+ if a == nil || ctx == nil {
+ return hostbox.WorkSource{}, hostwrite.ErrUnavailable
+ }
+ a.mu.RLock()
+ source, bound := a.source, a.bound
+ a.mu.RUnlock()
+ if !bound {
+ return hostbox.WorkSource{}, hostwrite.ErrUnavailable
+ }
+ if err := a.check(ctx); err != nil {
+ return hostbox.WorkSource{}, err
+ }
+ o, err := source.Guard.Claims.Read(ctx, host)
+ if err != nil {
+ return hostbox.WorkSource{}, err
+ }
+ if o.Absent || o.Host != host || o.Claim == nil || o.Claim.Host != host || o.Claim.Holder == nil || o.Claim.Holder.Incarnation == "" || o.Claim.Op.Exec != a.actor.Box || !claimMovesKind(o.Claim.Op.Kind) {
+ return hostbox.WorkSource{}, hostbox.ErrChanged
+ }
+ for _, lost := range o.Claim.Lost {
+ if lost == a.actor.Box {
+ return hostbox.WorkSource{}, hostbox.ErrChanged
+ }
+ }
+ return source, nil
+}
+
+func (a *claimMoveReadAccess) Route(ctx context.Context, host string) (*registry.Route, error) {
+ if _, err := a.selected(ctx, host); err != nil {
+ return nil, err
+ }
+ rt, err := a.route(host)
+ if err != nil {
+ return nil, err
+ }
+ if rt == nil || rt.Host != host {
+ return nil, hostbox.ErrChanged
+ }
+ // Deep copy future reference fields as well as today's scalar fields.
+ raw, err := json.Marshal(rt)
+ if err != nil {
+ return nil, err
+ }
+ var out registry.Route
+ if err := json.Unmarshal(raw, &out); err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+func (a *claimMoveReadAccess) HeldWork(ctx context.Context, host, action string, captured hostguard.Captured) (hostwrite.Work, error) {
+ switch action {
+ case movesActionHeldConfig, movesActionHeldRelease:
+ default:
+ return nil, hostbox.ErrUnsupported
+ }
+ source, err := a.selected(ctx, host)
+ if err != nil {
+ return nil, err
+ }
+ work, err := source.SelectAt(ctx, host, action, captured)
+ if err != nil {
+ return nil, err
+ }
+ return claimMoveReadWork{access: a, host: host, work: work}, nil
+}
+
+type claimMoveReadWork struct {
+ access *claimMoveReadAccess
+ host string
+ work hostwrite.Work
+}
+
+func (w claimMoveReadWork) With(ctx context.Context, id hostwrite.Identity, fn func() error) error {
+ if _, err := w.access.selected(ctx, w.host); err != nil {
+ return err
+ }
+ return w.work.With(ctx, id, fn)
+}
+
+func (a *claimMoveReadAccess) Launch(ctx context.Context, host string) (ClaimServingLaunch, moves.EnvReport, error) {
+ rt, err := a.Route(ctx, host)
+ if err != nil {
+ return ClaimServingLaunch{}, moves.EnvReport{}, err
+ }
+ launch, report, err := a.launch(ctx, *rt)
+ if err != nil {
+ return ClaimServingLaunch{}, moves.EnvReport{}, err
+ }
+ launch.Args = append([]string(nil), launch.Args...)
+ launch.Env = append([]string(nil), launch.Env...)
+ vars := make(map[string]string, len(report.Vars))
+ for k, v := range report.Vars {
+ vars[k] = v
+ }
+ report.Vars = vars
+ return launch, report, nil
+}
+
+func claimMoveLaunchSource(backend *activator.ExecBackend) func(context.Context, registry.Route) (ClaimServingLaunch, moves.EnvReport, error) {
+ return func(ctx context.Context, rt registry.Route) (ClaimServingLaunch, moves.EnvReport, error) {
+ // Snapshot refuses a missing token. This path must never mint one.
+ snapshot, err := backend.SnapshotClaimLaunch(ctx, rt)
+ if err != nil {
+ return ClaimServingLaunch{}, moves.EnvReport{}, err
+ }
+ env, _, err := backend.LaunchEnvironment(rt)
+ if err != nil {
+ return ClaimServingLaunch{}, moves.EnvReport{}, err
+ }
+ key, err := moves.ReportKeyFromSecrets(os.Getenv("CARLOS_SECRETS_KEY"))
+ if err != nil {
+ return ClaimServingLaunch{}, moves.EnvReport{}, err
+ }
+ delete(env, "CARLOS_MOVE_PROBE")
+ entries := make([]string, 0, len(env))
+ for k, v := range env {
+ entries = append(entries, k+"="+v)
+ }
+ sort.Strings(entries)
+ raw, err := json.Marshal(entries)
+ if err != nil {
+ return ClaimServingLaunch{}, moves.EnvReport{}, err
+ }
+ digest := sha256.Sum256(raw)
+ snapshot.Identity.Config = hex.EncodeToString(digest[:])
+ return ClaimServingLaunch{Identity: snapshot.Identity, Executable: snapshot.Executable, ExecutableSHA256: snapshot.SHA256, Args: snapshot.Args, Env: entries, Version: snapshot.Version, Limit: snapshot.Limit}, moves.ReportEnv(key, env), nil
+ }
+}
+
+// ClaimInputPolicyOwner is implemented by (*movesProbeOwners).InputPolicy.
+// The moves owner chooses each kind's record pins; serving has no fallback.
+type ClaimInputPolicyOwner interface {
+ InputPolicy(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error
+}
+
+func (d ClaimOwnerDispatch) InputPolicy(ctx context.Context, c *hostclaim.Claim, s hostbox.Selection, id hostwrite.Identity) error {
+ if c == nil || c.Holder == nil || s.Host != c.Host || s.Kind != c.Op.Kind || s.Captured.Incarnation != c.Holder.Incarnation || s.Captured.Stamp != c.Holder.Stamp {
+ return hostbox.ErrChanged
+ }
+ switch {
+ case c.Op.Kind == hostclaim.KindServing:
+ if d.Serving.InputPolicy == nil {
+ return hostwrite.ErrUnavailable
+ }
+ return d.Serving.InputPolicy(ctx, c, s, id)
+ case claimMovesKind(c.Op.Kind):
+ if d.Moves == nil {
+ return claimOperationRefusal()
+ }
+ owner, ok := d.Moves.Inputs.(ClaimInputPolicyOwner)
+ if !ok {
+ return claimOperationRefusal()
+ }
+ return owner.InputPolicy(ctx, c, s, id)
+ default:
+ return hostbox.ErrUnsupported
+ }
+}
+
+func bindProductionClaimInputPolicy(getenv func(string) string, out *ClaimOwnerBindings, read MovesRecordReader) {
+ if !hostwrite.Enabled(getenv) || !moves.Enabled(getenv) {
+ return
+ }
+ dispatch := ClaimOwnerDispatch{Serving: ClaimKindOwners{InputPolicy: out.InputPolicy}, Moves: &out.Moves.ClaimKindOwners}
+ out.InputPolicy = dispatch.InputPolicy
+ // Policy.Check dispatches move actions to Moves, never Inputs directly.
+ out.MovesPolicy = MovesClaimPolicyWithInputs(read, dispatch.InputPolicy)
+}
+```
+
+#### Patch: `internal/host/claimowners.go`
+
+```diff
+--- a/internal/host/claimowners.go
++++ b/internal/host/claimowners.go
+@@ -24,6 +24,7 @@
+ // ClaimKindOwners is one family of op kinds' input, admission and
+ // publication owners (claim Plan 9 D5, moves ruling H16).
+ type ClaimKindOwners struct {
++ InputPolicy func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error
+ Inputs ClaimWakeInputs
+ AdmissionEvidence func(context.Context, hostclaim.Observed, hostclaim.Admission, *genselect.Plan) error
+ PublicationEvidence func(context.Context, hostclaim.Observed) error
+```
+
+#### Patch: `internal/host/claimproduction.go`
+
+```diff
+--- a/internal/host/claimproduction.go
++++ b/internal/host/claimproduction.go
+@@ -33,7 +33,7 @@
+ // They do not grant local process or roster authority. Group D's public owner
+ // and OperationAttempts signatures are unchanged.
+ type ClaimProductionMoveSources interface {
+- Open(context.Context, release.Store, lease.VersionedStore, hostbox.Actor) (ClaimMovesOwners, ClaimFilesManifest, error)
++ Open(context.Context, release.Store, lease.VersionedStore, hostbox.Actor, ClaimMoveReadAccess) (ClaimMovesOwners, ClaimFilesManifest, error)
+ View(context.Context, hostclaim.Observed) (park.View, error)
+ VerifyRetirement(context.Context, hostclaim.Observed, []lease.ObjectVersion) (string, time.Time, error)
+ LeaseSet(context.Context, string, string) ([]string, error)
+@@ -143,6 +143,9 @@
+ tokens := claimAdminTokens{Guard: hostguard.Guard{DataDir: r.DataDir, Claims: hostguard.StoreReader{Store: store}}, Claims: store, Verify: out.Identity.Verify, Local: out.Identity.Local(local), Policy: hostbox.Policy{Held: hostbox.RegistryHoldPolicy(r.Registry)}.Check, Now: time.Now}
+ out.Serving.Launch = claimServingLaunchSource(backend, tokens)
+ out.Serving.Markers = marker
++ if hostwrite.Enabled(claimEntryEnv(ctx)) && moves.Enabled(claimEntryEnv(ctx)) {
++ out.moveRead = &claimMoveReadAccess{check: claimBoundIdentity(out.Identity.Verify, actor), actor: actor, route: r.Registry.Get, launch: claimMoveLaunchSource(backend)}
++ }
+ claimMoveSources.Lock()
+ external := claimMoveSources.Source
+ parks := claimMoveSources.Parks
+@@ -150,7 +153,7 @@
+ 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)
++ out.Moves, out.Serving.Files, err = external.Open(ctx, store, out.Data, actor, out.moveRead)
+ if err != nil {
+ return out, err
+ }
+@@ -190,6 +193,7 @@
+ return nil
+ }
+ out.MovesPolicy = MovesClaimPolicy(MovesRecordsFrom(store))
++ bindProductionClaimInputPolicy(claimEntryEnv(ctx), &out, MovesRecordsFrom(store))
+ regional := productionClaimRegional{Identity: out.Identity, Parks: parks, Local: local}
+ out.Evidence = regional
+ out.LeaseSet = regional.LeaseSet
+```
+
+#### Patch: `internal/host/claimmoveparks.go`
+
+```diff
+--- a/internal/host/claimmoveparks.go
++++ b/internal/host/claimmoveparks.go
+@@ -49,7 +49,7 @@
+ 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)
++ owners, files, err := external.Open(ctx, store, out.Data, actor, out.moveRead)
+ if err != nil {
+ out.wakeUnavailable = errors.Join(&ClaimDependencyError{Contracts: []string{"moves wake owners and C8 origin-qualified files manifest provider"}}, err)
+ return
+```
+
+#### 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 {
++ moveRead *claimMoveReadAccess
+ wakeUnavailable error
+ cleanupEnrollment *claimCleanupEnrollment
+ RetirementAuthority *hostops.ProductionAuthority
+@@ -314,7 +315,7 @@
+ stop.Claims = hostguard.StoreReader{Store: store}
+ stop.Inventory = inventory
+ stop.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}
++ assembly := &ClaimAssembly{moveRead: owners.moveRead, 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 {
+```
+
+#### 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 {
++ moveRead *claimMoveReadAccess
+ wakeUnavailable error
+ Journals *hostbox.JournalService
+ cleanupEnrollment *claimCleanupEnrollment
+@@ -184,6 +185,11 @@
+ source.Prepare = local.Prepare
+ a.Reconcile.Reconciler.Writes = hostwrite.Config{Enabled: true, Selector: source}
+ }
++ if a.moveRead != nil {
++ if err := a.moveRead.bind(ctx, source); err != nil {
++ return ClaimProviders{}, err
++ }
++ }
+ a.Wake.LeaseRegion = actor.Cloud.Region
+ wake := refusingRootWake(actor.Box, a.Claims)
+ if (a.Mode == "agent" || a.Mode == "") && a.wakeUnavailable == nil {
+```
+
+#### Patch: `internal/host/movespolicy.go`
+
+```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
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.
+Claim Plan 9 and moves Plans 4 and 5 are already on the required main base. G2 and committed-wake integration still require moves Plan 3 Tasks 5–7: pass the new access argument to `Open`, retain it until arming, and register the concrete `movesProbeOwners` input-policy method. Exercise a committed cold wake after the original probe has died with both matching and mismatching `Move.Inputs`. 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.
@@ -11718,6 +12574,8 @@ 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.
+
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.
@@ -11811,11 +12669,16 @@ The displayed transition token `move-target-lost` is new. It identifies the prop
## Verification and self-review
+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.
+
+
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.
+The following rev 2/rev 3 notes are historical validation.
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.
@@ -11867,4 +12730,4 @@ CLEANUP
GIT_OPTIONAL_LOCKS=0 git status --short
```
-The authoring worktree must show only `docs/superpowers/plans/2026-10-09-host-lifecycle-claim-10-moves-recovery-grants.md`. Do not update a handoff file, format another repository file or write git state as part of this plan-only task.
+This task must add changes only to `docs/superpowers/plans/2026-10-09-host-lifecycle-claim-10-moves-recovery-grants.md`; preserve any unrelated changes already present in the worktree. Do not update a handoff file, format another repository file or write git state as part of this plan-only task.
diff --git a/docs/superpowers/plans/2026-10-09-host-lifecycle-claim-11-region-ha-integration.md b/docs/superpowers/plans/2026-10-09-host-lifecycle-claim-11-region-ha-integration.md
index d779b6fd0..fffd035ff 100644
--- a/docs/superpowers/plans/2026-10-09-host-lifecycle-claim-11-region-ha-integration.md
+++ b/docs/superpowers/plans/2026-10-09-host-lifecycle-claim-11-region-ha-integration.md
@@ -1,11 +1,13 @@
# Host lifecycle claim, Plan 11: region HA integration
-Implementation plan, 2026-10-09, rev 3. This implements the claim session's accepted answers in amadan discussion #236: R1-R5, the narrowed R8, R10, `relocation-release` and `enrolled_at`. Claim spec rev 6.31 is normative. This plan authorizes no production changes.
+Implementation plan, 2026-10-09, rev 4. This implements the claim session's accepted answers in amadan discussion #236: R1-R5, the narrowed R8, R10, `relocation-release` and `enrolled_at`. Claim spec rev 6.31 is normative. This plan authorizes no production changes.
**Rev 2 — review round 1:** answers both P1 and both P2 findings in Task D's R10 root launch, with executable code and failing-first regressions. The published region HA signatures are unchanged. The root launch protocol capability becomes `isolated-unit-v2`, and internal root/kernel/unit interfaces are extended; agent and root must be upgraded together. The mandatory Task D amendment runs after G. Kinds without a root-side authoritative attempt verifier are refused; production operation launches remain gated on those verifiers.
**Rev 3 — review round 2:** answers both P1 and both P2 findings in `findings-plan11-r2.md`. Task C/D amendment below adds a root-authenticated reservation handshake at the trampoline's `ForkExec` boundary, a final boot-relative deadline check, directory-prefix no-process coverage, tenant-child observation within the pinned invocation, and stopped-unit cleanup before flag-off legacy starts. The published region HA signatures remain unchanged. Root's internal launch capability becomes `isolated-unit-v3`; root, agent and trampoline must be upgraded together. Replay A–G, D2, then D3R and D3. The Rev 1/2 code and results below are historical stages; the Rev 3 amendment is mandatory.
+**Rev 4 note - review round 3 and the rev 4 addendum:** answers both P1 findings (9-CG containment ownership and writers-stopped prefix coverage), the P2 sidecar inspection finding, and the P3 mask-object identity finding in `findings-plan11-r3.md` and `after236/plan11-brief.md`. Root derives the permitted containment parent from the checked claim tuple, validates journal additions, and revalidates persisted targets before privileged recovery. Both root witnesses use prefix-aware descriptor collection. Sidecar inspection errors propagate before authority is acquired. Migration recovery requires the actual mask object's identity. The published region HA signatures remain unchanged; the internal `ClaimLaunchRoles` signature gains an error result. Replay A-G, D2R/D2, D3R/D3, then every D4 amendment below. Rev 1-3 implementations are intermediate stages, not an activation-ready result.
+
## Interfaces for region HA
```go
@@ -72,7 +74,7 @@ Root launch uses `activator.RootRoleLauncher`, `hostbox.RoleLaunchRequest` and `
## Base and accepted scope
-Replay against `origin/feat/hostclaim-activation` at `ef3c4bff7d612c5bb4584713fef40bc0cb5a43ca`. This is the exported Plan 9 branch, including its merge of main. The observed `origin/main` was `412ca787cbfc8551eb33a59c4e9606dbfa159b90`; no refs were fetched or changed during authoring. These hashes identify the tested input, not a production release or an approved floor.
+Replay against `origin/main` history at `da76647be42e2b330f3ba425333f7a38a6ddcc52` (`moves-phase1-5`). This satisfies the requested `da76647b`-or-later base. The observed `origin/main` was `a3bf7d6c0038094495a58d3d18623fd1568b90d4`; its later zero-downtime changes conflict with Task D's older `claimwake.go` hunk, so this replay deliberately pins the permitted earlier main commit. No refs were fetched or changed. Rev 1-3 authoring results below refer to their historical `ef3c4bff` base; the Rev 4 results cover this main-based replay. These hashes identify tested input, not a production release or an approved floor.
The region HA requests were read from `docs/superpowers/plans/2026-10-07-region-ha-1b-next-relocation.md` on `origin/region-ha-1b-next-build` at `b346819704df502b17c42955149d69067ff34990`, including its tests and Requests section. The two-box model input is `origin/region-ha-two-box-build` at `cef63683cb4cecf9376be1a050314ad950acb61b`. Its round 3 review closes J1; it does not close J2/J3. Paul accepted J2/F4, J3/F5 and the R6 refusal in #236 on 2026-10-09. The claim session then accepted the scoped interfaces below.
@@ -108,7 +110,7 @@ import io, os, pathlib, subprocess, tarfile
root = pathlib.Path(os.environ['CLAIM11_SCRATCH']) / 'overlay'
root.mkdir()
raw = subprocess.check_output(['git', '-C', os.environ['CLAIM11_REPO'],
- 'archive', 'ef3c4bff7d612c5bb4584713fef40bc0cb5a43ca'])
+ 'archive', 'da76647be42e2b330f3ba425333f7a38a6ddcc52'])
with tarfile.open(fileobj=io.BytesIO(raw)) as archive:
archive.extractall(root, filter='data')
PY
@@ -129,7 +131,7 @@ blocks = [body for group, stage, body in re.findall(pattern, text, re.S)
if not blocks:
raise SystemExit('no matching task blocks')
for body in blocks:
- subprocess.run(['patch', '--batch', '--forward', '-p1'],
+ subprocess.run(['patch', '--batch', '--forward', '--fuzz=0', '-p1'],
input=(body + '\n').encode(), check=True)
```
@@ -15141,73 +15143,2387 @@ nice -n 10 go test -p 1 -timeout 5m ./internal/host ./internal/instancectl ./int
Expected: all round 2 regressions and the existing launch/witness tests pass. The flag-off transition consumes the new mutable launch file's argv/environment after removing the old frozen values. No production systemd unit or namespace is created by these tests; test the actual bind setup and root UID handshake in the disposable rollout environment before activation.
-## Region HA integration and rollout
-
-Keep all flags off while landing the code. Registration alone does not activate relocation. The rollout still needs Plan 9's verified identity, complete owners, activation census and floor guard. Region HA additionally needs `CARLOS_REGION_HA`; this implementation shares Group D with `CARLOS_MOVES_PHASE1`, so it requires that group to be complete too. Missing owners, an older root protocol, unsupported role shapes and incomplete stop evidence all refuse.
-
-The claim-enabled protocol in Task D carries a main role in main's isolated run unit, using the nested instance layout and a supported memory tier. Seed, sidecar and other layouts remain refused where the reservation cannot describe them. The existing flag-off `/start`, `/seed`, process backend and unit templates retain their existing path. Staged builds must also be root-owned and not writable by the agent or tenant. Verify the deployed stager satisfies that rule before activation. Do not remove those refusals to make a probe pass. Extend the reservation and root verification first if another shape is required.
-
-Before region HA stage B is armed, install and test root-owned authoritative attempt readers for each enabled operation kind, including the prepared executable/input digest and the exact operation/session/install identity. Until then, the new per-kind preflight refuses those launches before admission or attempt reservation. Keep the existing region HA adapter signatures. Also prove the root-owned input snapshot bind, same-invocation tenant-child wait, trampoline reservation/deadline check and flag-off snapshot teardown against the deployed unit template in the disposable environment.
+## Task C/D amendment (Rev 4): round 3 and addendum
-Rebase the stage B adapters onto these interfaces and test these joins:
+Apply all parts after D3. The two P1 findings concern independent root authority checks: containment ownership and descriptor coverage. The P2 finding concerns incomplete role inspection; the P3 finding concerns recovery of a migration mask. Each part includes a behavioral red run before its fix. Additional tests that need a new internal interface follow the behavioral red run, before implementation.
-1. Replace its duplicate `AutomaticLoss`, `LocalDischarge`, loss-ID helper and `Claim.EvidenceRefs` with Task A's canonical definitions. Preserve its fence-consuming transitions, D4 retention and strict decode tests.
-2. Register the complete relocation owner set once. Route the probe through `OperationAttempts`; do not apply the old `Extend` hook or introduce a separate probe lifecycle. Keep the same admitted session through `relocating/probing -> relocating/serving`. The built region branch currently commits directly to kind `serving`; that kind change ends this session and requires a separate serving wake. It must not be treated as an implicit continuation.
-3. Reconcile its prepared-generation and `released` coverage admission with the base's generic admission. This plan permits only the accepted cold probe shape. It does not import region HA's transition producer or grant a new coverage authority. An adapter cannot bypass the gate with an optional policy callback.
-4. For a drain, call `ClaimAssembly.WritersStopped`, register the files publisher under `ClaimProducerFiles`, retain replication and file publication, and use root's observation. Drop the region branch's duplicate descriptor observer and consume the claim-owned `(TID, Device, Inode)` descriptor shape. Confirm with the live Litestream witness that descriptors opened lazily after `Replicate` do not leave the narrowing hint incomplete. An incomplete hint refuses the observation. After release publication, queue the stop-only obligation and request the full root stop. Pass its immutable key and digest to `RelocationReleaseReceipts.Complete`, then perform the region-owned executor transition. Never use a writers-only witness as a full stop receipt.
-5. A release without its exact root receipt remains pending, including if the source terminates before producing it. Plan 11 grants no fence-only discharge for this new obligation. That receipt-only rule is the accepted contract; the region-owned termination transition must account for it before activation. It must not silently drop the obligation or route it through generic chore completion.
-6. Preserve the source's files live set until the authoritative record commits. Moves Plan 5 must replace the stand-in view and prove the push, landing-install and rollback-return park exclusions at each destructive step. The model's passing J1 tests do not implement those production exclusions.
-7. Keep the R6 no-LTX refusal for every positive writer session. Keep volume-based automatic discharge refused for legacy roster entries with unknown enrollment time. Publish no invented timestamp or empty generation.
+### D4W: retain directory prefixes in writers-stopped observations
-The release floor must include claim-level `evidence_refs` and its nested `AutomaticLoss` fields, roster `enrolled_at`, the `relocation-release` kind, and the launch journal and protocol additions, including `writers_closed`, `close-writers`, the `spec` preimage, capability-only `kind`, and `isolated-unit-v3`, the mandatory trampoline permit, and its internal root/unit handshake. Publish it only after every enrolled actor and console can strictly decode these fields, the signed census proves the same executable set, and the existing guard transaction has armed the new floor. Do not substitute the branch's commit hash for a release tag or floor. Old binaries must not participate after publication. Rollback must respect the published floor.
+Invariant: every descriptor below the installation's validated state directories participates in root's scan, including nested files and files created after launch and then unlinked. Replicator hints exempt only the actual descriptors root observes, with their process, task, FD, device and inode identities intact.
-Run the region HA two-box model and its joint review after the stage B adapters are rewritten. Then exercise isolated launch, cancellation, root crash recovery, writers-only stop and full stop in a designated disposable systemd/cgroup environment. This authoring session used local mocks and kernel fixtures; it did not create or modify production infrastructure.
+Replace the PID-only prefix scan with `stopevidence.HoldersWithPrefixes`. Both `verifyWritersAt` and `verifyNoProcessAt` use it with the validated inventory prefixes, current paths and any journaled file identities. The scan keeps path-component boundaries, scans an empty current directory, and refuses unreadable process tables. A name match with unavailable identity cannot be exempted by a hint. Existing journal and cgroup checks still bracket the writers observation.
-## Final verification
+The regressions open real files and inspect the kernel's descriptor names after unlink, so a dangling fixture symlink cannot supply a false red. They also prove that an authorized replicator's deleted descriptor retains its identity and that a wrong inode hint is refused.
-After all tasks, run the following in the disposable export. Run commands serially; no background test jobs.
+<!-- apply: D4W tests -->
+```diff
+--- /dev/null
++++ b/internal/host/claimstop_writers_rev4_linux_test.go
+@@ -0,0 +1,158 @@
++//go:build linux
++
++package host
++
++import (
++ "errors"
++ "os"
++ "path/filepath"
++ "strconv"
++ "testing"
++
++ "github.com/carlosframework/platform/internal/stopevidence"
++)
++
++func TestRev4WritersPrefixCoverage(t *testing.T) {
++ for _, shape := range []string{"deleted-wal", "nested", "deleted-nested", "empty-directory"} {
++ t.Run(shape, func(t *testing.T) {
++ fx := newWritersKernelFixture(t)
++ fx.dropAgentHolds(t)
++ // No journal identity exists for a file first opened after launch.
++ fx.journal.Files = nil
++ target := fx.db + "-wal"
++ if shape == "nested" || shape == "deleted-nested" {
++ target = filepath.Join(filepath.Dir(fx.db), "sqlite", "later.db")
++ }
++ if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
++ t.Fatal(err)
++ }
++ if err := os.WriteFile(target, nil, 0600); err != nil {
++ t.Fatal(err)
++ }
++ held, err := os.Open(target)
++ if err != nil {
++ t.Fatal(err)
++ }
++ defer held.Close()
++ if shape != "nested" {
++ if err := os.Remove(target); err != nil {
++ t.Fatal(err)
++ }
++ target += " (deleted)"
++ }
++ if shape == "empty-directory" {
++ if err := os.Remove(fx.db); err != nil {
++ t.Fatal(err)
++ }
++ }
++ // A real descriptor retains its inode after unlink, unlike a dangling fixture symlink.
++ pid := strconv.Itoa(os.Getpid())
++ if err := os.Symlink(filepath.Join("/proc", pid), filepath.Join(fx.proc, pid)); err != nil {
++ t.Fatal(err)
++ }
++ if _, err := fx.verify(t); !errors.Is(err, stopevidence.ErrForeignHolder) {
++ t.Fatalf("foreign %s holder passed writers-stopped: %v", shape, err)
++ }
++ })
++ }
++}
++
++func TestRev4WritersPrefixRetainsDescriptorIdentity(t *testing.T) {
++ fx := newWritersKernelFixture(t)
++ target := filepath.Join(filepath.Dir(fx.db), "sqlite", "later.db")
++ if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
++ t.Fatal(err)
++ }
++ if err := os.WriteFile(target, nil, 0600); err != nil {
++ t.Fatal(err)
++ }
++ rev3HoldTarget(t, fx, 20, 8, target)
++ dev, ino := fileID(t, target)
++ fx.hints = append(fx.hints, stopevidence.DescriptorHint{FD: 8, Device: dev, Inode: ino})
++ got, err := fx.verify(t)
++ if err != nil {
++ t.Fatal(err)
++ }
++ want := stopevidence.Descriptor{PID: 20, TID: 20, FD: 8, Device: dev, Inode: ino}
++ if len(got.Retained) != 2 || got.Retained[1] != want {
++ t.Fatalf("lost prefix descriptor identity: %+v", got.Retained)
++ }
++ fx.hints = fx.hints[:1]
++ if _, err := fx.verify(t); !errors.Is(err, stopevidence.ErrForeignHolder) {
++ t.Fatalf("unhinted prefix: %v", err)
++ }
++}
++
++func TestRev4WritersPrefixBoundaryAndScanErrors(t *testing.T) {
++ t.Run("sibling", func(t *testing.T) {
++ fx := newWritersKernelFixture(t)
++ target := filepath.Join(filepath.Dir(fx.db)+"-old", "other.db")
++ if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
++ t.Fatal(err)
++ }
++ if err := os.WriteFile(target, nil, 0600); err != nil {
++ t.Fatal(err)
++ }
++ rev3HoldTarget(t, fx, 31, 8, target)
++ if _, err := fx.verify(t); err != nil {
++ t.Fatalf("sibling matched: %v", err)
++ }
++ })
++ t.Run("empty-unreadable", func(t *testing.T) {
++ fx := newWritersKernelFixture(t)
++ fx.dropAgentHolds(t)
++ for _, p := range []string{fx.db, fx.db + "-wal"} {
++ if err := os.Remove(p); err != nil {
++ t.Fatal(err)
++ }
++ }
++ dir := filepath.Join(fx.proc, "31", "task", "31", "fd")
++ if err := os.MkdirAll(dir, 0700); err != nil {
++ t.Fatal(err)
++ }
++ if err := os.WriteFile(filepath.Join(dir, "8"), nil, 0600); err != nil {
++ t.Fatal(err)
++ }
++ if _, err := fx.verify(t); err == nil {
++ t.Fatal("unreadable table yielded writers-stopped")
++ }
++ })
++}
++
++func TestRev4WritersDeletedReplicatorIdentity(t *testing.T) {
++ fx := newWritersKernelFixture(t)
++ fx.dropAgentHolds(t)
++ path := fx.db + "-wal"
++ held, err := os.Open(path)
++ if err != nil {
++ t.Fatal(err)
++ }
++ defer held.Close()
++ dev, ino := fileID(t, path)
++ if err := os.Remove(path); err != nil {
++ t.Fatal(err)
++ }
++ // Keep one real kernel descriptor table under the authenticated fixture
++ // process. Readlink reports the deleted name; Stat still reaches the inode.
++ fdDir := filepath.Join(fx.proc, "20", "task", "20", "fd")
++ if err := os.Remove(fdDir); err != nil {
++ t.Fatal(err)
++ }
++ if err := os.Symlink("/proc/self/fd", fdDir); err != nil {
++ t.Fatal(err)
++ }
++ hint := stopevidence.DescriptorHint{FD: int(held.Fd()), Device: dev, Inode: ino}
++ fx.hints = []stopevidence.DescriptorHint{hint}
++ got, err := fx.verify(t)
++ if err != nil {
++ t.Fatal(err)
++ }
++ want := stopevidence.Descriptor{PID: 20, TID: 20, FD: hint.FD, Device: dev, Inode: ino}
++ if len(got.Retained) != 1 || got.Retained[0] != want {
++ t.Fatalf("deleted replicator identity: %+v", got.Retained)
++ }
++ fx.hints[0].Inode++
++ if _, err := fx.verify(t); !errors.Is(err, stopevidence.ErrForeignHolder) {
++ t.Fatalf("substituted hint exempted deleted file: %v", err)
++ }
++}
+```
```bash
-nice -n 10 go test -p 1 -timeout 5m ./internal/hostactors ./internal/hostclaim ./internal/hostbox ./internal/hostguard ./internal/stopevidence ./internal/instancectl ./internal/instanceexec -count=1
-nice -n 10 go test -p 1 -timeout 5m ./internal/host ./internal/activator -run 'TestRev3|TestPlan11|TestRev2Shape|TestStopWriters|TestWriters|TestVerifyWriters|TestVerifyNoProcess|TestNoProcess|TestRootRole|TestRootClaimProcess|TestClaimLaunch|TestClaimUnitStop|TestClaimStop|TestClaimProducer|TestPlan9D|TestClaimIsolation' -count=1
-nice -n 10 go vet ./internal/hostactors ./internal/hostclaim ./internal/hostbox ./internal/hostguard ./internal/stopevidence ./internal/instancectl ./internal/instanceexec ./internal/host ./internal/activator
-nice -n 10 go build ./internal/host ./internal/activator ./internal/instancectl ./internal/instanceexec
-GOOS=darwin GOARCH=arm64 nice -n 10 go build ./internal/host ./internal/activator ./internal/instancectl ./internal/instanceexec
-GOOS=windows GOARCH=amd64 nice -n 10 go build ./internal/host ./internal/activator ./internal/instancectl ./internal/instanceexec
+python3 "$CLAIM11_SCRATCH/apply.py" "$CLAIM11_PLAN" D4W tests
+nice -n 10 go test -p 1 -timeout 5m ./internal/host -run 'TestRev4Writers' -count=1
```
-Check `gofmt -l` on every changed Go file. Before a later implementation commit lands, run the repository's full build, vet, test and formatting gate and obtain green amadan CI on that tip. This plan's focused overlay verification does not replace that landing gate.
-
-### Rev 1 authoring results
-
-The plan was replayed from a fresh export of the pinned base. All fourteen test and implementation patch blocks applied in order, and the resulting changed Go files matched the tested combined overlay byte for byte. No git index or ref was changed.
-
-The supporting packages `hostactors`, `hostclaim`, `hostbox`, `hostguard`, `stopevidence` and `instancectl` passed their full package tests. The focused host and activator set passed, including Plan 9 dispatch, writers-only stop, descriptor and no-process observations, root launch, receipt validation and disabled-flag regressions. Vet passed for all eight packages. Builds of host, activator and instancectl passed on Linux and cross-compiled for darwin/arm64 and windows/amd64. Formatting was checked on all changed files.
-
-Red runs first exposed the missing record, enrollment, dispatch, writer-stop, observation and launch interfaces. Integration regressions then demonstrated mutable policy authority, cached witness aliasing, an omitted isolated cgroup, hidden root observation interfaces, a stale console enrollment time, refusal of a valid session-zero release, recovery settling an unobserved unit, and a probe launch before the executor became the admitted writer. Each corresponding guard was fixed and its focused tests passed. The final plan places every test block before its implementation.
-
-Some early delegated socket runs failed because of sandbox restrictions or long Unix socket paths; the parent reran the focused combined set under its short disk-backed TMPDIR and it passed. The long `TestClaimCompletedProducersBoundStopAndRetry` test exceeded an early delegated timeout; it is outside the final focused set. No production systemd unit, deployment, bucket, roster or floor was changed. Real unit isolation and the joint two-box stage B activation remain rollout checks, not claimed authoring results.
-
-### Rev 2 authoring results
-
-The four R10 behavioral regressions were run against the completed Rev 1 overlay first. They failed with the expected behavioral assertions, including the premature kill of a slow valid start. The amendment's successful root fixtures now contain authoritative attempts. Its additional tests cover both serving and operation tuples, missing verifiers, input and executable substitution, pending/lost/expired participation, loss of the live lock, state changes during unit preparation, namespace/exec delay, timeout, cancellation, PID reuse and invocation replacement, and snapshot binding/cleanup.
+Expected: Rev 3 falsely accepts deleted WAL, nested, deleted-nested and empty-directory holders, omits prefix descriptor identities, and skips an unreadable table when the listing is empty. The path-boundary control remains successful.
-All seventeen patch blocks replayed from the pinned base with zero fuzz. The resulting Go files matched the tested combined overlay byte for byte, and formatting was clean. The full supporting-package tests passed for `hostactors`, `hostclaim`, `hostbox`, `hostguard`, `stopevidence` and `instancectl`; the expanded focused host/activator set passed, including the amendment, launch, retry/drain and Plan 9 dispatch regressions. The focused amendment/launch set also passed from the fresh replay. Vet passed for all eight packages. Host, activator and instancectl built on Linux and cross-compiled for darwin/arm64 and windows/amd64.
+<!-- apply: D4W implementation -->
+```diff
+--- a/internal/host/claimstop_writers_linux.go
++++ b/internal/host/claimstop_writers_linux.go
+@@ -12,7 +12,6 @@
+ "path/filepath"
+ "reflect"
+ "sort"
+- "strconv"
+ "strings"
+ "syscall"
+ "time"
+@@ -107,7 +106,7 @@
+ if err != nil {
+ return WritersKernelEvidence{}, err
+ }
+- paths, err := installationFiles(roots.Managed, inv, true)
++ paths, prefixes, err := installationCoverage(roots.Managed, inv, true)
+ if err != nil {
+ return WritersKernelEvidence{}, err
+ }
+@@ -116,8 +115,8 @@
+ files = append(files, stopevidence.FileIdentity{Device: f.Device, Inode: f.Inode})
+ }
+ retained := []stopevidence.Descriptor{}
+- if len(paths)+len(files) > 0 {
+- held, err := stopevidence.Holders(ctx, roots.Proc, paths, files)
++ if len(paths)+len(prefixes)+len(files) > 0 {
++ held, err := stopevidence.HoldersWithPrefixes(ctx, roots.Proc, paths, prefixes, files)
+ if err != nil {
+ return WritersKernelEvidence{}, err
+ }
+@@ -160,24 +159,14 @@
+ if len(paths)+len(prefixes) == 0 {
+ return StopKernelEvidence{}, ErrClaimStopInventory
+ }
+- if len(paths) > 0 {
+- held, err := stopevidence.Holders(ctx, roots.Proc, paths, nil)
+- if err != nil {
+- return StopKernelEvidence{}, err
+- }
+- if len(held) != 0 {
+- return StopKernelEvidence{}, fmt.Errorf("%w: pid %d holds an installation file", ErrClaimStopIdentity, held[0].PID)
+- }
+- }
+- if len(prefixes) > 0 {
+- prefixHeld, err := installationPrefixHolders(ctx, roots.Proc, prefixes)
+- if err != nil {
+- return StopKernelEvidence{}, err
+- }
+- if len(prefixHeld) != 0 {
+- return StopKernelEvidence{}, fmt.Errorf("%w: pid %d holds an installation file", ErrClaimStopIdentity, prefixHeld[0])
+- }
+- }
++ held, err := stopevidence.HoldersWithPrefixes(ctx, roots.Proc, paths, prefixes, nil)
++ if err != nil {
++ return StopKernelEvidence{}, err
++ }
++ if len(held) != 0 {
++ return StopKernelEvidence{}, fmt.Errorf("%w: pid %d holds an installation file", ErrClaimStopIdentity, held[0].PID)
++ }
++
+ if has, err := launches.HasHost(ctx, t.Host); err != nil {
+ return StopKernelEvidence{}, err
+ } else if has {
+@@ -193,18 +182,8 @@
+ return StopKernelEvidence{InventorySHA256: digestStop(inv), LocalJournalSHA256: journal, CgroupIdentities: groups, UnitInvocationIDs: []string{}}, nil
+ }
+
+-// installationFiles lists the files root watches for an installation: each
+-// inventory path that is a file, with its WAL and SHM names, and every
+-// regular file directly in each inventory directory. Directories are
+-// checked against the inventory's device and inode first. dataOnly keeps
+-// the "data" root alone (the database's state directory).
+-func installationFiles(managed map[string]string, inv StopInventory, dataOnly bool) ([]string, error) {
+- paths, _, err := installationCoverage(managed, inv, dataOnly)
+- return paths, err
+-}
+-
+ // installationCoverage returns both the currently named files and the
+-// inventory directory prefixes. The latter keep the session-zero witness
++// inventory directory prefixes. The latter keep both root witnesses
+ // effective for nested and open-but-unlinked files, including an installation
+ // directory whose current listing is empty.
+ func installationCoverage(managed map[string]string, inv StopInventory, dataOnly bool) ([]string, []string, error) {
+@@ -267,101 +246,6 @@
+ }
+ sort.Strings(prefixes)
+ return out, prefixes, nil
+-}
+-
+-// installationPrefixHolders performs a strict /proc descriptor scan and
+-// matches absolute descriptor targets beneath an inventory directory. A path
+-// separator is part of the match so a sibling such as "instance-old" is not
+-// covered by the prefix "instance". Deleted targets retain their useful name
+-// in /proc and are matched after removing its kernel suffix.
+-func installationPrefixHolders(ctx context.Context, procRoot string, prefixes []string) ([]int, error) {
+- if ctx == nil || !filepath.IsAbs(procRoot) || len(prefixes) == 0 {
+- return nil, ErrClaimStopInventory
+- }
+- matches := func(target string) bool {
+- if !filepath.IsAbs(target) {
+- return false
+- }
+- target = filepath.Clean(strings.TrimSuffix(target, " (deleted)"))
+- for _, prefix := range prefixes {
+- if target == prefix || strings.HasPrefix(target, prefix+string(os.PathSeparator)) {
+- return true
+- }
+- }
+- return false
+- }
+- processes, err := os.ReadDir(procRoot)
+- if err != nil {
+- return nil, err
+- }
+- seen := map[int]bool{}
+- for _, process := range processes {
+- if err := ctx.Err(); err != nil {
+- return nil, err
+- }
+- pid, err := strconv.Atoi(process.Name())
+- if err != nil || pid <= 0 {
+- continue
+- }
+- pidDir := filepath.Join(procRoot, process.Name())
+- tasks, err := readStopProcDir(filepath.Join(pidDir, "task"), pidDir)
+- if err != nil {
+- return nil, fmt.Errorf("pid %d: %w", pid, err)
+- }
+- for _, task := range tasks {
+- if err := ctx.Err(); err != nil {
+- return nil, err
+- }
+- tid, err := strconv.Atoi(task.Name())
+- if err != nil || tid <= 0 {
+- continue
+- }
+- taskDir := filepath.Join(pidDir, "task", task.Name())
+- fds, err := readStopProcDir(filepath.Join(taskDir, "fd"), taskDir)
+- if err != nil {
+- return nil, fmt.Errorf("pid %d tid %d: %w", pid, tid, err)
+- }
+- for _, fd := range fds {
+- if err := ctx.Err(); err != nil {
+- return nil, err
+- }
+- if _, err := strconv.Atoi(fd.Name()); err != nil {
+- continue
+- }
+- target, err := os.Readlink(filepath.Join(taskDir, "fd", fd.Name()))
+- if errors.Is(err, os.ErrNotExist) {
+- continue
+- }
+- if err != nil {
+- return nil, fmt.Errorf("pid %d tid %d fd %s: %w", pid, tid, fd.Name(), err)
+- }
+- if matches(target) {
+- seen[pid] = true
+- }
+- }
+- }
+- }
+- if err := ctx.Err(); err != nil {
+- return nil, err
+- }
+- holders := make([]int, 0, len(seen))
+- for pid := range seen {
+- holders = append(holders, pid)
+- }
+- sort.Ints(holders)
+- return holders, nil
+-}
+-
+-func readStopProcDir(path, owner string) ([]os.DirEntry, error) {
+- entries, err := os.ReadDir(path)
+- if errors.Is(err, os.ErrNotExist) {
+- if _, ownerErr := os.Stat(owner); errors.Is(ownerErr, os.ErrNotExist) {
+- return nil, nil
+- } else if ownerErr != nil {
+- return nil, ownerErr
+- }
+- }
+- return entries, err
+ }
+
+ // hostContainmentEmpty checks the host's containment under the agent's
+--- a/internal/stopevidence/fds_linux.go
++++ b/internal/stopevidence/fds_linux.go
+@@ -52,8 +52,17 @@
+ // unreadable entry fails the whole scan. Results are ordered by process,
+ // task and descriptor.
+ func fdDescriptors(ctx context.Context, procRoot string, paths []string, files []FileIdentity, ops fdOps) ([]Descriptor, error) {
+- if ctx == nil || !filepath.IsAbs(procRoot) || len(paths)+len(files) == 0 {
++ return fdDescriptorsCovered(ctx, procRoot, paths, nil, files, ops)
++}
++
++func fdDescriptorsCovered(ctx context.Context, procRoot string, paths, prefixes []string, files []FileIdentity, ops fdOps) ([]Descriptor, error) {
++ if ctx == nil || !filepath.IsAbs(procRoot) || len(paths)+len(prefixes)+len(files) == 0 {
+ return nil, ErrInvalidInput
++ }
++ for _, prefix := range prefixes {
++ if !filepath.IsAbs(prefix) || filepath.Clean(prefix) != prefix || prefix == string(os.PathSeparator) {
++ return nil, ErrInvalidInput
++ }
+ }
+ wanted := make(map[string]bool, len(paths))
+ for _, path := range paths {
+@@ -115,7 +124,7 @@
+ continue
+ }
+ taskDir := filepath.Join(pidDir, "task", task.Name())
+- found, err := taskDescriptors(ctx, taskDir, pid, tid, wanted, inodes, ops)
++ found, err := taskDescriptorsCovered(ctx, taskDir, pid, tid, wanted, prefixes, inodes, ops)
+ if err != nil {
+ return nil, err
+ }
+@@ -140,6 +149,10 @@
+
+ // taskDescriptors reads one task's descriptor table.
+ func taskDescriptors(ctx context.Context, taskDir string, pid, tid int, wanted map[string]bool, inodes map[fileID]bool, ops fdOps) ([]Descriptor, error) {
++ return taskDescriptorsCovered(ctx, taskDir, pid, tid, wanted, nil, inodes, ops)
++}
++
++func taskDescriptorsCovered(ctx context.Context, taskDir string, pid, tid int, wanted map[string]bool, prefixes []string, inodes map[fileID]bool, ops fdOps) ([]Descriptor, error) {
+ fdDir := filepath.Join(taskDir, "fd")
+ fds, err := readProcDir(fdDir, taskDir, ops)
+ if err != nil {
+@@ -163,6 +176,15 @@
+ return nil, fmt.Errorf("%w: pid %d tid %d fd %s: %w", ErrIncomplete, pid, tid, fd.Name(), err)
+ }
+ named := filepath.IsAbs(target) && (wanted[filepath.Clean(target)] || wanted[filepath.Clean(strings.TrimSuffix(target, " (deleted)"))])
++ if filepath.IsAbs(target) && !named {
++ name := filepath.Clean(strings.TrimSuffix(target, " (deleted)"))
++ for _, prefix := range prefixes {
++ if name == prefix || strings.HasPrefix(name, prefix+string(os.PathSeparator)) {
++ named = true
++ break
++ }
++ }
++ }
+ if !named && (len(inodes) == 0 || !filepath.IsAbs(target)) {
+ continue // sockets, pipes and anon inodes name no file
+ }
+--- a/internal/stopevidence/holders_linux.go
++++ b/internal/stopevidence/holders_linux.go
+@@ -20,6 +20,13 @@
+ // unreadable process, task or descriptor fails the whole scan.
+ func Holders(ctx context.Context, procRoot string, paths []string, files []FileIdentity) ([]Descriptor, error) {
+ return fdDescriptors(ctx, procRoot, paths, files, realFDOps())
++}
++
++// HoldersWithPrefixes includes every descriptor beneath the validated directory
++// prefixes, even if its file was created after launch or has been unlinked.
++// Each match retains the descriptor identity needed by ReplicatorOnly.
++func HoldersWithPrefixes(ctx context.Context, procRoot string, paths, prefixes []string, files []FileIdentity) ([]Descriptor, error) {
++ return fdDescriptorsCovered(ctx, procRoot, paths, prefixes, files, realFDOps())
+ }
+
+ // OwnDescriptors is the agent's half: the descriptors of one process's own
+--- a/internal/stopevidence/holders_other.go
++++ b/internal/stopevidence/holders_other.go
+@@ -13,3 +13,7 @@
+ func ReplicatorOnly(context.Context, string, []Descriptor, Agent, []DescriptorHint) ([]Descriptor, error) {
+ return nil, ErrUnsupported
+ }
++
++func HoldersWithPrefixes(context.Context, string, []string, []string, []FileIdentity) ([]Descriptor, error) {
++ return nil, ErrUnsupported
++}
+```
-The default Go cache was read-only in the sandbox, so verification used the explicitly permitted shared cache `/home/paulca/.cache/tmp/claude-1001/gocache`. It was not deleted. An initial broad host run exceeded the Unix socket path limit; the bootstrap now uses a shorter disk-backed temporary directory, and the same tests passed there. These setup errors were not counted as regression evidence. No git index, ref or worktree registration was written.
+```bash
+python3 "$CLAIM11_SCRATCH/apply.py" "$CLAIM11_PLAN" D4W implementation
+nice -n 10 go test -p 1 -timeout 5m ./internal/stopevidence ./internal/host -run 'TestRev4Writers|TestRev3NoProcess|TestVerifyWritersAt|TestVerifyNoProcessAt|TestHolders|TestReplicator|TestFDHolders' -count=1
+```
-Opus reviewed the root authority and snapshot implementation and found no P1 or P2. Its data-root observation is handled by refusing production roots other than the template's `/data`, with a construction regression. The parent checked the final callback ordering, per-kind preflight refusal and replacement-invocation cleanup. Sonnet supplied the preflight shape changes; the parent integrated and verified them. No live systemd mount or production experiment was run. The real namespace/snapshot and region HA stage B checks remain activation gates.
+Expected: all pass, including precise retained descriptor identities and existing hint narrowing tests.
-### Rev 3 authoring results
+### D4S: propagate sidecar inspection errors
-The D3R regressions compiled against the completed Rev 2 export and failed on all four round 2 defects. Setup-time expiry returned a grant and counted one tenant execution; the actual `runInit` parent was mistaken for the tenant; deleted DB/WAL, nested, unlinked and empty-directory cases returned false no-process success; and the reconstructed flag-off controller started with claim artifacts still installed. The transition test now writes different argv/environment and verifies that legacy restart consumes those new values after stopped-unit cleanup.
+Invariant: a claim wake can omit the staged sidecar only after proving its environment file absent. `EACCES`, `EIO`, `ELOOP` and all other inspection failures refuse before admission or reservation.
-All twenty patch blocks replayed from a fresh export of `ef3c4bff7d612c5bb4584713fef40bc0cb5a43ca` with zero fuzz. Every resulting Go file matched the tested overlay byte for byte; changed-file formatting was clean. The new regressions, Rev 2 authority checks, launch and no-process tests passed in that fresh replay. The combined focused host/activator gate passed. Full supporting-package tests passed for `hostactors`, `hostclaim`, `hostbox`, `hostguard`, `stopevidence`, `instancectl` and `instanceexec`; the last two were rerun after the boundary integration fixes. Vet passed for these packages plus host/activator. Host, activator, instancectl and instanceexec built on Linux and cross-compiled for darwin/arm64 and windows/amd64. A delegated full-host run was interrupted without a verdict; no full-repository gate is claimed.
+`ExecBackend.ClaimLaunchRoles` becomes `([]string, error)`. The claim shape check and launch snapshot propagate that error. The legacy flag-off `HasSidecar` implementation remains byte-identical. The real-filesystem regressions use the existing shape API, so they compile and fail against Rev 3. The additional tests inject each error and assert that token authorization is never called after inspection failure.
-OpenAI subagents supplied bounded witness, legacy-cleanup and child-observation work. A bounded OpenAI review of the new boundary found the template's hidden boot-ID path, escaped `%i` argument, and early socket unlink before `ExecStopPost`. The amendment binds the boot ID explicitly, uses `%I`, retains the closed socket until stopped-unit proof, and tests the generated bindings and socket lifetime. No Anthropic-model subagent or review was used in this run; the Rev 1/2 review records above are historical.
+<!-- apply: D4SR tests -->
+```diff
+--- /dev/null
++++ b/internal/activator/claim_shape_rev4_sidecar_error_test.go
+@@ -0,0 +1,68 @@
++package activator
++
++import (
++ "errors"
++ "os"
++ "path/filepath"
++ "syscall"
++ "testing"
++
++ "github.com/carlosframework/platform/internal/hostbox"
++ "github.com/carlosframework/platform/internal/registry"
++)
++
++func TestClaimLaunchShapeSidecarInspection(t *testing.T) {
++ const host = "claim-sidecar.example"
++ rt := registry.Route{Host: host}
++
++ t.Run("proven absent", func(t *testing.T) {
++ b := &ExecBackend{DataDir: t.TempDir()}
++ if err := b.claimLaunchShape(rt); err != nil {
++ t.Fatalf("claimLaunchShape: %v", err)
++ }
++ })
++
++ t.Run("present", func(t *testing.T) {
++ b := &ExecBackend{DataDir: t.TempDir()}
++ if err := os.WriteFile(filepath.Join(b.DataDir, host+"-sidecar.env"), nil, 0o600); err != nil {
++ t.Fatal(err)
++ }
++ if err := b.claimLaunchShape(rt); !errors.Is(err, hostbox.ErrUnsupported) {
++ t.Fatalf("claimLaunchShape error = %v, want unsupported", err)
++ }
++ })
++
++ t.Run("symlink loop", func(t *testing.T) {
++ b := &ExecBackend{DataDir: t.TempDir()}
++ path := filepath.Join(b.DataDir, host+"-sidecar.env")
++ if err := os.Symlink(filepath.Base(path), path); err != nil {
++ t.Fatal(err)
++ }
++ if _, err := os.Stat(path); !errors.Is(err, syscall.ELOOP) {
++ t.Fatalf("fixture os.Stat error = %v, want ELOOP", err)
++ }
++ if err := b.claimLaunchShape(rt); !errors.Is(err, syscall.ELOOP) {
++ t.Fatalf("claimLaunchShape error = %v, want ELOOP", err)
++ }
++ })
++
++ t.Run("permission denied", func(t *testing.T) {
++ parent := t.TempDir()
++ dataDir := filepath.Join(parent, "data")
++ if err := os.Mkdir(dataDir, 0o700); err != nil {
++ t.Fatal(err)
++ }
++ if err := os.Chmod(dataDir, 0); err != nil {
++ t.Fatal(err)
++ }
++ t.Cleanup(func() { _ = os.Chmod(dataDir, 0o700) })
++ path := filepath.Join(dataDir, host+"-sidecar.env")
++ if _, err := os.Stat(path); !errors.Is(err, syscall.EACCES) {
++ t.Skipf("fixture cannot produce EACCES: %v", err)
++ }
++ b := &ExecBackend{DataDir: dataDir}
++ if err := b.claimLaunchShape(rt); !errors.Is(err, syscall.EACCES) {
++ t.Fatalf("claimLaunchShape error = %v, want EACCES", err)
++ }
++ })
++}
+```
+
+```bash
+python3 "$CLAIM11_SCRATCH/apply.py" "$CLAIM11_PLAN" D4SR tests
+nice -n 10 go test -p 1 -timeout 5m ./internal/activator -run 'TestClaimLaunchShapeSidecarInspection' -count=1
+```
+
+Expected: the symlink-loop case returns nil instead of `ELOOP`; the permission-denied case returns nil instead of `EACCES` when the test user cannot bypass permissions. Proven absence remains valid and a present sidecar remains unsupported.
+
+<!-- apply: D4S tests -->
+```diff
+--- a/internal/activator/claim_shape_rev2_shape_test.go
++++ b/internal/activator/claim_shape_rev2_shape_test.go
+@@ -40,10 +40,10 @@
+
+ func TestRev2ShapeClaimLaunchRoles(t *testing.T) {
+ b := &ExecBackend{DataDir: t.TempDir()}
+- if got := b.ClaimLaunchRoles(registry.Route{Host: "h.example"}); len(got) != 1 || got[0] != "main" {
+- t.Fatalf("roles=%v", got)
++ if got, err := b.ClaimLaunchRoles(registry.Route{Host: "h.example"}); err != nil || len(got) != 1 || got[0] != "main" {
++ t.Fatalf("roles=%v err=%v", got, err)
+ }
+- if got := b.ClaimLaunchRoles(registry.Route{Host: "h.example", Seed: `["init"]`}); len(got) != 2 || got[1] != "seed" {
+- t.Fatalf("roles=%v", got)
++ if got, err := b.ClaimLaunchRoles(registry.Route{Host: "h.example", Seed: `["init"]`}); err != nil || len(got) != 2 || got[1] != "seed" {
++ t.Fatalf("roles=%v err=%v", got, err)
+ }
+ }
+--- a/internal/activator/claim_shape_rev4_sidecar_error_test.go
++++ b/internal/activator/claim_shape_rev4_sidecar_error_test.go
+@@ -1,6 +1,7 @@
+ package activator
+
+ import (
++ "context"
+ "errors"
+ "os"
+ "path/filepath"
+@@ -66,3 +67,38 @@
+ }
+ })
+ }
++
++func TestClaimLaunchRolesPropagatesInjectedInspectionErrors(t *testing.T) {
++ b := &ExecBackend{DataDir: t.TempDir()}
++ for _, want := range []error{syscall.EACCES, syscall.EIO, syscall.ELOOP} {
++ t.Run(want.Error(), func(t *testing.T) {
++ roles, err := b.claimLaunchRoles(registry.Route{Host: "claim-sidecar.example"}, func(string) (os.FileInfo, error) {
++ return nil, want
++ })
++ if roles != nil || !errors.Is(err, want) {
++ t.Fatalf("roles=%v error=%v, want nil roles and %v", roles, err, want)
++ }
++ })
++ }
++}
++
++func TestEnsureClaimAdminTokenInspectsSidecarBeforeAuthorize(t *testing.T) {
++ const host = "claim-sidecar.example"
++ b := &ExecBackend{DataDir: t.TempDir()}
++ path := filepath.Join(b.DataDir, host+"-sidecar.env")
++ if err := os.Symlink(filepath.Base(path), path); err != nil {
++ t.Fatal(err)
++ }
++ rt := registry.Route{Host: host, Socket: "/run/carlos/claim-sidecar.sock", DBPath: "/data/claim-sidecar.db"}
++ authorized := 0
++ err := b.EnsureClaimAdminToken(context.Background(), rt, func(_ context.Context, write func() error) error {
++ authorized++
++ return write()
++ })
++ if !errors.Is(err, syscall.ELOOP) {
++ t.Fatalf("EnsureClaimAdminToken error = %v, want ELOOP", err)
++ }
++ if authorized != 0 {
++ t.Fatalf("authorize called %d times after sidecar inspection failed", authorized)
++ }
++}
+```
+
+```bash
+python3 "$CLAIM11_SCRATCH/apply.py" "$CLAIM11_PLAN" D4S tests
+nice -n 10 go test -p 1 -timeout 5m ./internal/activator -run 'TestClaimLaunchShapeSidecarInspection|TestClaimLaunchRolesPropagates|TestEnsureClaimAdminTokenInspects|TestRev2Shape' -count=1
+```
+
+Expected: the new error-return signature and inspection helper are missing. The preceding behavioral red run is the evidence for the original defect.
+
+<!-- apply: D4S implementation -->
+```diff
+--- a/internal/activator/claim_shape.go
++++ b/internal/activator/claim_shape.go
+@@ -1,6 +1,10 @@
+ package activator
+
+ import (
++ "errors"
++ "fmt"
++ "io/fs"
++ "os"
+ "path/filepath"
+ "strings"
+
+@@ -13,21 +17,36 @@
+ // always, the route's first-boot seed when it has one, and the legacy
+ // co-spawned sidecar when its env file is staged. The isolated claim launch
+ // carries main alone.
+-func (b *ExecBackend) ClaimLaunchRoles(rt registry.Route) []string {
++func (b *ExecBackend) ClaimLaunchRoles(rt registry.Route) ([]string, error) {
++ return b.claimLaunchRoles(rt, os.Stat)
++}
++
++func (b *ExecBackend) claimLaunchRoles(rt registry.Route, stat func(string) (os.FileInfo, error)) ([]string, error) {
+ roles := []string{"main"}
+ if rt.Seed != "" {
+ roles = append(roles, "seed")
+ }
+- if b.HasSidecar(rt) {
++ _, err := stat(b.pathsFor(rt).sidecarEnv)
++ switch {
++ case err == nil:
+ roles = append(roles, "sidecar")
++ case errors.Is(err, fs.ErrNotExist):
++ // A proven absence is the only safe negative answer. Other inspection
++ // failures cannot admit a launch whose complete role set is unknown.
++ default:
++ return nil, fmt.Errorf("inspect staged sidecar for %s: %w", rt.Host, err)
+ }
+- return roles
++ return roles, nil
+ }
+
+ // claimLaunchShape refuses, read-only, a route whose real start is more than
+ // the isolated main launch.
+ func (b *ExecBackend) claimLaunchShape(rt registry.Route) error {
+- if roles := b.ClaimLaunchRoles(rt); len(roles) != 1 {
++ roles, err := b.ClaimLaunchRoles(rt)
++ if err != nil {
++ return err
++ }
++ if len(roles) != 1 {
+ return hostbox.ErrUnsupported
+ }
+ return nil
+--- a/internal/activator/claim_snapshot.go
++++ b/internal/activator/claim_snapshot.go
+@@ -108,7 +108,10 @@
+ if err != nil || out.Version == "" {
+ return out, hostwrite.ErrUnavailable
+ }
+- out.Roles = b.ClaimLaunchRoles(rt)
++ out.Roles, err = b.ClaimLaunchRoles(rt)
++ if err != nil {
++ return out, err
++ }
+ out.Args = []string{"--socket", rt.Socket, "--db", rt.DBPath}
+ out.Env = childEnv(b.agentOwnedVars(rt, "", vars, false))
+ raw, err := json.Marshal(out.Env)
+```
+
+```bash
+python3 "$CLAIM11_SCRATCH/apply.py" "$CLAIM11_PLAN" D4S implementation
+nice -n 10 go test -p 1 -timeout 5m ./internal/activator -run 'TestClaimLaunchShapeSidecarInspection|TestClaimLaunchRolesPropagates|TestEnsureClaimAdminTokenInspects|TestRev2Shape' -count=1
+```
+
+Expected: all pass; injected uncertain errors return no role list, and the authorization counter stays zero.
+
+### D4C: root-derived containment ownership (9-CG)
+
+Invariant: an authenticated edge can request work only within the containment derived from its checked claim tuple. Neither a submitted path nor a previously persisted path grants root authority over another service or host.
+
+Root derives the parent from the trusted `carlos-edge.service` ControlGroup and the existing tuple-bound `claimContainmentName`. It opens that parent beneath `/sys/fs/cgroup` without following symlinks, captures its device and inode, and requires submitted identity to match before journaling it. `create` and `prelaunch` initially contain exactly that parent. Every agent `intend` or `reserve` must name that same recorded parent and a direct `role-*` child; concrete reservations must also match the child's kernel identity. A parentless reservation is refused even when the prelaunch parent is valid. Agent requests cannot add isolated unit reservations through this journal door; root's unit protocol derives the exact host unit independently.
+
+Before recovery resolves intentions, signals a unit, kills a cgroup or settles a record, it validates the entire persisted inventory. Validation includes already-settled reservations, binds every reservation to the launch owner, requires each recorded child to have its reservation, and restricts root unit groups to `ClaimUnitControlGroup(target.Host)`. Linux stop verification and reboot recovery repeat that validation. The unit wrapper validates before its first `Gone` call, and root launch validates prior intention records before any cleanup. Launch and stop validate their own persisted record too. Stop refuses a foreign unit path before writing a tombstone, so a rejected request cannot poison later recovery.
+
+An old journal entry without a provable tuple-derived parent remains refused. Do not migrate such a record into authority by trusting its path. These checks do not add an agent-selected parent field or broaden the set of launchable roles.
+
+<!-- apply: D4CR tests -->
+```diff
+--- /dev/null
++++ b/internal/host/plan11_rev4_containment_linux_test.go
+@@ -0,0 +1,93 @@
++//go:build linux
++
++package host
++
++import (
++ "bytes"
++ "context"
++ "encoding/json"
++ "net/http"
++ "net/http/httptest"
++ "os"
++ "path/filepath"
++ "testing"
++
++ "github.com/carlosframework/platform/internal/cgroup"
++ "github.com/carlosframework/platform/internal/hostbox"
++ "github.com/carlosframework/platform/internal/hostguard"
++)
++
++func TestPlan11Rev4JournalRefusesForeignContainment(t *testing.T) {
++ for _, op := range []string{"create", "prelaunch", "reserve"} {
++ for _, path := range []string{"system.slice/ssh.service", "system.slice/carlos-edge.service/carlos-exec@other.example", "derived"} {
++ if path == "derived" && op != "reserve" {
++ continue
++ }
++ t.Run(op+"/"+path, func(t *testing.T) {
++ f := newRecoveryFixture(t)
++ f.recordPrelaunch = false
++ c := f.controller("boot-1", "controller-1", 4101, "start-1")
++ admitted := c.admit(t, f)
++ target := recoveryTuple(admitted, c.local)
++ target.Op = admitted.Claim.Op.ID
++ if path == "derived" {
++ path = filepath.Join("system.slice/carlos-edge.service", cgroup.ExecName(claimContainmentName(target)))
++ }
++ service, err := hostbox.NewJournalService(f.journal)
++ if err != nil {
++ t.Fatal(err)
++ }
++ root := ClaimRootHTTP{Journals: service}
++ root.Reconcile.Box, root.Reconcile.Enrollment = target.Box, target.Enrollment
++ root.Stop.Coordinator.Evidence = f.claims
++ client, _ := hostbox.RemoteJournals(func(ctx context.Context, q hostbox.JournalRequest) (hostbox.JournalResponse, error) {
++ raw, _ := json.Marshal(q)
++ w := httptest.NewRecorder()
++ root.serveJournal(w, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(raw)), f.peer)
++ if w.Code != http.StatusOK {
++ return hostbox.JournalResponse{V: 1, ID: q.ID, Error: "refused"}, nil
++ }
++ var out hostbox.JournalResponse
++ err := json.Unmarshal(w.Body.Bytes(), &out)
++ return out, err
++ })
++ in := hostbox.LaunchInventory{Target: target, ControllerPID: f.peer.PID, ProcessStart: f.peer.ProcessStart, NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: path, Device: 1, Inode: 2}}}
++ switch op {
++ case "create":
++ err = client.Create(t.Context(), in)
++ case "prelaunch":
++ err = client.Prelaunch(t.Context(), in)
++ case "reserve":
++ if err = f.journal.Prelaunch(t.Context(), in); err != nil {
++ t.Fatal(err)
++ }
++ err = client.Reserve(t.Context(), hostbox.LaunchReservation{ID: "x", Role: "main", InvocationID: "x", Inputs: "inputs", Target: target, Group: hostbox.LaunchGroup{RelativePath: path + "/role-x", Device: 1, Inode: 3, InvocationID: "x"}, Executable: hostbox.LaunchFile{Device: 1, Inode: 4}})
++ }
++ if err == nil {
++ t.Fatal("root accepted unrelated containment or parentless reservation")
++ }
++ })
++ }
++ }
++}
++
++func TestPlan11Rev4RecoveryRefusesForgedPersistedGroups(t *testing.T) {
++ for _, settled := range []bool{false, true} {
++ t.Run(map[bool]string{false: "pending", true: "settled"}[settled], func(t *testing.T) {
++ root, proc := t.TempDir(), t.TempDir()
++ if err := os.MkdirAll(filepath.Join(proc, "sys/kernel/random"), 0700); err != nil {
++ t.Fatal(err)
++ }
++ if err := os.WriteFile(filepath.Join(proc, "sys/kernel/random/boot_id"), []byte("new-boot"), 0600); err != nil {
++ t.Fatal(err)
++ }
++ target := hostguard.FenceTarget{Host: "shop.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: "enroll", BootID: "old-boot", ControllerID: "controller", FenceID: "fence"}
++ j := &rebootJournalFixture{j: hostbox.StopKernelJournal{V: 1, Target: target, ControllerPID: 42, ProcessStart: "42", Settled: settled, Cgroups: []hostbox.StopCgroupIdentity{{RelativePath: "system.slice/ssh.service", Device: 1, Inode: 2}}}}
++ k := LinuxStopKernel{AgentCgroup: func(context.Context) (string, error) { return "/sys/fs/cgroup/system.slice/carlos-edge.service", nil }, Journal: j, CgroupRoot: root, ProcRoot: proc, ManagedRoots: map[string]string{"data": t.TempDir()}}
++ _, _, err := k.recoverAfterReboot(t.Context(), target, StopInventory{Complete: true}, j.j, "new-boot", j)
++ if err == nil {
++ t.Fatal("recovery accepted unrelated persisted containment")
++ }
++ })
++ }
++}
+```
+
+```bash
+python3 "$CLAIM11_SCRATCH/apply.py" "$CLAIM11_PLAN" D4CR tests
+nice -n 10 go test -p 1 -timeout 5m ./internal/host -run 'TestPlan11Rev4JournalRefusesForeignContainment|TestPlan11Rev4RecoveryRefusesForgedPersistedGroups' -count=1
+```
+
+Expected: Rev 3 accepts unrelated service groups, foreign host groups and a parentless reservation; it also accepts forged persisted recovery groups in both pending and settled records. These tests use existing interfaces and compile before the fix.
+
+<!-- apply: D4C tests -->
+```diff
+--- a/internal/host/claimlaunch_root_test.go
++++ b/internal/host/claimlaunch_root_test.go
+@@ -148,8 +148,11 @@
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { journal.Close() })
++ previousAgentRoot := claimContainmentAgentRoot
++ claimContainmentAgentRoot = func(context.Context) (string, error) { return "/sys/fs/cgroup/system.slice/carlos-edge.service", nil }
++ t.Cleanup(func() { claimContainmentAgentRoot = previousAgentRoot })
+ target := hostguard.FenceTarget{Region: "eu", Host: host, Incarnation: "inc-1", Stamp: 11, Session: 13, Op: op.ID, Phase: op.Phase, Box: "box-1", Enrollment: "enrollment-1", BootID: "boot-1", ControllerID: "controller-1", ClaimETag: "etag-1", DirectoryRevision: "7"}
+- if err = journal.Prelaunch(t.Context(), hostbox.LaunchInventory{ControllerPID: 42, ProcessStart: "start-42", Target: target, Groups: []hostbox.LaunchGroup{{RelativePath: "carlos/claim-parent", Device: 1, Inode: 9}}, NoState: true}); err != nil {
++ if err = journal.Prelaunch(t.Context(), hostbox.LaunchInventory{ControllerPID: 42, ProcessStart: "start-42", Target: target, Groups: []hostbox.LaunchGroup{{RelativePath: filepath.Join("system.slice/carlos-edge.service", cgroup.ExecName(claimContainmentName(target))), Device: 1, Inode: 9}}, NoState: true}); err != nil {
+ t.Fatal(err)
+ }
+ actor := &hostbox.Actor{Box: "box-1", Enrollment: "enrollment-1", Revision: 7, Region: "eu", Eligible: true}
+--- a/internal/host/claimstop_kernel_linux_test.go
++++ b/internal/host/claimstop_kernel_linux_test.go
+@@ -101,8 +101,12 @@
+ t.Fatal(err)
+ }
+ target := hostguard.FenceTarget{Host: "shop.example", Incarnation: "inc", Stamp: 11, Session: 13, Box: "box", Enrollment: "enroll", BootID: "old-boot", ControllerID: "old-controller", FenceID: "fence"}
+- journal := &rebootJournalFixture{j: hostbox.StopKernelJournal{V: 1, Target: target, ControllerPID: 42, ProcessStart: "old-start", Cgroups: []hostbox.StopCgroupIdentity{{RelativePath: "host", Device: 1, Inode: 2}, {RelativePath: "host/main", Device: 1, Inode: 3}}}}
+- kernel := LinuxStopKernel{Journal: journal, CgroupRoot: root, ProcRoot: proc, ManagedRoots: map[string]string{"data": t.TempDir()}}
++ parent, err := claimContainmentParent("/sys/fs/cgroup/system.slice/carlos-edge.service", target)
++ if err != nil {
++ t.Fatal(err)
++ }
++ journal := &rebootJournalFixture{j: hostbox.StopKernelJournal{V: 1, Target: target, ControllerPID: 42, ProcessStart: "old-start", Cgroups: []hostbox.StopCgroupIdentity{{RelativePath: parent, Device: 1, Inode: 2}, {RelativePath: parent + "/role-main", Device: 1, Inode: 3, InvocationID: "main"}}}}
++ kernel := LinuxStopKernel{AgentCgroup: func(context.Context) (string, error) { return "/sys/fs/cgroup/system.slice/carlos-edge.service", nil }, Journal: journal, CgroupRoot: root, ProcRoot: proc, ManagedRoots: map[string]string{"data": t.TempDir()}}
+ inventory := StopInventory{Complete: true, Host: target.Host, Incarnation: target.Incarnation, InstallationStamp: target.Stamp}
+ evidence, ack, err := kernel.recoverAfterReboot(t.Context(), target, inventory, journal.j, "new-boot", journal)
+ if err != nil {
+@@ -117,7 +121,7 @@
+ if _, err := scanStopCgroups(t.Context(), root, journal.j.Cgroups); err == nil {
+ t.Fatal("same-boot missing containment accepted")
+ }
+- if err := os.Mkdir(filepath.Join(root, "host"), 0700); err != nil {
++ if err := os.MkdirAll(filepath.Join(root, parent), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := kernel.recoverAfterReboot(t.Context(), target, inventory, journal.j, "new-boot", journal); err == nil {
+--- a/internal/host/plan11_rev4_containment_linux_test.go
++++ b/internal/host/plan11_rev4_containment_linux_test.go
+@@ -6,6 +6,7 @@
+ "bytes"
+ "context"
+ "encoding/json"
++ "github.com/carlosframework/platform/internal/instancectl"
+ "net/http"
+ "net/http/httptest"
+ "os"
+@@ -18,11 +19,16 @@
+ )
+
+ func TestPlan11Rev4JournalRefusesForeignContainment(t *testing.T) {
++ oldRoot, oldInspect := claimContainmentAgentRoot, claimContainmentInspect
++ t.Cleanup(func() { claimContainmentAgentRoot, claimContainmentInspect = oldRoot, oldInspect })
++ claimContainmentAgentRoot = func(context.Context) (string, error) { return "/sys/fs/cgroup/system.slice/carlos-edge.service", nil }
++ claimContainmentInspect = func(p string) (hostbox.LaunchGroup, error) {
++ return hostbox.LaunchGroup{RelativePath: p, Device: 1, Inode: 2}, nil
++ }
++
+ for _, op := range []string{"create", "prelaunch", "reserve"} {
+ for _, path := range []string{"system.slice/ssh.service", "system.slice/carlos-edge.service/carlos-exec@other.example", "derived"} {
+- if path == "derived" && op != "reserve" {
+- continue
+- }
++
+ t.Run(op+"/"+path, func(t *testing.T) {
+ f := newRecoveryFixture(t)
+ f.recordPrelaunch = false
+@@ -30,6 +36,7 @@
+ admitted := c.admit(t, f)
+ target := recoveryTuple(admitted, c.local)
+ target.Op = admitted.Claim.Op.ID
++ positive := path == "derived" && op != "reserve"
+ if path == "derived" {
+ path = filepath.Join("system.slice/carlos-edge.service", cgroup.ExecName(claimContainmentName(target)))
+ }
+@@ -63,6 +70,12 @@
+ }
+ err = client.Reserve(t.Context(), hostbox.LaunchReservation{ID: "x", Role: "main", InvocationID: "x", Inputs: "inputs", Target: target, Group: hostbox.LaunchGroup{RelativePath: path + "/role-x", Device: 1, Inode: 3, InvocationID: "x"}, Executable: hostbox.LaunchFile{Device: 1, Inode: 4}})
+ }
++ if positive {
++ if err != nil {
++ t.Fatalf("valid root intake refused: %v", err)
++ }
++ return
++ }
+ if err == nil {
+ t.Fatal("root accepted unrelated containment or parentless reservation")
+ }
+@@ -91,3 +104,203 @@
+ })
+ }
+ }
++
++func TestPlan11Rev4ContainmentCapturesOnlyDerivedParent(t *testing.T) {
++ target := hostguard.FenceTarget{Host: "shop.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: "enroll", BootID: "boot", ControllerID: "controller"}
++ parentPath, err := claimContainmentParent("/sys/fs/cgroup/system.slice/carlos-edge.service", target)
++ if err != nil {
++ t.Fatal(err)
++ }
++ parent := hostbox.LaunchGroup{RelativePath: parentPath, Device: 1, Inode: 2}
++ child := hostbox.LaunchGroup{RelativePath: parentPath + "/role-one", Device: 1, Inode: 3, InvocationID: "one"}
++ oldRoot, oldInspect := claimContainmentAgentRoot, claimContainmentInspect
++ t.Cleanup(func() { claimContainmentAgentRoot, claimContainmentInspect = oldRoot, oldInspect })
++ claimContainmentAgentRoot = func(context.Context) (string, error) { return "/sys/fs/cgroup/system.slice/carlos-edge.service", nil }
++ claimContainmentInspect = func(p string) (hostbox.LaunchGroup, error) {
++ if p == parent.RelativePath {
++ return parent, nil
++ }
++ if p == child.RelativePath {
++ g := child
++ g.InvocationID = ""
++ return g, nil
++ }
++ t.Fatalf("root tried to capture foreign containment: %s", p)
++ return hostbox.LaunchGroup{}, nil
++ }
++ j, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "launch.db"))
++ if err != nil {
++ t.Fatal(err)
++ }
++ defer j.Close()
++ in := hostbox.LaunchInventory{Target: target, ControllerPID: 42, ProcessStart: "42", NoState: true, Groups: []hostbox.LaunchGroup{parent}}
++ for _, op := range []string{"create", "prelaunch"} {
++ if err := validateClaimJournalContainment(t.Context(), hostbox.JournalRequest{Op: op, Launch: in}, j); err != nil {
++ t.Fatal(err)
++ }
++ for _, foreign := range []string{"system.slice/ssh.service", "system.slice/carlos-edge.service/carlos-exec@other.example"} {
++ forged := in
++ forged.Groups = []hostbox.LaunchGroup{{RelativePath: foreign, Device: 1, Inode: 2}}
++ if err := validateClaimJournalContainment(t.Context(), hostbox.JournalRequest{Op: op, Launch: forged}, j); err == nil {
++ t.Fatal("foreign containment accepted with a valid capture provider")
++ }
++ }
++ forged := in
++ forged.Groups = append([]hostbox.LaunchGroup{}, in.Groups...)
++ forged.Groups[0].Inode++
++ if err := validateClaimJournalContainment(t.Context(), hostbox.JournalRequest{Op: op, Launch: forged}, j); err == nil {
++ t.Fatal("forged kernel identity accepted")
++ }
++ }
++ if err := j.Prelaunch(t.Context(), in); err != nil {
++ t.Fatal(err)
++ }
++ r := hostbox.LaunchReservation{ID: "one", Role: "main", InvocationID: "one", Inputs: "inputs", Target: target, Parent: parent, Group: child, Executable: hostbox.LaunchFile{Device: 1, Inode: 4}}
++ intended := r
++ intended.Group.Device = 0
++ intended.Group.Inode = 0
++ if err := validateClaimJournalContainment(t.Context(), hostbox.JournalRequest{Op: "intend", Reservation: intended}, j); err != nil {
++ t.Fatal(err)
++ }
++ if err := j.IntendRole(t.Context(), intended); err != nil {
++ t.Fatal(err)
++ }
++ if err := validateClaimJournalContainment(t.Context(), hostbox.JournalRequest{Op: "reserve", Reservation: r}, j); err != nil {
++ t.Fatal(err)
++ }
++ parentless := r
++ parentless.Parent = hostbox.LaunchGroup{}
++ if err := validateClaimJournalContainment(t.Context(), hostbox.JournalRequest{Op: "reserve", Reservation: parentless}, j); err == nil {
++ t.Fatal("parentless reservation accepted")
++ }
++ if err := j.Reserve(t.Context(), r); err != nil {
++ t.Fatal(err)
++ }
++ in, err = j.Read(t.Context(), target)
++ if err != nil {
++ t.Fatal(err)
++ }
++ in.Reservations = append(in.Reservations, hostbox.LaunchReservation{Target: target, State: hostbox.UnitIntended, Group: hostbox.LaunchGroup{RelativePath: instancectl.ClaimUnitControlGroup(target.Host)}})
++ if err := validateClaimContainment(in, parentPath); err != nil {
++ t.Fatal("root's exact isolated unit rejected", err)
++ }
++}
++
++type containmentCountingUnits struct {
++ *launchUnits
++ calls int
++}
++
++func (u *containmentCountingUnits) Gone(ctx context.Context, host, inv string) error {
++ u.calls++
++ return u.launchUnits.Gone(ctx, host, inv)
++}
++
++func TestPlan11Rev4RecoveryValidatesWholeInventoryBeforeEffects(t *testing.T) {
++ for _, kind := range []string{"unrelated-parent", "foreign-unit", "settled-foreign-unit", "parentless-role"} {
++ t.Run(kind, func(t *testing.T) {
++ f := newLaunchFixture(t, servingOp())
++ in, err := f.journal.Read(t.Context(), f.target)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if kind == "unrelated-parent" {
++ in.Groups[0].RelativePath = "system.slice/ssh.service"
++ }
++ j, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "forged.db"))
++ if err != nil {
++ t.Fatal(err)
++ }
++ defer j.Close()
++ if err = j.Create(t.Context(), in); err != nil {
++ t.Fatal(err)
++ }
++ r := *f.req.Reservation
++ if kind == "foreign-unit" || kind == "settled-foreign-unit" {
++ r.Group.RelativePath = instancectl.ClaimUnitControlGroup("other.example")
++ }
++ if err = j.IntendUnit(t.Context(), r); err != nil {
++ t.Fatal(err)
++ }
++ if kind == "settled-foreign-unit" {
++ if err = j.SettleUnit(t.Context(), f.target, r.ID); err != nil {
++ t.Fatal(err)
++ }
++ }
++ if kind == "parentless-role" {
++ role := hostbox.LaunchReservation{ID: "role", Role: "writer", InvocationID: "role", Inputs: "inputs", Target: f.target, Group: hostbox.LaunchGroup{RelativePath: in.Groups[0].RelativePath + "/role-x", Device: 1, Inode: 7, InvocationID: "role"}, Executable: hostbox.LaunchFile{Device: 1, Inode: 8}}
++ if err = j.Reserve(t.Context(), role); err != nil {
++ t.Fatal(err)
++ }
++ }
++ units := &containmentCountingUnits{launchUnits: f.units}
++ innerCalls := 0
++ k := claimUnitStopKernel{Journal: j, Units: units, Inner: recoveryKernelFunc(func(context.Context, hostguard.FenceTarget, StopInventory) (StopKernelEvidence, DrainAcknowledgement, error) {
++ innerCalls++
++ return StopKernelEvidence{}, DrainAcknowledgement{}, nil
++ })}
++ if _, _, err = k.RecoverStopped(t.Context(), f.target, StopInventory{}); err == nil {
++ t.Fatal("forged persisted inventory accepted")
++ }
++ if units.calls != 0 || innerCalls != 0 {
++ t.Fatalf("privileged recovery reached before validation: Gone=%d inner=%d", units.calls, innerCalls)
++ }
++ })
++ }
++}
++
++func TestPlan11Rev4LaunchAndStopRefuseForgedContainmentBeforeEffects(t *testing.T) {
++ for _, op := range []string{hostbox.RoleLaunchStart, hostbox.RoleLaunchStop} {
++ t.Run(op, func(t *testing.T) {
++ f := newLaunchFixture(t, servingOp())
++ in, err := f.journal.Read(t.Context(), f.target)
++ if err != nil {
++ t.Fatal(err)
++ }
++ in.Groups[0].RelativePath = "system.slice/ssh.service"
++ j, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "forged.db"))
++ if err != nil {
++ t.Fatal(err)
++ }
++ defer j.Close()
++ if err = j.Create(t.Context(), in); err != nil {
++ t.Fatal(err)
++ }
++ f.root.Journal = j
++ units := &containmentCountingUnits{launchUnits: f.units}
++ // The reserved-launch extension is supplied by the original fixture.
++ f.root.Units = units
++ q := f.req
++ if op == hostbox.RoleLaunchStop {
++ q = hostbox.RoleLaunchRequest{V: hostbox.RoleLaunchV, Op: op, Reservation: f.req.Reservation}
++ }
++ if _, err = f.root.Serve(t.Context(), f.peer, q); err == nil {
++ t.Fatal("forged launch inventory accepted")
++ }
++ starts, stops := f.units.counts()
++ if starts != 0 || stops != 0 || units.calls != 0 {
++ t.Fatalf("privileged actions before validation: starts=%d stops=%d gone=%d", starts, stops, units.calls)
++ }
++ })
++ }
++}
++
++func TestPlan11Rev4StopCannotTombstoneForeignUnit(t *testing.T) {
++ f := newLaunchFixture(t, servingOp())
++ units := &containmentCountingUnits{launchUnits: f.units}
++ f.root.Units = units
++ r := *f.req.Reservation
++ r.ID = "foreign-stop"
++ r.Group.RelativePath = instancectl.ClaimUnitControlGroup("other.example")
++ if _, err := f.root.Serve(t.Context(), f.peer, hostbox.RoleLaunchRequest{V: hostbox.RoleLaunchV, Op: hostbox.RoleLaunchStop, Reservation: &r}); err == nil {
++ t.Fatal("foreign stop request accepted")
++ }
++ in, err := f.journal.Read(t.Context(), f.target)
++ if err != nil {
++ t.Fatal(err)
++ }
++ starts, stops := f.units.counts()
++ if len(in.Reservations) != 0 || starts != 0 || stops != 0 || units.calls != 0 {
++ t.Fatalf("foreign stop changed state: reservations=%d starts=%d stops=%d gone=%d", len(in.Reservations), starts, stops, units.calls)
++ }
++}
+```
+
+<!-- apply: D4C tests -->
+```diff
+--- a/internal/host/plan11_launch_retry_linux_test.go
++++ b/internal/host/plan11_launch_retry_linux_test.go
+@@ -153,7 +153,11 @@
+ next.Target.ControllerID = "controller-2"
+ nextPeer := f.peer
+ nextPeer.ControllerID = next.Target.ControllerID
+- if err := f.journal.Prelaunch(t.Context(), hostbox.LaunchInventory{ControllerPID: nextPeer.PID, ProcessStart: nextPeer.ProcessStart, Target: next.Target, Groups: []hostbox.LaunchGroup{{RelativePath: "carlos/claim-parent", Device: 1, Inode: 9}}, NoState: true}); err != nil {
++ parent, err := claimContainmentParent("/sys/fs/cgroup/system.slice/carlos-edge.service", next.Target)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if err := f.journal.Prelaunch(t.Context(), hostbox.LaunchInventory{ControllerPID: nextPeer.PID, ProcessStart: nextPeer.ProcessStart, Target: next.Target, Groups: []hostbox.LaunchGroup{{RelativePath: parent, Device: 1, Inode: 9}}, NoState: true}); err != nil {
+ t.Fatal(err)
+ }
+ q := f.req
+```
+
+```bash
+python3 "$CLAIM11_SCRATCH/apply.py" "$CLAIM11_PLAN" D4C tests
+nice -n 10 go test -p 1 -timeout 5m ./internal/host -run 'TestPlan11Rev4' -count=1
+```
+
+Expected: the new containment helpers are not yet defined. The additional tests pair transport-level valid acceptance with the foreign-path refusals under the same root and kernel-identity providers, so a missing local systemd service cannot make the negative cases pass. They also require zero unit calls and zero journal mutation on refusal. Existing positive fixtures gain the real derived parent shape.
+
+<!-- apply: D4C implementation -->
+```diff
+--- a/internal/host/claimjournal_transport.go
++++ b/internal/host/claimjournal_transport.go
+@@ -166,6 +166,10 @@
+ }
+ }
+ }
++ if err := validateClaimJournalContainment(r.Context(), q, h.Journals.Launches); err != nil {
++ w.WriteHeader(http.StatusForbidden)
++ return
++ }
+ if q.Op == "inventory-write" {
+ if h.Reconcile.Registry == nil {
+ w.WriteHeader(http.StatusForbidden)
+--- a/internal/host/claimlaunch_root.go
++++ b/internal/host/claimlaunch_root.go
+@@ -143,6 +143,9 @@
+ if err != nil {
+ return err
+ }
++ if err := validatePersistedClaimContainment(ctx, rec); err != nil {
++ return err
++ }
+ if rec.ControllerPID != peer.PID || rec.ProcessStart != peer.ProcessStart {
+ return ErrClaimStopIdentity
+ }
+@@ -189,17 +192,29 @@
+ if err := h.launchRecord(ctx, peer, t); err != nil {
+ return hostbox.RoleLaunchGrant{}, err
+ }
++ // Root has proved there is no run unit, while holding the host guard.
++ // Spend old intentions now, before a replacement can acquire the unit name.
++ // Otherwise a late stop of an intention with no invocation ID could stop it.
++ intentions, err := h.Journal.UnitIntentions(ctx, t.Host)
++ if err != nil {
++ return hostbox.RoleLaunchGrant{}, err
++ }
++ for _, old := range intentions {
++ record, err := h.Journal.Read(ctx, old.Target)
++ if err != nil {
++ return hostbox.RoleLaunchGrant{}, err
++ }
++ if err := validatePersistedClaimContainment(ctx, record); err != nil {
++ return hostbox.RoleLaunchGrant{}, err
++ }
++ if old.Group.RelativePath != r.Group.RelativePath {
++ return hostbox.RoleLaunchGrant{}, hostbox.ErrChanged
++ }
++ }
+ // Refuse a busy unit before journaling, so a refusal leaves nothing
+ // pending in the session's stop record.
+ if err := h.Units.Gone(ctx, t.Host, ""); err != nil {
+ return hostbox.RoleLaunchGrant{}, errors.Join(hostbox.ErrChanged, err)
+- }
+- // Root has proved there is no run unit, while holding the host guard.
+- // Spend old intentions now, before a replacement can acquire the unit name.
+- // Otherwise a late stop of an intention with no invocation ID could stop it.
+- intentions, err := h.Journal.UnitIntentions(ctx, t.Host)
+- if err != nil {
+- return hostbox.RoleLaunchGrant{}, err
+ }
+ for _, old := range intentions {
+ if old.Group.RelativePath != r.Group.RelativePath {
+@@ -306,12 +321,18 @@
+ // serializes with a launch. A reservation root never saw is spent.
+ func (h *ClaimLaunchRoot) stop(ctx context.Context, peer StopPeer, r hostbox.LaunchReservation) error {
+ t := r.Target
++ if r.Group.RelativePath != instancectl.ClaimUnitControlGroup(t.Host) {
++ return hostbox.ErrRoleLaunchShape
++ }
+ if t.BootID != peer.BootID || t.ControllerID != peer.ControllerID {
+ return ErrClaimStopIdentity
+ }
+ return h.Guard.WithLocal(ctx, t.Host, func() error {
+ rec, err := h.Journal.Read(ctx, t)
+ if err != nil {
++ return err
++ }
++ if err := validatePersistedClaimContainment(ctx, rec); err != nil {
+ return err
+ }
+ if rec.ControllerPID != peer.PID || rec.ProcessStart != peer.ProcessStart {
+@@ -404,6 +425,9 @@
+ if err != nil {
+ return nil, err
+ }
++ if err := validatePersistedClaimContainment(ctx, in); err != nil {
++ return nil, err
++ }
+ ids := []string{}
+ for _, r := range in.Reservations {
+ if !hostbox.IsUnitReservation(r) {
+--- a/internal/host/claimstop_kernel_linux.go
++++ b/internal/host/claimstop_kernel_linux.go
+@@ -39,7 +39,7 @@
+ if err := verifyStopKernelMounts(k.CgroupRoot, k.ProcRoot); err != nil {
+ return StopKernelEvidence{}, err
+ }
+- j, err := k.Journal.ReadStopKernelJournal(ctx, t)
++ j, err := k.readContainedJournal(ctx, t)
+ if err != nil {
+ return StopKernelEvidence{}, err
+ }
+@@ -53,7 +53,7 @@
+ if err := k.resolveRoleIntentions(ctx, t, false); err != nil {
+ return StopKernelEvidence{}, err
+ }
+- j, err = k.Journal.ReadStopKernelJournal(ctx, t)
++ j, err = k.readContainedJournal(ctx, t)
+ if err != nil {
+ return StopKernelEvidence{}, err
+ }
+@@ -291,7 +291,7 @@
+ if !ok {
+ return refuse(ErrClaimStopUnavailable)
+ }
+- j, err := k.Journal.ReadStopKernelJournal(ctx, t)
++ j, err := k.readContainedJournal(ctx, t)
+ if err != nil {
+ return refuse(err)
+ }
+@@ -329,7 +329,7 @@
+ if err := k.resolveRoleIntentions(ctx, t, false); err != nil {
+ return refuse(err)
+ }
+- j, err = k.Journal.ReadStopKernelJournal(ctx, t)
++ j, err = k.readContainedJournal(ctx, t)
+ if err != nil {
+ return refuse(err)
+ }
+@@ -401,6 +401,9 @@
+ if observedBoot == "" || t.BootID == "" || observedBoot == t.BootID || !sameStopSession(t, j.Target) || j.ControllerPID <= 0 || j.ProcessStart == "" || j.V != 1 {
+ return refuse(ErrClaimStopIdentity)
+ }
++ if err := k.validateContainment(ctx, t, j); err != nil {
++ return refuse(err)
++ }
+ bootPath := filepath.Join(k.ProcRoot, "sys/kernel/random/boot_id")
+ checkBoot := func() error {
+ b, err := os.ReadFile(bootPath)
+@@ -420,7 +423,7 @@
+ return refuse(err)
+ }
+ var err error
+- j, err = k.Journal.ReadStopKernelJournal(ctx, t)
++ j, err = k.readContainedJournal(ctx, t)
+ if err != nil {
+ return refuse(err)
+ }
+@@ -439,7 +442,7 @@
+ if err := journal.SettleRecovered(ctx, t); err != nil {
+ return refuse(err)
+ }
+- fresh, err := k.Journal.ReadStopKernelJournal(ctx, t)
++ fresh, err := k.readContainedJournal(ctx, t)
+ if err != nil {
+ return refuse(err)
+ }
+@@ -521,6 +524,9 @@
+ }
+
+ func (k LinuxStopKernel) resolveRoleIntentions(ctx context.Context, t hostguard.FenceTarget, reboot bool) error {
++ if _, err := k.readContainedJournal(ctx, t); err != nil {
++ return err
++ }
+ resolver, ok := k.Journal.(interface {
+ ResolveRoleIntentions(context.Context, hostguard.FenceTarget, string, bool) error
+ })
+@@ -529,3 +535,48 @@
+ }
+ return resolver.ResolveRoleIntentions(ctx, t, k.CgroupRoot, reboot)
+ }
++
++func (k LinuxStopKernel) validateContainment(ctx context.Context, t hostguard.FenceTarget, j hostbox.StopKernelJournal) error {
++ if !sameStopSession(j.Target, t) || k.AgentCgroup == nil {
++ return ErrClaimStopInventory
++ }
++ agent, err := k.AgentCgroup(ctx)
++ if err != nil {
++ return err
++ }
++ return validateStopClaimGroups(t, j, agent)
++}
++
++func (k LinuxStopKernel) readContainedJournal(ctx context.Context, t hostguard.FenceTarget) (hostbox.StopKernelJournal, error) {
++ j, err := k.Journal.ReadStopKernelJournal(ctx, t)
++ if err != nil {
++ return j, err
++ }
++ if err = k.validateContainment(ctx, t, j); err != nil {
++ return hostbox.StopKernelJournal{}, err
++ }
++ // Intentions are persisted separately from the concrete groups and must be
++ // checked before their resolver opens any privileged recovery target.
++ if raw, ok := k.Journal.(interface {
++ Read(context.Context, hostguard.FenceTarget) (hostbox.LaunchInventory, error)
++ }); ok {
++ in, e := raw.Read(ctx, t)
++ if e != nil {
++ return hostbox.StopKernelJournal{}, e
++ }
++ agent, e := k.AgentCgroup(ctx)
++ if e != nil {
++ return hostbox.StopKernelJournal{}, e
++ }
++ parent, e := claimContainmentParent(agent, t)
++ if e != nil {
++ return hostbox.StopKernelJournal{}, e
++ }
++ if e = validateClaimContainment(in, parent); e != nil {
++ return hostbox.StopKernelJournal{}, e
++ }
++ } else if j.PendingRoles {
++ return hostbox.StopKernelJournal{}, ErrClaimStopInventory
++ }
++ return j, nil
++}
+--- /dev/null
++++ b/internal/host/claimjournal_containment.go
+@@ -0,0 +1,200 @@
++package host
++
++import (
++ "context"
++ "path/filepath"
++ "strings"
++
++ "github.com/carlosframework/platform/internal/cgroup"
++ "github.com/carlosframework/platform/internal/hostbox"
++ "github.com/carlosframework/platform/internal/hostguard"
++ "github.com/carlosframework/platform/internal/instancectl"
++)
++
++func claimContainmentParent(agent string, t hostguard.FenceTarget) (string, error) {
++ rel, err := filepath.Rel("/sys/fs/cgroup", agent)
++ if err != nil || !filepath.IsLocal(rel) || rel == "." || filepath.Clean(agent) != agent || !stopComponent(t.Host) || t.Incarnation == "" || t.Stamp == 0 || t.Session == 0 || t.Box == "" || t.Enrollment == "" || t.BootID == "" || t.ControllerID == "" {
++ return "", ErrClaimStopInventory
++ }
++ return filepath.Join(rel, cgroup.ExecName(claimContainmentName(t))), nil
++}
++
++func claimRoleChild(parent, p string) bool {
++ return filepath.IsLocal(p) && filepath.Clean(p) == p && filepath.Dir(p) == parent && strings.HasPrefix(filepath.Base(p), "role-") && len(filepath.Base(p)) > len("role-")
++}
++
++func validateClaimGroups(parent string, groups []hostbox.LaunchGroup) error {
++ if len(groups) == 0 {
++ return ErrClaimStopInventory
++ }
++ found := false
++ seen := map[string]bool{}
++ for _, g := range groups {
++ if g.Device == 0 || g.Inode == 0 || seen[g.RelativePath] {
++ return ErrClaimStopInventory
++ }
++ seen[g.RelativePath] = true
++ if g.RelativePath == parent {
++ if g.InvocationID != "" {
++ return ErrClaimStopInventory
++ }
++ found = true
++ } else if !claimRoleChild(parent, g.RelativePath) || g.InvocationID == "" {
++ return ErrClaimStopInventory
++ }
++ }
++ if !found {
++ return ErrClaimStopInventory
++ }
++ return nil
++}
++
++func validateClaimContainment(in hostbox.LaunchInventory, parent string) error {
++ if err := validateClaimGroups(parent, in.Groups); err != nil {
++ return err
++ }
++ var parentGroup hostbox.LaunchGroup
++ groups := map[string]hostbox.LaunchGroup{}
++ for _, g := range in.Groups {
++ groups[g.RelativePath] = g
++ if g.RelativePath == parent {
++ parentGroup = g
++ }
++ }
++ claimed := map[string]bool{}
++ for _, r := range in.Reservations {
++ if !hostbox.SameLaunchOwner(in.Target, r.Target) {
++ return ErrClaimStopInventory
++ }
++ if hostbox.IsUnitReservation(r) {
++ if r.Parent != (hostbox.LaunchGroup{}) || r.Group.RelativePath != instancectl.ClaimUnitControlGroup(in.Target.Host) {
++ return ErrClaimStopInventory
++ }
++ continue
++ }
++ if r.Parent != parentGroup || !claimRoleChild(parent, r.Group.RelativePath) || r.Group.InvocationID != r.InvocationID || r.InvocationID == "" || claimed[r.Group.RelativePath] {
++ return ErrClaimStopInventory
++ }
++ claimed[r.Group.RelativePath] = true
++ switch r.State {
++ case "intended":
++ if r.Group.Device != 0 || r.Group.Inode != 0 {
++ return ErrClaimStopInventory
++ }
++ case "reserved", "started", "settled":
++ // An intention settled after an absent child retains its zero identity.
++ if r.State == "settled" && r.Group.Device == 0 && r.Group.Inode == 0 {
++ continue
++ }
++ if groups[r.Group.RelativePath] != r.Group {
++ return ErrClaimStopInventory
++ }
++ default:
++ return ErrClaimStopInventory
++ }
++ }
++ for _, g := range in.Groups {
++ if g.RelativePath != parent && !claimed[g.RelativePath] {
++ return ErrClaimStopInventory
++ }
++ }
++ return nil
++}
++
++func validateClaimJournalContainment(ctx context.Context, q hostbox.JournalRequest, j *hostbox.LaunchJournal) error {
++ switch q.Op {
++ case "create", "prelaunch", "intend", "reserve":
++ default:
++ return nil
++ }
++ target := q.Launch.Target
++ if q.Op == "intend" || q.Op == "reserve" {
++ target = q.Reservation.Target
++ }
++ agent, err := claimContainmentAgentRoot(ctx)
++ if err != nil {
++ return err
++ }
++ parent, err := claimContainmentParent(agent, target)
++ if err != nil {
++ return err
++ }
++ if q.Op == "create" || q.Op == "prelaunch" {
++ if len(q.Launch.Groups) != 1 || len(q.Launch.Reservations) != 0 {
++ return ErrClaimStopInventory
++ }
++ if err := validateClaimContainment(q.Launch, parent); err != nil {
++ return err
++ }
++ captured, err := claimContainmentInspect(parent)
++ if err != nil {
++ return err
++ }
++ if captured != q.Launch.Groups[0] {
++ return ErrClaimStopIdentity
++ }
++ return nil
++ }
++ in, err := j.Read(ctx, target)
++ if err != nil {
++ return err
++ }
++ if err := validateClaimContainment(in, parent); err != nil {
++ return err
++ }
++ r := q.Reservation
++ var expected hostbox.LaunchGroup
++ for _, g := range in.Groups {
++ if g.RelativePath == parent {
++ expected = g
++ }
++ }
++ if r.Parent != expected || !claimRoleChild(parent, r.Group.RelativePath) || r.Group.InvocationID != r.InvocationID || r.InvocationID == "" {
++ return ErrClaimStopInventory
++ }
++ captured, err := claimContainmentInspect(parent)
++ if err != nil {
++ return err
++ }
++ if captured != expected {
++ return ErrClaimStopIdentity
++ }
++ if q.Op == "reserve" {
++ captured, err = claimContainmentInspect(r.Group.RelativePath)
++ if err != nil {
++ return err
++ }
++ captured.InvocationID = r.InvocationID
++ if captured != r.Group {
++ return ErrClaimStopIdentity
++ }
++ }
++ return nil
++}
++
++func validatePersistedClaimContainment(ctx context.Context, in hostbox.LaunchInventory) error {
++ agent, err := claimContainmentAgentRoot(ctx)
++ if err != nil {
++ return err
++ }
++ parent, err := claimContainmentParent(agent, in.Target)
++ if err != nil {
++ return err
++ }
++ return validateClaimContainment(in, parent)
++}
++
++func validateStopClaimGroups(t hostguard.FenceTarget, j hostbox.StopKernelJournal, agent string) error {
++ if !hostbox.SameLaunchOwner(j.Target, t) {
++ return ErrClaimStopInventory
++ }
++ parent, err := claimContainmentParent(agent, t)
++ if err != nil {
++ return err
++ }
++ groups := make([]hostbox.LaunchGroup, 0, len(j.Cgroups))
++ for _, g := range j.Cgroups {
++ groups = append(groups, hostbox.LaunchGroup{RelativePath: g.RelativePath, Device: g.Device, Inode: g.Inode, InvocationID: g.InvocationID})
++ }
++ return validateClaimGroups(parent, groups)
++}
+--- /dev/null
++++ b/internal/host/claimjournal_containment_linux.go
+@@ -0,0 +1,37 @@
++//go:build linux
++
++package host
++
++import (
++ "github.com/carlosframework/platform/internal/hostbox"
++ "golang.org/x/sys/unix"
++ "path/filepath"
++ "strings"
++)
++
++var claimContainmentAgentRoot = agentUnitCgroup
++var claimContainmentInspect = inspectClaimContainment
++
++func inspectClaimContainment(relative string) (hostbox.LaunchGroup, error) {
++ if !filepath.IsLocal(relative) || filepath.Clean(relative) != relative || relative == "." {
++ return hostbox.LaunchGroup{}, ErrClaimStopInventory
++ }
++ fd, err := unix.Open("/sys/fs/cgroup", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
++ if err != nil {
++ return hostbox.LaunchGroup{}, err
++ }
++ for _, part := range strings.Split(relative, "/") {
++ next, e := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
++ unix.Close(fd)
++ if e != nil {
++ return hostbox.LaunchGroup{}, e
++ }
++ fd = next
++ }
++ defer unix.Close(fd)
++ var st unix.Stat_t
++ if err = unix.Fstat(fd, &st); err != nil {
++ return hostbox.LaunchGroup{}, err
++ }
++ return hostbox.LaunchGroup{RelativePath: relative, Device: uint64(st.Dev), Inode: st.Ino}, nil
++}
+--- /dev/null
++++ b/internal/host/claimjournal_containment_other.go
+@@ -0,0 +1,11 @@
++//go:build !linux
++
++package host
++
++import (
++ "context"
++ "github.com/carlosframework/platform/internal/hostbox"
++)
++
++var claimContainmentAgentRoot = func(context.Context) (string, error) { return "", ErrClaimStopUnavailable }
++var claimContainmentInspect = func(string) (hostbox.LaunchGroup, error) { return hostbox.LaunchGroup{}, ErrClaimStopUnavailable }
+```
+
+<!-- apply: D4C implementation -->
+```diff
+--- a/internal/host/claimstop_writers_linux.go
++++ b/internal/host/claimstop_writers_linux.go
+@@ -55,6 +55,9 @@
+ if err != nil {
+ return WritersKernelEvidence{}, err
+ }
++ if _, err := k.readContainedJournal(ctx, t); err != nil {
++ return WritersKernelEvidence{}, err
++ }
+ return verifyWritersAt(ctx, roots, k.Journal, t, inv, peer, hints)
+ }
+```
+
+```bash
+python3 "$CLAIM11_SCRATCH/apply.py" "$CLAIM11_PLAN" D4C implementation
+nice -n 10 go test -p 1 -timeout 5m ./internal/host -run 'TestPlan11Rev4|TestClaim(UnitStopKernel|KernelPositiveReboot)|TestClaimLaunchRoot|TestPlan11Launch|TestPlan11Writer|TestVerifyWritersAt|TestRev4Writers' -count=1
+```
+
+Expected: all pass. A valid parent and exact isolated host unit remain usable; unrelated groups, forged parent identity, parentless additions, foreign unit requests and forged persisted inventories refuse before privileged effects.
+
+### D4M: recover only the migration's actual mask object
+
+Invariant: a migration ownership record authorizes removing only the symlink object that migration created. Boot identity, the unit pathname and a `/dev/null` target alone cannot distinguish that object from an operator's mask.
+
+Create a uniquely named private symlink under the runtime systemd directory, durably record its boot ID, random token, device and inode in root's journal directory, then hard-link that object into `carlos-edge.service` without replacing anything already there. Retain the private hard-link anchor until cleanup; it prevents the symlink inode from being reused while ownership is recorded. Reload systemd after installing the mask. Do not use ctime as identity: creating a hard link changes it.
+
+Both normal release and completed-migration recovery require the current runtime mask and its private anchor to match the recorded object. A boot-only legacy intent, an interrupted creation followed by an operator mask, a missing anchor, or a same-target replacement preserves the mask. A failed unmask retains the record and anchor for retry. Cleanup removes the private anchor only when its identity matches; stale ownership never transfers to the replacement object.
+
+The migration remains offline. Systemd exposes a name-based unmask, not an atomic compare-and-unlink operation. An operator must not concurrently replace the runtime unit pathname during that final call; the object check protects replacements made before it, including replacements while migration was interrupted. The tests use a temporary runtime directory and a mocked systemctl, without changing a real service.
+
+<!-- apply: D4MR tests -->
+```diff
+--- a/internal/host/plan11rev4_claimjournal_migration_linux_test.go
++++ b/internal/host/plan11rev4_claimjournal_migration_linux_test.go
+@@ -0,0 +1,29 @@
++//go:build linux
++
++package host
++
++import (
++ "os"
++ "strings"
++ "testing"
++)
++
++// A same-boot boot-ID record can be an intent written before mask(2), not
++// proof that the migration created the mask now at the unit path. Recovery
++// must leave that ambiguous runtime mask in place.
++func TestPlan11Rev4IntentBeforeMaskCrashKeepsLaterOperatorMask(t *testing.T) {
++ var calls []string
++ plan9Fix2Systemctl(t, "masked-runtime", &calls)
++ owner := plan9Fix2MaskOwner(t)
++ if err := os.WriteFile(owner, []byte(plan9Fix2Boot(t)), 0600); err != nil {
++ t.Fatal(err)
++ }
++ if err := recoverLegacyClaimEdgeMask(t.Context()); err != nil {
++ t.Fatal(err)
++ }
++ for _, call := range calls {
++ if strings.HasPrefix(call, "unmask ") {
++ t.Fatalf("ambiguous operator mask was lifted: %v", calls)
++ }
++ }
++}
+```
+
+```bash
+python3 "$CLAIM11_SCRATCH/apply.py" "$CLAIM11_PLAN" D4MR tests
+nice -n 10 go test -p 1 -timeout 5m ./internal/host -run 'TestPlan11Rev4IntentBeforeMaskCrashKeepsLaterOperatorMask' -count=1
+```
+
+Expected: Rev 3 calls `unmask --runtime carlos-edge.service` on the later operator mask because the boot ID matches.
+
+<!-- apply: D4M tests -->
+```diff
+--- a/internal/host/plan9fix2_claimjournal_migration_linux_test.go
++++ b/internal/host/plan9fix2_claimjournal_migration_linux_test.go
+@@ -11,10 +11,26 @@
+ "testing"
+ )
+
++var plan9Fix2RuntimeMaskDefault = claimEdgeRuntimeMask
++
+ // plan9Fix2Systemctl fakes systemctl for a stopped edge whose unit file state
+ // is enabled. It records every call.
+ func plan9Fix2Systemctl(t *testing.T, enabled string, calls *[]string) {
+ t.Helper()
++ if claimEdgeRuntimeMask == plan9Fix2RuntimeMaskDefault {
++ restoreMask := claimEdgeRuntimeMask
++ t.Cleanup(func() { claimEdgeRuntimeMask = restoreMask })
++ claimEdgeRuntimeMask = filepath.Join(t.TempDir(), "carlos-edge.service")
++ }
++ if enabled == "masked-runtime" {
++ if _, err := os.Lstat(claimEdgeRuntimeMask); os.IsNotExist(err) {
++ if err = os.Symlink("/dev/null", claimEdgeRuntimeMask); err != nil {
++ t.Fatal(err)
++ }
++ } else if err != nil {
++ t.Fatal(err)
++ }
++ }
+ restore := claimSystemctl
+ t.Cleanup(func() { claimSystemctl = restore })
+ restoreOwner := claimQuiesceOwner
+@@ -27,6 +43,14 @@
+ return []byte(enabled + "\n"), nil
+ case "show":
+ return []byte("inactive\n"), nil
++ case "mask":
++ return nil, os.Symlink("/dev/null", claimEdgeRuntimeMask)
++ case "unmask":
++ err := os.Remove(claimEdgeRuntimeMask)
++ if os.IsNotExist(err) {
++ err = nil
++ }
++ return nil, err
+ }
+ return nil, nil
+ }
+@@ -49,15 +73,33 @@
+ return strings.TrimSpace(string(boot))
+ }
+
++func plan11Rev4WriteOwnedMask(t *testing.T, owner string) claimEdgeMaskRecord {
++ t.Helper()
++ token := strings.Repeat("a", 32)
++ anchor := claimEdgeMaskAnchor(token)
++ if err := os.Link(claimEdgeRuntimeMask, anchor); err != nil {
++ t.Fatal(err)
++ }
++ record, err := currentClaimEdgeMask(anchor, plan9Fix2Boot(t), token)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if err = writeClaimEdgeMaskRecord(record); err != nil {
++ t.Fatal(err)
++ }
++ if owner != claimEdgeMaskOwner {
++ t.Fatalf("owner fixture drifted: %q != %q", owner, claimEdgeMaskOwner)
++ }
++ return record
++}
++
+ // m-7: a runtime mask left by a migration that was killed while masked is
+ // recognised as the migration's own and lifted by the next attempt.
+ func TestPlan9Fix2QuiesceLiftsMaskLeftByCrashedAttempt(t *testing.T) {
+ var calls []string
+ plan9Fix2Systemctl(t, "masked-runtime", &calls)
+ owner := plan9Fix2MaskOwner(t)
+- if err := os.WriteFile(owner, []byte(plan9Fix2Boot(t)), 0600); err != nil {
+- t.Fatal(err)
+- }
++ plan11Rev4WriteOwnedMask(t, owner)
+ release, err := quiesceLegacyClaimJournals(t.Context())
+ if err != nil {
+ t.Fatal(err)
+@@ -71,17 +113,21 @@
+ }
+ }
+
+-// m-7: the record is written before the mask, so a kill between the two still
+-// leaves the mask recognisable.
+-func TestPlan9Fix2QuiesceRecordsMaskBeforeMasking(t *testing.T) {
++// The durable record identifies the exact private anchor that is linked into
++// the unit name before systemd reloads its view.
++func TestPlan9Fix2QuiesceRecordsConcreteMaskObject(t *testing.T) {
+ var calls []string
+ plan9Fix2Systemctl(t, "enabled", &calls)
+ owner := plan9Fix2MaskOwner(t)
+ restore := claimSystemctl
+ claimSystemctl = func(ctx context.Context, args ...string) ([]byte, error) {
+- if args[0] == "mask" {
+- if raw, err := os.ReadFile(owner); err != nil || string(raw) != plan9Fix2Boot(t) {
+- t.Errorf("masked before the record was written: %q %v", raw, err)
++ if args[0] == "daemon-reload" {
++ raw, err := os.ReadFile(owner)
++ record, recorded := decodeClaimEdgeMaskRecord(raw, plan9Fix2Boot(t))
++ if err != nil || !recorded {
++ t.Errorf("mask identity not recorded before reload: %q %v", raw, err)
++ } else if ours, identityErr := claimEdgeMaskStillOwned(record); identityErr != nil || !ours {
++ t.Errorf("record does not own concrete mask: %v %v", ours, identityErr)
+ }
+ }
+ return restore(ctx, args...)
+@@ -91,7 +137,7 @@
+ t.Fatal(err)
+ }
+ release()
+- if !slices.Contains(calls, "mask --runtime carlos-edge.service") || !slices.Contains(calls, "unmask --runtime carlos-edge.service") {
++ if !slices.Contains(calls, "daemon-reload") || !slices.Contains(calls, "unmask --runtime carlos-edge.service") {
+ t.Fatalf("calls: %v", calls)
+ }
+ if _, err = os.Stat(owner); !os.IsNotExist(err) {
+--- a/internal/host/plan9fix4_claimjournal_migration_linux_test.go
++++ b/internal/host/plan9fix4_claimjournal_migration_linux_test.go
+@@ -204,8 +204,13 @@
+ if err = journal.Close(); err != nil {
+ t.Fatal(err)
+ }
+- if raw, err := os.ReadFile(owner); err != nil || string(raw) != plan9Fix2Boot(t) {
+- t.Fatalf("commit lost crash mask: %q %v", raw, err)
++ raw, err := os.ReadFile(owner)
++ record, recorded := decodeClaimEdgeMaskRecord(raw, plan9Fix2Boot(t))
++ if err != nil || !recorded {
++ t.Fatalf("commit lost crash mask identity: %q %v", raw, err)
++ }
++ if ours, identityErr := claimEdgeMaskStillOwned(record); identityErr != nil || !ours {
++ t.Fatalf("commit changed crash mask identity: %v %v", ours, identityErr)
+ }
+ if removeLegacy {
+ if err = os.RemoveAll(legacy); err != nil {
+@@ -277,9 +282,7 @@
+ if err := migrateClaimJournals(t.Context(), store, resources, owners); err != nil {
+ t.Fatal(err)
+ }
+- if err := os.WriteFile(owner, []byte(plan9Fix2Boot(t)), 0600); err != nil {
+- t.Fatal(err)
+- }
++ record := plan11Rev4WriteOwnedMask(t, owner)
+ systemctl := claimSystemctl
+ claimSystemctl = func(ctx context.Context, args ...string) ([]byte, error) {
+ if args[0] == "unmask" {
+@@ -290,8 +293,10 @@
+ if err := migrateClaimJournals(t.Context(), store, resources, owners); err == nil {
+ t.Fatal("unmask failure reported success")
+ }
+- if raw, err := os.ReadFile(owner); err != nil || string(raw) != plan9Fix2Boot(t) {
++ if raw, err := os.ReadFile(owner); err != nil {
+ t.Fatalf("failed recovery discarded mask ownership: %q %v", raw, err)
++ } else if got, ok := decodeClaimEdgeMaskRecord(raw, plan9Fix2Boot(t)); !ok || got != record {
++ t.Fatalf("failed recovery changed mask ownership: %q", raw)
+ }
+ claimSystemctl = systemctl
+ if err := migrateClaimJournals(t.Context(), store, resources, owners); err != nil {
+--- a/internal/host/plan11rev4_claimjournal_migration_linux_test.go
++++ b/internal/host/plan11rev4_claimjournal_migration_linux_test.go
+@@ -4,10 +4,23 @@
+
+ import (
+ "os"
++ "path/filepath"
++ "slices"
+ "strings"
+ "testing"
+ )
+
++func plan11Rev4ReplaceRuntimeMask(t *testing.T) {
++ t.Helper()
++ replacement := filepath.Join(filepath.Dir(claimEdgeRuntimeMask), "operator-mask")
++ if err := os.Symlink("/dev/null", replacement); err != nil {
++ t.Fatal(err)
++ }
++ if err := os.Rename(replacement, claimEdgeRuntimeMask); err != nil {
++ t.Fatal(err)
++ }
++}
++
+ // A same-boot boot-ID record can be an intent written before mask(2), not
+ // proof that the migration created the mask now at the unit path. Recovery
+ // must leave that ambiguous runtime mask in place.
+@@ -26,4 +39,124 @@
+ t.Fatalf("ambiguous operator mask was lifted: %v", calls)
+ }
+ }
++ if target, err := os.Readlink(claimEdgeRuntimeMask); err != nil || target != "/dev/null" {
++ t.Fatalf("operator mask changed: %q %v", target, err)
++ }
++}
++
++func TestPlan11Rev4IdentityIntentBeforeLinkKeepsLaterOperatorMask(t *testing.T) {
++ var calls []string
++ plan9Fix2Systemctl(t, "masked-runtime", &calls)
++ owner := plan9Fix2MaskOwner(t)
++ token := strings.Repeat("b", 32)
++ anchor := claimEdgeMaskAnchor(token)
++ if err := os.Symlink("/dev/null", anchor); err != nil {
++ t.Fatal(err)
++ }
++ record, err := currentClaimEdgeMask(anchor, plan9Fix2Boot(t), token)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if err = writeClaimEdgeMaskRecord(record); err != nil {
++ t.Fatal(err)
++ }
++ if err = recoverLegacyClaimEdgeMask(t.Context()); err != nil {
++ t.Fatal(err)
++ }
++ if slices.Contains(calls, "unmask --runtime carlos-edge.service") {
++ t.Fatalf("intent authorised a later operator mask: %v", calls)
++ }
++ if target, err := os.Readlink(claimEdgeRuntimeMask); err != nil || target != "/dev/null" {
++ t.Fatalf("operator mask changed: %q %v", target, err)
++ }
++ if _, err = os.Stat(owner); !os.IsNotExist(err) {
++ t.Fatalf("stale intent remains: %v", err)
++ }
++ if _, err = os.Lstat(anchor); !os.IsNotExist(err) {
++ t.Fatalf("stale intent anchor remains: %v", err)
++ }
++}
++
++func TestPlan11Rev4ReleaseKeepsSameTargetOperatorReplacement(t *testing.T) {
++ var calls []string
++ plan9Fix2Systemctl(t, "enabled", &calls)
++ owner := plan9Fix2MaskOwner(t)
++ release, err := quiesceLegacyClaimJournals(t.Context())
++ if err != nil {
++ t.Fatal(err)
++ }
++ plan11Rev4ReplaceRuntimeMask(t)
++ calls = nil
++ release()
++ if slices.Contains(calls, "unmask --runtime carlos-edge.service") {
++ t.Fatalf("replacement mask was lifted: %v", calls)
++ }
++ if target, err := os.Readlink(claimEdgeRuntimeMask); err != nil || target != "/dev/null" {
++ t.Fatalf("replacement mask changed: %q %v", target, err)
++ }
++ if _, err = os.Stat(owner); !os.IsNotExist(err) {
++ t.Fatalf("stale ownership record remains: %v", err)
++ }
++}
++
++func TestPlan11Rev4CompletedRecoveryKeepsSameTargetReplacement(t *testing.T) {
++ var calls []string
++ plan9Fix2Systemctl(t, "masked-runtime", &calls)
++ owner := plan9Fix2MaskOwner(t)
++ plan11Rev4WriteOwnedMask(t, owner)
++ plan11Rev4ReplaceRuntimeMask(t)
++ calls = nil
++ if err := recoverLegacyClaimEdgeMask(t.Context()); err != nil {
++ t.Fatal(err)
++ }
++ if slices.Contains(calls, "unmask --runtime carlos-edge.service") {
++ t.Fatalf("completed recovery lifted replacement mask: %v", calls)
++ }
++ if target, err := os.Readlink(claimEdgeRuntimeMask); err != nil || target != "/dev/null" {
++ t.Fatalf("replacement mask changed: %q %v", target, err)
++ }
++}
++
++func TestPlan11Rev4OwnedIdentityIsDurableAndCleansUp(t *testing.T) {
++ var calls []string
++ plan9Fix2Systemctl(t, "enabled", &calls)
++ owner := plan9Fix2MaskOwner(t)
++ release, err := quiesceLegacyClaimJournals(t.Context())
++ if err != nil {
++ t.Fatal(err)
++ }
++ raw, err := os.ReadFile(owner)
++ if err != nil {
++ t.Fatal(err)
++ }
++ record, recorded := decodeClaimEdgeMaskRecord(raw, plan9Fix2Boot(t))
++ if !recorded {
++ t.Fatalf("ownership record is not a concrete identity: %q", raw)
++ }
++ if ours, identityErr := claimEdgeMaskStillOwned(record); identityErr != nil || !ours {
++ t.Fatalf("ownership does not name the live mask: %v %v", ours, identityErr)
++ }
++ info, err := os.Stat(owner)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if info.Mode().Perm() != 0600 {
++ t.Fatalf("ownership mode = %o, want 0600", info.Mode().Perm())
++ }
++ if _, err = os.Stat(owner + ".tmp"); !os.IsNotExist(err) {
++ t.Fatalf("durable-write temporary remains: %v", err)
++ }
++ release()
++ if !slices.Contains(calls, "unmask --runtime carlos-edge.service") {
++ t.Fatalf("owned mask was not lifted: %v", calls)
++ }
++ if _, err = os.Lstat(claimEdgeRuntimeMask); !os.IsNotExist(err) {
++ t.Fatalf("owned runtime mask remains: %v", err)
++ }
++ if _, err = os.Stat(owner); !os.IsNotExist(err) {
++ t.Fatalf("owned mask record remains: %v", err)
++ }
++ if _, err = os.Lstat(claimEdgeMaskAnchor(record.Token)); !os.IsNotExist(err) {
++ t.Fatalf("owned mask anchor remains: %v", err)
++ }
+ }
+```
+
+```bash
+python3 "$CLAIM11_SCRATCH/apply.py" "$CLAIM11_PLAN" D4M tests
+nice -n 10 go test -p 1 -timeout 5m ./internal/host -run 'TestPlan11Rev4.*(Mask|Replacement|Identity)|TestPlan9Fix[24]' -count=1
+```
+
+Expected: the object-identity helpers are missing. These tests replace the old boot-only fixture assumptions, cover replacement with another `/dev/null` symlink, exercise the crash gap before linking, and retain the successful owned-mask cleanup and unmask-error retry cases.
+
+<!-- apply: D4M implementation -->
+```diff
+--- a/internal/host/claimjournal_migration_linux.go
++++ b/internal/host/claimjournal_migration_linux.go
+@@ -4,30 +4,185 @@
+
+ import (
+ "context"
++ "crypto/rand"
++ "encoding/hex"
++ "encoding/json"
+ "errors"
+- "github.com/carlosframework/platform/internal/hostbox"
+- "golang.org/x/sys/unix"
+ "log"
+ "os"
+ "os/exec"
++ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
++
++ "github.com/carlosframework/platform/internal/hostbox"
++ "golang.org/x/sys/unix"
+ )
+
+ var claimQuiesceOwner = claimStateOwner
+
+-// claimEdgeMaskOwner records that the migration, not an operator, put the
+-// edge's runtime mask in place. It holds the boot ID and is written before
+-// the mask: a runtime mask ends at reboot, so a record from another boot is
+-// stale. It lives in root's own journal directory, which the agent cannot
+-// write.
++// claimEdgeMaskOwner records the identity of the runtime-mask object that the
++// migration put in place. It lives in root's own journal directory, which the
++// agent cannot write.
+ var claimEdgeMaskOwner = "/var/lib/carlos-claims/edge-mask"
+
++var claimEdgeRuntimeMask = "/run/systemd/system/carlos-edge.service"
++
++type claimEdgeMaskRecord struct {
++ Version int `json:"v"`
++ Boot string `json:"boot"`
++ Token string `json:"token"`
++ Device uint64 `json:"device"`
++ Inode uint64 `json:"inode"`
++}
++
+ var claimSystemctl = func(ctx context.Context, args ...string) ([]byte, error) {
+ return exec.CommandContext(ctx, "systemctl", args...).Output()
+ }
+
++func claimEdgeMaskAnchor(token string) string {
++ return filepath.Join(filepath.Dir(claimEdgeRuntimeMask), ".carlos-edge.service.mask-"+token)
++}
++
++func currentClaimEdgeMask(path, boot, token string) (claimEdgeMaskRecord, error) {
++ var st unix.Stat_t
++ if err := unix.Lstat(path, &st); err != nil {
++ return claimEdgeMaskRecord{}, err
++ }
++ return claimEdgeMaskRecord{
++ Version: 1,
++ Boot: boot,
++ Token: token,
++ Device: uint64(st.Dev),
++ Inode: st.Ino,
++ }, nil
++}
++
++func decodeClaimEdgeMaskRecord(raw []byte, boot string) (claimEdgeMaskRecord, bool) {
++ var record claimEdgeMaskRecord
++ if json.Unmarshal(raw, &record) != nil {
++ return claimEdgeMaskRecord{}, false
++ }
++ token, tokenErr := hex.DecodeString(record.Token)
++ if tokenErr != nil || len(token) != 16 || record.Version != 1 || record.Boot != boot || record.Inode == 0 {
++ return claimEdgeMaskRecord{}, false
++ }
++ return record, true
++}
++
++func claimEdgeMaskStillOwned(record claimEdgeMaskRecord) (bool, error) {
++ anchor, err := currentClaimEdgeMask(claimEdgeMaskAnchor(record.Token), record.Boot, record.Token)
++ if errors.Is(err, os.ErrNotExist) {
++ return false, nil
++ }
++ if err != nil || anchor != record {
++ return false, err
++ }
++ current, err := currentClaimEdgeMask(claimEdgeRuntimeMask, record.Boot, record.Token)
++ if errors.Is(err, os.ErrNotExist) {
++ return false, nil
++ }
++ return current == record, err
++}
++
++func writeClaimEdgeMaskRecord(record claimEdgeMaskRecord) error {
++ raw, err := json.Marshal(record)
++ if err != nil {
++ return err
++ }
++ return writeFileDurable(claimEdgeMaskOwner, append(raw, '\n'), 0600)
++}
++
++func discardClaimEdgeMaskRecord(record claimEdgeMaskRecord, recorded bool) error {
++ var anchorErr error
++ if recorded {
++ anchor := claimEdgeMaskAnchor(record.Token)
++ current, err := currentClaimEdgeMask(anchor, record.Boot, record.Token)
++ if err == nil && current == record {
++ if err = os.Remove(anchor); err == nil {
++ err = syncDir(filepath.Dir(anchor))
++ }
++ }
++ if err != nil && !errors.Is(err, os.ErrNotExist) {
++ anchorErr = err
++ }
++ }
++ return errors.Join(anchorErr, removeDurable(claimEdgeMaskOwner))
++}
++
++func createClaimEdgeMask(ctx context.Context, boot string) (claimEdgeMaskRecord, error) {
++ tokenBytes := make([]byte, 16)
++ if _, err := rand.Read(tokenBytes); err != nil {
++ return claimEdgeMaskRecord{}, err
++ }
++ token := hex.EncodeToString(tokenBytes)
++ anchor := claimEdgeMaskAnchor(token)
++ if err := os.Symlink("/dev/null", anchor); err != nil {
++ return claimEdgeMaskRecord{}, err
++ }
++ record, err := currentClaimEdgeMask(anchor, boot, token)
++ if err != nil {
++ removeErr := os.Remove(anchor)
++ if removeErr == nil {
++ removeErr = syncDir(filepath.Dir(anchor))
++ }
++ return claimEdgeMaskRecord{}, errors.Join(err, removeErr)
++ }
++ if err == nil {
++ err = writeClaimEdgeMaskRecord(record)
++ }
++ if err == nil {
++ err = os.Link(anchor, claimEdgeRuntimeMask)
++ }
++ if err == nil {
++ err = syncDir(filepath.Dir(claimEdgeRuntimeMask))
++ }
++ if err == nil {
++ _, err = claimSystemctl(ctx, "daemon-reload")
++ }
++ if err == nil {
++ var ours bool
++ ours, err = claimEdgeMaskStillOwned(record)
++ if err == nil && !ours {
++ err = errors.New("claim journal migration: runtime mask identity changed")
++ }
++ }
++ if err != nil {
++ // If linking did not put our exact object at the unit name, cleanup
++ // removes only the private anchor and record. An object at the unit
++ // name is preserved.
++ ours, identityErr := claimEdgeMaskStillOwned(record)
++ if identityErr == nil && ours {
++ identityErr = releaseClaimEdgeMask(context.Background(), record)
++ } else {
++ identityErr = errors.Join(identityErr, discardClaimEdgeMaskRecord(record, record.Inode != 0))
++ }
++ return claimEdgeMaskRecord{}, errors.Join(err, identityErr)
++ }
++ return record, nil
++}
++
++// releaseClaimEdgeMask checks the object again immediately before asking
++// systemd to remove it. A missing or replaced object makes the ownership
++// record stale; it never grants authority over the object now at the path.
++func releaseClaimEdgeMask(ctx context.Context, record claimEdgeMaskRecord) error {
++ ours, err := claimEdgeMaskStillOwned(record)
++ if err != nil {
++ return err
++ }
++ if !ours {
++ return discardClaimEdgeMaskRecord(record, true)
++ }
++ // systemd has no compare-and-unlink operation. This check protects every
++ // replacement visible before the unmask call; a root operator concurrently
++ // replacing the name during this call is outside the migration protocol.
++ if _, err = claimSystemctl(ctx, "unmask", "--runtime", "carlos-edge.service"); err != nil {
++ return err
++ }
++ return discardClaimEdgeMaskRecord(record, true)
++}
++
+ func claimEdgeStopped(ctx context.Context) error {
+ state, err := claimSystemctl(ctx, "show", "-p", "ActiveState", "--value", "carlos-edge.service")
+ if err != nil || (strings.TrimSpace(string(state)) != "inactive" && strings.TrimSpace(string(state)) != "failed") {
+@@ -62,21 +217,25 @@
+ if err != nil && !masked {
+ return nil, err
+ }
+- // A runtime mask recorded in this boot was left by a migration that was
+- // killed while masked: it is ours to lift. Any other existing mask belongs
+- // to its operator and stays in place, and a record that names no live
+- // mask of ours is stale.
+- ours := state == "masked-runtime" && len(record) != 0 && string(record) == strings.TrimSpace(string(boot))
+- if !ours && record != nil {
+- if err = os.Remove(claimEdgeMaskOwner); err != nil {
++ // Only the identity of the concrete runtime-mask object grants authority
++ // to lift it. A same-boot intent, malformed record, or replaced object is
++ // ambiguous and therefore leaves the mask in place.
++ owned, recorded := decodeClaimEdgeMaskRecord(record, strings.TrimSpace(string(boot)))
++ ours := false
++ if state == "masked-runtime" && recorded {
++ ours, err = claimEdgeMaskStillOwned(owned)
++ if err != nil {
+ return nil, err
+ }
+ }
+- if !masked {
+- if err = os.WriteFile(claimEdgeMaskOwner, []byte(strings.TrimSpace(string(boot))), 0600); err != nil {
++ if !ours && record != nil {
++ if err = discardClaimEdgeMaskRecord(owned, recorded); err != nil {
+ return nil, err
+ }
+- if _, err = claimSystemctl(ctx, "mask", "--runtime", "carlos-edge.service"); err != nil {
++ }
++ if !masked {
++ owned, err = createClaimEdgeMask(ctx, strings.TrimSpace(string(boot)))
++ if err != nil {
+ return nil, err
+ }
+ ours = true
+@@ -85,12 +244,8 @@
+ if !ours {
+ return
+ }
+- if _, err := claimSystemctl(context.Background(), "unmask", "--runtime", "carlos-edge.service"); err != nil {
++ if err := releaseClaimEdgeMask(context.Background(), owned); err != nil {
+ log.Printf("claim journal migration: edge runtime mask remains: %v", err)
+- return
+- }
+- if err := os.Remove(claimEdgeMaskOwner); err != nil && !errors.Is(err, os.ErrNotExist) {
+- log.Printf("claim journal migration: edge mask record remains: %v", err)
+ }
+ }
+ // The edge may have started between the first check and the mask.
+@@ -138,13 +293,9 @@
+ if err != nil && state != "masked" && state != "masked-runtime" {
+ return err
+ }
+- if state == "masked-runtime" && string(record) == strings.TrimSpace(string(boot)) {
+- if _, err = claimSystemctl(ctx, "unmask", "--runtime", "carlos-edge.service"); err != nil {
+- return err
+- }
+- }
+- if err = os.Remove(claimEdgeMaskOwner); err != nil && !errors.Is(err, os.ErrNotExist) {
+- return err
++ owned, recorded := decodeClaimEdgeMaskRecord(record, strings.TrimSpace(string(boot)))
++ if state == "masked-runtime" && recorded {
++ return releaseClaimEdgeMask(ctx, owned)
+ }
+- return nil
++ return discardClaimEdgeMaskRecord(owned, recorded)
+ }
+```
+
+```bash
+python3 "$CLAIM11_SCRATCH/apply.py" "$CLAIM11_PLAN" D4M implementation
+nice -n 10 go test -p 1 -timeout 5m ./internal/host -run 'TestPlan11Rev4.*(Mask|Replacement|Identity)|TestPlan9Fix[24]' -count=1
+```
+
+Expected: all pass. Ambiguous masks remain, while the exact owned object and its private anchor are removed on successful cleanup.
+
+## Region HA integration and rollout
+
+Keep all flags off while landing the code. Registration alone does not activate relocation. The rollout still needs Plan 9's verified identity, complete owners, activation census and floor guard. Region HA additionally needs `CARLOS_REGION_HA`; this implementation shares Group D with `CARLOS_MOVES_PHASE1`, so it requires that group to be complete too. Missing owners, an older root protocol, unsupported role shapes and incomplete stop evidence all refuse.
+
+Rev 4's root containment checks are an activation prerequisite. The `isolated-unit-v3` capability identifies the existing launch protocol; it does not prove that an older root contains the 9-CG fix. Verify the root executable in the signed activation census and keep claims disabled until all participating roots enforce this amendment. Migration recovery preserves legacy ambiguous masks, so an operator may need to inspect one during the offline upgrade.
+
+The claim-enabled protocol in Task D carries a main role in main's isolated run unit, using the nested instance layout and a supported memory tier. Seed, sidecar and other layouts remain refused where the reservation cannot describe them. The existing flag-off `/start`, `/seed`, process backend and unit templates retain their existing path. Staged builds must also be root-owned and not writable by the agent or tenant. Verify the deployed stager satisfies that rule before activation. Do not remove those refusals to make a probe pass. Extend the reservation and root verification first if another shape is required.
+
+Before region HA stage B is armed, install and test root-owned authoritative attempt readers for each enabled operation kind, including the prepared executable/input digest and the exact operation/session/install identity. Until then, the new per-kind preflight refuses those launches before admission or attempt reservation. Keep the existing region HA adapter signatures. Also prove the root-owned input snapshot bind, same-invocation tenant-child wait, trampoline reservation/deadline check and flag-off snapshot teardown against the deployed unit template in the disposable environment.
+
+Rebase the stage B adapters onto these interfaces and test these joins:
+
+1. Replace its duplicate `AutomaticLoss`, `LocalDischarge`, loss-ID helper and `Claim.EvidenceRefs` with Task A's canonical definitions. Preserve its fence-consuming transitions, D4 retention and strict decode tests.
+2. Register the complete relocation owner set once. Route the probe through `OperationAttempts`; do not apply the old `Extend` hook or introduce a separate probe lifecycle. Keep the same admitted session through `relocating/probing -> relocating/serving`. The built region branch currently commits directly to kind `serving`; that kind change ends this session and requires a separate serving wake. It must not be treated as an implicit continuation.
+3. Reconcile its prepared-generation and `released` coverage admission with the base's generic admission. This plan permits only the accepted cold probe shape. It does not import region HA's transition producer or grant a new coverage authority. An adapter cannot bypass the gate with an optional policy callback.
+4. For a drain, call `ClaimAssembly.WritersStopped`, register the files publisher under `ClaimProducerFiles`, retain replication and file publication, and use root's observation. Drop the region branch's duplicate descriptor observer and consume the claim-owned `(TID, Device, Inode)` descriptor shape. Confirm with the live Litestream witness that descriptors opened lazily after `Replicate` do not leave the narrowing hint incomplete. An incomplete hint refuses the observation. After release publication, queue the stop-only obligation and request the full root stop. Pass its immutable key and digest to `RelocationReleaseReceipts.Complete`, then perform the region-owned executor transition. Never use a writers-only witness as a full stop receipt.
+5. A release without its exact root receipt remains pending, including if the source terminates before producing it. Plan 11 grants no fence-only discharge for this new obligation. That receipt-only rule is the accepted contract; the region-owned termination transition must account for it before activation. It must not silently drop the obligation or route it through generic chore completion.
+6. Preserve the source's files live set until the authoritative record commits. Moves Plan 5 must replace the stand-in view and prove the push, landing-install and rollback-return park exclusions at each destructive step. The model's passing J1 tests do not implement those production exclusions.
+7. Keep the R6 no-LTX refusal for every positive writer session. Keep volume-based automatic discharge refused for legacy roster entries with unknown enrollment time. Publish no invented timestamp or empty generation.
+
+The release floor must include claim-level `evidence_refs` and its nested `AutomaticLoss` fields, roster `enrolled_at`, the `relocation-release` kind, and the launch journal and protocol additions, including `writers_closed`, `close-writers`, the `spec` preimage, capability-only `kind`, and `isolated-unit-v3`, the mandatory trampoline permit, and its internal root/unit handshake. Publish it only after every enrolled actor and console can strictly decode these fields, the signed census proves the same executable set, and the existing guard transaction has armed the new floor. Do not substitute the branch's commit hash for a release tag or floor. Old binaries must not participate after publication. Rollback must respect the published floor.
+
+Run the region HA two-box model and its joint review after the stage B adapters are rewritten. Then exercise isolated launch, cancellation, root crash recovery, writers-only stop and full stop in a designated disposable systemd/cgroup environment. This authoring session used local mocks and kernel fixtures; it did not create or modify production infrastructure.
+
+## Final verification
+
+After all tasks, run the following in the disposable export. Run commands serially; no background test jobs.
+
+```bash
+nice -n 10 go test -p 1 -timeout 5m ./internal/hostactors ./internal/hostclaim ./internal/hostbox ./internal/hostguard ./internal/stopevidence ./internal/instancectl ./internal/instanceexec -count=1
+nice -n 10 go test -p 1 -timeout 5m ./internal/host ./internal/activator -run 'TestRev[34]|TestPlan11|TestRev2Shape|TestStopWriters|TestWriters|TestVerifyWriters|TestVerifyNoProcess|TestNoProcess|TestRootRole|TestRootClaimProcess|TestClaimLaunch|TestClaimUnitStop|TestClaimStop|TestClaimProducer|TestPlan9D|TestClaimIsolation|TestEnsureClaimAdminTokenInspects|TestPlan9Fix[24]|TestClaimKernelPositiveReboot' -count=1
+nice -n 10 go vet ./internal/hostactors ./internal/hostclaim ./internal/hostbox ./internal/hostguard ./internal/stopevidence ./internal/instancectl ./internal/instanceexec ./internal/host ./internal/activator
+nice -n 10 go build ./internal/host ./internal/activator ./internal/instancectl ./internal/instanceexec
+GOOS=darwin GOARCH=arm64 nice -n 10 go build ./internal/host ./internal/activator ./internal/instancectl ./internal/instanceexec
+GOOS=windows GOARCH=amd64 nice -n 10 go build ./internal/host ./internal/activator ./internal/instancectl ./internal/instanceexec
+GOOS=linux GOARCH=arm64 nice -n 10 go build ./internal/host ./internal/activator ./internal/instancectl ./internal/instanceexec
+```
+
+Check `gofmt -l` on every changed Go file. Before a later implementation commit lands, run the repository's full build, vet, test and formatting gate and obtain green amadan CI on that tip. This plan's focused overlay verification does not replace that landing gate.
+
+### Rev 1 authoring results
+
+The plan was replayed from a fresh export of the pinned base. All fourteen test and implementation patch blocks applied in order, and the resulting changed Go files matched the tested combined overlay byte for byte. No git index or ref was changed.
+
+The supporting packages `hostactors`, `hostclaim`, `hostbox`, `hostguard`, `stopevidence` and `instancectl` passed their full package tests. The focused host and activator set passed, including Plan 9 dispatch, writers-only stop, descriptor and no-process observations, root launch, receipt validation and disabled-flag regressions. Vet passed for all eight packages. Builds of host, activator and instancectl passed on Linux and cross-compiled for darwin/arm64 and windows/amd64. Formatting was checked on all changed files.
+
+Red runs first exposed the missing record, enrollment, dispatch, writer-stop, observation and launch interfaces. Integration regressions then demonstrated mutable policy authority, cached witness aliasing, an omitted isolated cgroup, hidden root observation interfaces, a stale console enrollment time, refusal of a valid session-zero release, recovery settling an unobserved unit, and a probe launch before the executor became the admitted writer. Each corresponding guard was fixed and its focused tests passed. The final plan places every test block before its implementation.
+
+Some early delegated socket runs failed because of sandbox restrictions or long Unix socket paths; the parent reran the focused combined set under its short disk-backed TMPDIR and it passed. The long `TestClaimCompletedProducersBoundStopAndRetry` test exceeded an early delegated timeout; it is outside the final focused set. No production systemd unit, deployment, bucket, roster or floor was changed. Real unit isolation and the joint two-box stage B activation remain rollout checks, not claimed authoring results.
+
+### Rev 2 authoring results
+
+The four R10 behavioral regressions were run against the completed Rev 1 overlay first. They failed with the expected behavioral assertions, including the premature kill of a slow valid start. The amendment's successful root fixtures now contain authoritative attempts. Its additional tests cover both serving and operation tuples, missing verifiers, input and executable substitution, pending/lost/expired participation, loss of the live lock, state changes during unit preparation, namespace/exec delay, timeout, cancellation, PID reuse and invocation replacement, and snapshot binding/cleanup.
+
+All seventeen patch blocks replayed from the pinned base with zero fuzz. The resulting Go files matched the tested combined overlay byte for byte, and formatting was clean. The full supporting-package tests passed for `hostactors`, `hostclaim`, `hostbox`, `hostguard`, `stopevidence` and `instancectl`; the expanded focused host/activator set passed, including the amendment, launch, retry/drain and Plan 9 dispatch regressions. The focused amendment/launch set also passed from the fresh replay. Vet passed for all eight packages. Host, activator and instancectl built on Linux and cross-compiled for darwin/arm64 and windows/amd64.
+
+The default Go cache was read-only in the sandbox, so verification used the explicitly permitted shared cache `/home/paulca/.cache/tmp/claude-1001/gocache`. It was not deleted. An initial broad host run exceeded the Unix socket path limit; the bootstrap now uses a shorter disk-backed temporary directory, and the same tests passed there. These setup errors were not counted as regression evidence. No git index, ref or worktree registration was written.
+
+Opus reviewed the root authority and snapshot implementation and found no P1 or P2. Its data-root observation is handled by refusing production roots other than the template's `/data`, with a construction regression. The parent checked the final callback ordering, per-kind preflight refusal and replacement-invocation cleanup. Sonnet supplied the preflight shape changes; the parent integrated and verified them. No live systemd mount or production experiment was run. The real namespace/snapshot and region HA stage B checks remain activation gates.
+
+### Rev 3 authoring results
+
+The D3R regressions compiled against the completed Rev 2 export and failed on all four round 2 defects. Setup-time expiry returned a grant and counted one tenant execution; the actual `runInit` parent was mistaken for the tenant; deleted DB/WAL, nested, unlinked and empty-directory cases returned false no-process success; and the reconstructed flag-off controller started with claim artifacts still installed. The transition test now writes different argv/environment and verifies that legacy restart consumes those new values after stopped-unit cleanup.
+
+All twenty patch blocks replayed from a fresh export of `ef3c4bff7d612c5bb4584713fef40bc0cb5a43ca` with zero fuzz. Every resulting Go file matched the tested overlay byte for byte; changed-file formatting was clean. The new regressions, Rev 2 authority checks, launch and no-process tests passed in that fresh replay. The combined focused host/activator gate passed. Full supporting-package tests passed for `hostactors`, `hostclaim`, `hostbox`, `hostguard`, `stopevidence`, `instancectl` and `instanceexec`; the last two were rerun after the boundary integration fixes. Vet passed for these packages plus host/activator. Host, activator, instancectl and instanceexec built on Linux and cross-compiled for darwin/arm64 and windows/amd64. A delegated full-host run was interrupted without a verdict; no full-repository gate is claimed.
+
+OpenAI subagents supplied bounded witness, legacy-cleanup and child-observation work. A bounded OpenAI review of the new boundary found the template's hidden boot-ID path, escaped `%i` argument, and early socket unlink before `ExecStopPost`. The amendment binds the boot ID explicitly, uses `%I`, retains the closed socket until stopped-unit proof, and tests the generated bindings and socket lifetime. No Anthropic-model subagent or review was used in this run; the Rev 1/2 review records above are historical.
The default Go cache returned a read-only-filesystem error. Verification used `/home/paulca/.cache/tmp/claude-1001/gocache`, which was retained. This revision changes only this plan in the repository; no git index, refs, worktree registration or production resources were written. Tests used local sockets, kernel fixtures and the actual parent/child process arrangement; they did not create a systemd namespace or test live bind mounts. The disposable systemd rollout check and region HA stage B activation gates remain required.
+### Rev 4 authoring results and self-review
+
+All 33 patch blocks replayed in their documented order from a fresh export of main commit `da76647be42e2b330f3ba425333f7a38a6ddcc52`, with zero fuzz. Every resulting changed file matched the tested overlay byte for byte. Formatting was clean across the 127 changed Go files. No git index, ref, worktree registration or other repository file was written.
+
+The behavioral red runs reproduced every round 3 finding. Real open-but-unlinked WAL and nested-file descriptors escaped the old writers-stopped scan; an empty listing suppressed scanning, and a deleted replicator descriptor disappeared from the retained result. Sidecar `ELOOP` and `EACCES` became false absence. Root accepted unrelated-service and foreign-host journal groups, a parentless reservation with an otherwise valid stored parent, and forged persisted recovery targets. A same-boot intent caused mask recovery to unmask the operator's later object. Additional tests cover injected `EIO`, exact descriptor identity, valid containment acceptance, forged kernel identities, whole-inventory recovery refusal before any unit action, foreign stop requests with no journal mutation, and normal mask cleanup and retry.
+
+The final combined host/activator set passed from the fresh replay, including the new regressions, earlier launch-boundary fixes, retry/drain, reboot recovery and migration tests. Full package tests passed for `hostactors`, `hostclaim`, `hostbox`, `hostguard`, `stopevidence`, `instancectl` and `instanceexec`; the full activator package also passed. Vet passed for those packages plus host/activator. Host, activator, instancectl and instanceexec built natively and cross-compiled for darwin/arm64, windows/amd64 and linux/arm64. No full-repository test gate is claimed.
+
+A broader integration run caught an old replacement-controller fixture with a hard-coded parent; it now derives the parent from the replacement tuple. Two socket tests exceeded the Unix pathname limit with the first scratch path; the final combined run used the bootstrap's shorter disk-backed temporary directory and passed. Those setup failures were not counted as regression evidence. The default cache returned a read-only-filesystem error, so verification used the permitted shared cache `/home/paulca/.cache/tmp/claude-1001/gocache`, which was retained. The disposable exports, logs and both socket-test directories created for this revision were removed after verification.
+
+OpenAI subagents supplied bounded containment, sidecar and mask changes. A separate OpenAI review found two additional containment paths: launch/stop could act before validating the full persisted inventory, and stop could persist a foreign unit path. Both are fixed with zero-action and no-mutation regressions; the follow-up review found no remaining actionable issue. The descriptor, sidecar and mask reviews also found no actionable issue. No Anthropic-model subagent was used. The self-review confirmed that the ordinary flag-off `HasSidecar` and launch paths retain their previous behavior, while interrupted claim cleanup and offline migration retain their explicit recovery rules.
+
+Tests used temporary kernel fixtures, local sockets and real local file descriptors. They did not alter production, invoke a real service mask, create a systemd namespace or validate a deployed cgroup hierarchy. Real isolation, offline migration and the region HA stage B joint witness remain rollout checks. The mask protocol's final name-based unmask assumes no concurrent root replacement of that pathname, as stated in D4M.
+
## Rev 1 self-review
The record/dispatch review found no authority bypass in signed receipt validation or refusal-only policy. Its console re-enrollment finding is fixed by assigning a new root time to the new enrollment. Its proposed fence-only discharge conflicts with the accepted receipt-only obligation contract, so this plan preserves the refusal and names the resulting availability dependency in rollout. Generic obligation validation already rejects empty operation and retirement identities.