carlos / platform Public

plan: claim Plan 9 rev 1, activation readiness (amadan #250: arming blockers, activation blockers, rollout order)

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 3a5ae7aad15e203bc786b27c0a304f08ed9e8288 parent 108ca16
1 file changed, +4692 −0
  • docs/superpowers/plans/2026-10-08-host-lifecycle-claim-9-activation-readiness.md +4692 −0

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-08-host-lifecycle-claim-9-activation-readiness.md b/docs/superpowers/plans/2026-10-08-host-lifecycle-claim-9-activation-readiness.md
new file mode 100644
index 000000000..1d5e68aa3
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-08-host-lifecycle-claim-9-activation-readiness.md
@@ -0,0 +1,4692 @@
+# Host lifecycle claim, Plan 9: activation readiness
+
+Implementation plan, 2026-10-08, rev 1. Base: `origin/main` at **76f728a910ff9c78856620801e712650b962f2cd**. The remote main ref was checked without fetching or writing git state. Spec: `docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md`, rev **6.30**. Scope: amadan discussion **#250**, including its Plan 5, Plan 6b and integration additions. Plans 1 through 8b, 6b, and Plan 5 Tasks 1 through 9 are already on this base; do not replay them.
+
+This is a plan-only deliverable. Authoring changes only this file in the design worktree. Every implementation block is a complete new file or exact patch against the pinned main export. Prototypes and focused tests run in disposable overlays outside the repo; no branch, index, commit, production resource, floor, flag or service is changed.
+
+Apply Group A before anyone runs `carlos system guard-arm`. Apply Group B and resolve its marked decisions before enabling `CARLOS_HOST_CLAIM` anywhere. Group C contains rollout checks, approvals and witnesses, with no additional implementation. Keep the flag unset while implementing and reviewing these tasks.
+
+A decision-gated task is not permission to invent an authority adapter. Its complete acceptance tests are included where the current APIs permit them. Do not call the activation gate complete until Paul rules on the unresolved contract, its complete implementation is added to this plan, and the red tests and privileged witnesses pass. Compiling the decided fixes does not mean every activation blocker is closed.
+
+Flag-off preservation means preserving main's ordinary legacy branches, order of effects, unit bytes, and public responses unless this brief explicitly requests their correction. Floor enforcement is already independent of the claim flag: Group A necessarily changes armed, pre-flag behavior. M-3 deliberately refuses a previously accepted colliding report, and M-6 deliberately changes a lost rollback CAS from 500 to 409. Treat these named corrections as explicit exceptions, not a license to rewrite unrelated flag-off behavior. These explicit requested fixes do not authorize unrelated flag-off changes.
+
+The task groups retain the discussion's order. Every task states the authority or compatibility invariant it restores and its focused check. Historical findings I-3 and I-5 are already fixed on main. M-4's unavailable evidence producers and M-7's retained staging are addressed as rollout dependencies and recovery retention rules, not silently removed from scope. First-review I2 and M5 are checked against the actual main sources, not inferred from a prior plan.
+
+## Applying and verifying the blocks
+
+Use a clean, later implementation checkout based on the pinned main or a reviewed descendant. If main moved, refresh the exact patches and repeat this document's replay checks; do not force a failed hunk. This authoring session performs no git writes. Implementation must later use the repository's reviewed amadan branch workflow, all required gates, and authority review before landing.
+
+Save the following as `$TMPDIR/claim9-apply.py` in the later implementation checkout. It applies one task and phase, without staging or committing. `B4` and `B7` contain only decision-gated tests; do not apply them to a landing branch before Paul's ruling and the follow-on implementation. Apply test blocks first, run the task's focused command and record the expected failure, then apply implementation blocks and repeat. Patches use the exact pinned base or the preceding task's result; a failed patch means refresh the plan, not accept fuzz.
+
+```python
+import pathlib
+import re
+import subprocess
+import sys
+
+plan, wanted, phase = sys.argv[1:]
+if phase not in {"tests", "implementation"}:
+ raise SystemExit("phase must be tests or implementation")
+body = pathlib.Path(plan).read_text()
+sections = re.split(r"^### Task ([A-Z][0-9]+):[^\n]*\n", body, flags=re.M)
+found = False
+for task, section in zip(sections[1::2], sections[2::2]):
+ if task != wanted:
+ continue
+ found = True
+ blocks = re.findall(
+ r"^#### (New file|Patch): `([^`]+)`\n\n```(?:go|diff|hcl)\n(.*?)^```$",
+ section, re.M | re.S)
+ for kind, name, content in blocks:
+ path = pathlib.Path(name)
+ if path.is_absolute() or ".." in path.parts:
+ raise SystemExit("invalid path: " + name)
+ if (name.endswith("_test.go") or name.endswith(".tftest.hcl")) != (phase == "tests"):
+ continue
+ content = content.replace(r"\u2014", chr(0x2014))
+ if kind == "New file":
+ if path.exists():
+ raise SystemExit("already exists: " + name)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content)
+ else:
+ subprocess.run(
+ ["patch", "--batch", "--forward", "--fuzz=0", "-p1"],
+ input=content, text=True, check=True)
+if not found:
+ raise SystemExit("no such task: " + wanted)
+```
+
+For example, after saving the extractor:
+
+```bash
+python3 "$TMPDIR/claim9-apply.py" docs/superpowers/plans/2026-10-08-host-lifecycle-claim-9-activation-readiness.md A1 tests
+```
+
+Use the default Go cache. This author's sandbox reported it read-only, so the executed checks used the brief's allowed fallback `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache`. Run all focused tests serially under `nice -n 10` and `go test -p 1`; delegates must share the same `flock` if they run checks concurrently. Do not create a per-task Go cache. All authoring scratch and overlays are removed at hand-back.
+
+
+## Group A: arming blockers
+
+### Task A1: make arming and guarded recovery usable without weakening the floor
+
+Invariant: arming is monotone, uses real controller state, and never labels an unchanged old image as a completed target activation. An armed actor may boot before publication; missing or conflicting published authority is never invented.
+
+This step covers I-1, I-2, M1-2, M1-3 and M1-4. Add the complete tests below first. The main failures are: an armed box refuses an absent remote pair; `.hostexec` is absent after arming; leftover marker variants are accepted; recover waits for the apply lock; and the old-image no-journal path records completion. New helper names initially produce compile failures; the delegate also exercised these behavioral failures with temporary behavior-preserving seams, then removed those seams.
+
+Apply the implementation blocks after recording red. `CheckRemotePolicy` permits *both* remote objects absent only in `armed` mode; the local verified policy still constrains execution. Half-written, malformed and different-digest pairs refuse. `enforced` remains strict. The existing offline fallback is unchanged.
+
+Keep the edge's `Requires=` and `After=` ordering. Only `guard-exec recover`, after loading its local policy, steps aside when a nonblocking flock proves a different apply holds the existing root-owned mode-0600 lock. Permission errors, malformed lock paths and other errors refuse. Unguarded recovery is unchanged. This avoids the 30-second dependency wait during apply's own edge restart without granting the edge write access to root's lock.
+
+Before writing armed state, create and fsync `<DataDir>/.hostexec` as `carlos:carlos`, mode 2770, through no-follow directory descriptors. Keep existing records. Refuse any open update journal or `update-begun.json*` entry, including symlinks, temporary files and copies. Do not delete evidence to arm.
+
+A signed activation's middle authorized hash identifies its target. With no journal, root records completion only for that verified on-disk target or its durable rollback marker. A never-applied transaction removes only its own local intent and records no completion; its console reservation remains held. The agent can retry that exact published reservation. An operator can cancel it only if it was never published, through A2; a different re-promote is not a bypass. Backward recovery durably records rollback before removing the journal. Foreign markers refuse. Console release still requires a fresh authenticated running-image report, so the local disk check is not itself evidence of the currently executing process.
+
+Run after tests and again after implementation:
+
+```bash
+nice -n 10 go test -p 1 ./internal/hostfloor ./internal/host -run 'TestPlan9A(Remote|Controller|Marker|Unchanged|Forward|Backward|RolledBack|Rollback|Guard)' -count=1
+nice -n 10 go test -p 1 ./internal/hostfloor ./internal/host ./internal/hostexec -run 'TestGuard|TestSystemGuard|TestSystemFloor|TestFloorRefusal|TestUnpublished|TestRecover|TestRollback|TestApply|TestBootRecovery|TestForwardRecovery|TestBackwardRecovery|TestARecovered|TestPrevBackup|TestAStale|TestTheMarker|TestClaimStaging|TestClaimControl|TestClaimBad|TestClaimReview|TestFloor|TestPending|TestParticipation|TestConsoleGuard|TestCanonical' -count=1
+```
+
+Remaining recovery dependency: an open journal plus a durable pending controller still refuses recovery. Since that controller normally lives in the edge, this can keep the edge down after a crash. Do not exempt pending records or delete them by hand. Paul must choose a root recovery operation that proves the exact controller dead and settles it, or a specified pending-state exemption with its proof. This is included in C3's crash witnesses and blocks activation until resolved.
+
+
+Tests first.
+
+#### New file: `internal/hostfloor/plan9_a_test.go`
+
+```go
+package hostfloor
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type plan9aOfflineStore struct{ release.Store }
+
+func (plan9aOfflineStore) Get(context.Context, string) ([]byte, string, error) {
+ return nil, "", context.DeadlineExceeded
+}
+
+// I-1: an armed box before publication finds neither remote object. Its
+// local policy already enforces the floor, so guard-exec accepts it. A half
+// pair, a malformed object or a different digest still refuses, and only
+// "armed" gets the absent-pair reading.
+func TestPlan9ARemotePolicyArmedBeforePublication(t *testing.T) {
+ pub, key, _ := ed25519.GenerateKey(rand.Reader)
+ sg := release.RootSigner(key)
+ ctx := context.Background()
+ floor := Floor{1, "v1.2.3"}
+ raw, _ := json.Marshal(floor)
+ auth, err := release.SignRootEnvelope(sg, Authorization{1, "moves-protocol", "bucket", Key, Digest(raw)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ local, err := Verify(pub, "bucket", raw, auth)
+ if err != nil {
+ t.Fatal(err)
+ }
+ armed := Policy{1, "bucket", "armed", raw, auth}
+ enforced := Policy{1, "bucket", "enforced", raw, auth}
+
+ empty := release.NewDirStore(t.TempDir())
+ if err := CheckRemotePolicy(ctx, empty, pub, "bucket", armed, local); err != nil {
+ t.Fatalf("armed box refused before publication: %v", err)
+ }
+ if err := CheckRemotePolicy(ctx, empty, pub, "bucket", enforced, local); err == nil {
+ t.Fatal("enforced box accepted a missing remote floor")
+ }
+
+ authOnly := release.NewDirStore(t.TempDir())
+ if _, err := authOnly.Put(ctx, AuthKey, auth, ""); err != nil {
+ t.Fatal(err)
+ }
+ floorOnly := release.NewDirStore(t.TempDir())
+ if _, err := floorOnly.Put(ctx, Key, raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ for name, s := range map[string]release.Store{"auth-only": authOnly, "floor-only": floorOnly} {
+ if err := CheckRemotePolicy(ctx, s, pub, "bucket", armed, local); err == nil {
+ t.Fatalf("%s: half-written pair accepted", name)
+ }
+ }
+
+ malformed := release.NewDirStore(t.TempDir())
+ if _, err := malformed.Put(ctx, AuthKey, auth, ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := malformed.Put(ctx, Key, []byte("{"), ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := CheckRemotePolicy(ctx, malformed, pub, "bucket", armed, local); err == nil {
+ t.Fatal("malformed remote floor accepted")
+ }
+
+ other := release.NewDirStore(t.TempDir())
+ if err := Publish(ctx, other, sg, "bucket", Floor{2, "v1.2.4"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := CheckRemotePolicy(ctx, other, pub, "bucket", armed, local); !errors.Is(err, ErrFloor) {
+ t.Fatalf("different remote digest = %v", err)
+ }
+
+ same := release.NewDirStore(t.TempDir())
+ if err := Publish(ctx, same, sg, "bucket", floor); err != nil {
+ t.Fatal(err)
+ }
+ for _, p := range []Policy{armed, enforced} {
+ if err := CheckRemotePolicy(ctx, same, pub, "bucket", p, local); err != nil {
+ t.Fatalf("%s: matching floor refused: %v", p.Mode, err)
+ }
+ }
+ if err := CheckRemotePolicy(ctx, plan9aOfflineStore{}, pub, "bucket", enforced, local); err != nil {
+ t.Fatalf("offline store refused (main accepts it): %v", err)
+ }
+}
+
+// M1-2: root creates the controller directory the armed path requires,
+// owned by the controllers' account, without following a planted link.
+func TestPlan9AControllerDirCreatedWithoutFollowingLinks(t *testing.T) {
+ uid, gid := os.Getuid(), os.Getgid()
+ check := func(t *testing.T, dir string) {
+ t.Helper()
+ fi, err := os.Lstat(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ st := fi.Sys().(*syscall.Stat_t)
+ if !fi.IsDir() || fi.Mode().Perm() != 0o770 || fi.Mode()&os.ModeSetgid == 0 || int(st.Uid) != uid || int(st.Gid) != gid {
+ t.Fatalf("controller dir mode=%v uid=%d gid=%d", fi.Mode(), st.Uid, st.Gid)
+ }
+ }
+ t.Run("absent", func(t *testing.T) {
+ data := t.TempDir()
+ if err := CheckControllersIdle(data); err == nil {
+ t.Fatal("precondition: armed path accepted a missing directory")
+ }
+ if err := EnsureControllerDir(data, uid, gid); err != nil {
+ t.Fatal(err)
+ }
+ check(t, filepath.Join(data, ".hostexec"))
+ if err := CheckControllersIdle(data); err != nil {
+ t.Fatalf("armed path still refuses: %v", err)
+ }
+ if err := EnsureControllerDir(data, uid, gid); err != nil {
+ t.Fatalf("second run: %v", err)
+ }
+ })
+ t.Run("existing-loose", func(t *testing.T) {
+ data := t.TempDir()
+ dir := filepath.Join(data, ".hostexec")
+ if err := os.Mkdir(dir, 0o777); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(dir, 0o777); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "host.json"), []byte("{}"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := EnsureControllerDir(data, uid, gid); err != nil {
+ t.Fatal(err)
+ }
+ check(t, dir)
+ if _, err := os.Stat(filepath.Join(dir, "host.json")); err != nil {
+ t.Fatal("controller record discarded", err)
+ }
+ })
+ t.Run("symlink", func(t *testing.T) {
+ data, elsewhere := t.TempDir(), t.TempDir()
+ if err := os.Chmod(elsewhere, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(elsewhere, filepath.Join(data, ".hostexec")); err != nil {
+ t.Skip(err)
+ }
+ if err := EnsureControllerDir(data, uid, gid); err == nil {
+ t.Fatal("followed a planted symlink")
+ }
+ fi, err := os.Stat(elsewhere)
+ if err != nil || fi.Mode().Perm() != 0o755 || fi.Mode()&os.ModeSetgid != 0 {
+ t.Fatalf("link target changed: %v %v", fi.Mode(), err)
+ }
+ })
+ t.Run("file", func(t *testing.T) {
+ data := t.TempDir()
+ if err := os.WriteFile(filepath.Join(data, ".hostexec"), nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := EnsureControllerDir(data, uid, gid); err == nil {
+ t.Fatal("accepted a regular file")
+ }
+ })
+ t.Run("data-symlink", func(t *testing.T) {
+ parent := t.TempDir()
+ real := t.TempDir()
+ link := filepath.Join(parent, "data")
+ if err := os.Symlink(real, link); err != nil {
+ t.Skip(err)
+ }
+ if err := EnsureControllerDir(link, uid, gid); err == nil {
+ t.Fatal("followed a symlinked data directory")
+ }
+ if _, err := os.Lstat(filepath.Join(real, ".hostexec")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("created through the link: %v", err)
+ }
+ })
+ t.Run("relative", func(t *testing.T) {
+ if err := EnsureControllerDir("data", uid, gid); err == nil {
+ t.Fatal("accepted a relative data directory")
+ }
+ })
+}
+
+// M1-4: a rollback is recorded in root's own marker before the journal goes,
+// so completion can tell a finished rollback from an update that never
+// changed the box.
+func TestPlan9AMarkerRecordsRollback(t *testing.T) {
+ d := Disk{Dir: t.TempDir()}
+ data := t.TempDir()
+ if err := os.Mkdir(filepath.Join(data, ".hostexec"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := MarkRolledBack(d.Dir, "tx"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("absent marker = %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(d.Dir, "update-begun.json")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("marking created a marker", err)
+ }
+ if err := d.Begin("tx", data); err != nil {
+ t.Fatal(err)
+ }
+ if b, err := ReadBegun(d.Dir); err != nil || b.ID != "tx" || b.RolledBack {
+ t.Fatalf("fresh marker = %+v, %v", b, err)
+ }
+ before, _ := os.ReadFile(filepath.Join(d.Dir, "update-begun.json"))
+ if err := MarkRolledBack(d.Dir, "other"); !errors.Is(err, ErrFloor) {
+ t.Fatalf("foreign marker = %v", err)
+ }
+ if after, _ := os.ReadFile(filepath.Join(d.Dir, "update-begun.json")); string(after) != string(before) {
+ t.Fatal("foreign marking rewrote the marker")
+ }
+ for range 2 {
+ if err := MarkRolledBack(d.Dir, "tx"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ b, err := ReadBegun(d.Dir)
+ if err != nil || b.ID != "tx" || !b.RolledBack {
+ t.Fatalf("marked = %+v, %v", b, err)
+ }
+ raw, _ := os.ReadFile(filepath.Join(d.Dir, "update-begun.json"))
+ if !strings.Contains(string(raw), `"id":"tx"`) {
+ t.Fatalf("marker lost its transaction id: %s", raw)
+ }
+ if err := os.WriteFile(filepath.Join(d.Dir, "update-begun.json"), []byte(`{"id":"tx","rolled_back":"yes"}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ReadBegun(d.Dir); err == nil {
+ t.Fatal("malformed marker read")
+ }
+}
+```
+
+#### New file: `internal/host/plan9_a_guard_test.go`
+
+```go
+package host
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// plan9aNewBody is the target build's executable in the M1-4 fixture.
+var plan9aNewBody = []byte("#!/bin/sh\necho carlos new-build\n")
+
+// plan9aTargetFixture is guardRecoveryFixture with a reservation whose
+// target is not the image on disk: current and recovery are the old build,
+// the target is new-build, which has its own signed identity.
+func plan9aTargetFixture(t *testing.T) (*applyFixture, string) {
+ t.Helper()
+ f, id := guardRecoveryFixture(t)
+ a := f.a
+ old, err := os.ReadFile(systemBinaryPath(a.optDir))
+ if err != nil {
+ t.Fatal(err)
+ }
+ oldHash, newHash := hostfloor.Digest(old), hostfloor.Digest(plan9aNewBody)
+ sg := release.RootSigner(f.priv)
+ rel := release.SystemRelease{Version: "new-build", ReleaseTag: "v1.2.4", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{a.platform: {Key: release.SystemArtifactKey("new-build", a.platform), SHA256: newHash}}}
+ if _, err := release.PromoteSystem(t.Context(), f.store, sg, "router", "plan9a", rel, release.SystemPromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ identity, _, err := f.store.Get(t.Context(), release.SystemPointerKey("router", "plan9a"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := hostfloor.Durable(filepath.Join(a.floor.Dir, "identities", "v1.2.4.json"), identity, 0644); err != nil {
+ t.Fatal(err)
+ }
+ tx, err := a.guardTransaction()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var roster hostactors.Roster
+ if err := release.VerifyRootEnvelope(f.pub, tx.Roster, &roster); err != nil {
+ t.Fatal(err)
+ }
+ h := roster.Activations[id]
+ h.Hashes = map[string][]string{tx.Actor: {oldHash, newHash, oldHash}}
+ roster.Activations[id] = h
+ if err := roster.Validate("bucket"); err != nil {
+ t.Fatal(err)
+ }
+ if tx.Roster, err = release.SignRootEnvelope(sg, roster); err != nil {
+ t.Fatal(err)
+ }
+ raw, _ := json.Marshal(tx)
+ if err := hostfloor.Durable(filepath.Join(a.floor.Dir, "activation.json"), raw, 0644); err != nil {
+ t.Fatal(err)
+ }
+ return f, id
+}
+
+func plan9aAbsent(t *testing.T, paths ...string) {
+ t.Helper()
+ for _, p := range paths {
+ if _, err := os.Lstat(p); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("%s present: %v", p, err)
+ }
+ }
+}
+
+// M1-4: a transaction that never changed the box (refused or deferred
+// before the first destructive step, or abandoned at the rename) records no
+// completion. Root's marker and transaction go, so the box is not wedged,
+// and the console's reservation stays held for a retry or an operator.
+func TestPlan9AUnchangedTransactionRecordsNoCompletion(t *testing.T) {
+ for _, state := range []string{"prepared", "staging-failed", "late-participation", "abandoned-install"} {
+ t.Run(state, func(t *testing.T) {
+ f, id := plan9aTargetFixture(t)
+ a := f.a
+ marker := filepath.Join(a.floor.Dir, updateBegunFile)
+ record := filepath.Join(a.dataDir, hostexecDir, "pending.json")
+ if state != "prepared" {
+ if err := a.floor.Begin(id, a.dataDir); err != nil {
+ t.Fatal(err)
+ }
+ }
+ switch state {
+ case "late-participation":
+ // The late re-check before the first destructive step runs
+ // under CARLOS_REGION_HA (systemparticipation.go).
+ a.regionHA = true
+ if err := os.WriteFile(record, []byte(`{"state":"pending"}`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.beginExecutableChange("new-build", f.now); !errors.Is(err, errSystemDeferred) {
+ t.Fatalf("late participation = %v", err)
+ }
+ case "abandoned-install":
+ if err := a.journal(systemJournal{Phase: systemPhaseInstalling, Target: "new-build", PrevBuild: "old-build", StartedAt: f.now}); err != nil {
+ t.Fatal(err)
+ }
+ a.removeJournal()
+ }
+ before, err := os.ReadFile(systemBinaryPath(a.optDir))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for range 2 {
+ if err := a.Run(t.Context(), true); err != nil {
+ t.Fatalf("unchanged transaction wedged recovery: %v", err)
+ }
+ }
+ plan9aAbsent(t, filepath.Join(a.floor.Dir, "completed.json"), marker, filepath.Join(a.floor.Dir, "activation.json"))
+ if after, _ := os.ReadFile(systemBinaryPath(a.optDir)); string(after) != string(before) {
+ t.Fatal("binary changed")
+ }
+ if len(f.calls) != 0 {
+ t.Fatal("recovery executed something", f.calls)
+ }
+ if state == "late-participation" {
+ if _, err := os.Stat(record); err != nil {
+ t.Fatal("root removed a controller record", err)
+ }
+ if err := os.Remove(record); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // The retry begins the same reservation again.
+ if err := a.floor.Begin(id, a.dataDir); err != nil {
+ t.Fatalf("retry blocked: %v", err)
+ }
+ })
+ }
+}
+
+// M1-4: the target on disk is a finished transaction.
+func TestPlan9AForwardFinishRecordsCompletion(t *testing.T) {
+ f, id := plan9aTargetFixture(t)
+ a := f.a
+ if err := a.floor.Begin(id, a.dataDir); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.journal(systemJournal{Phase: systemPhaseGated, Target: "new-build", PrevBuild: "old-build", StartedAt: f.now}); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(systemBinaryPath(a.optDir), plan9aNewBody, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.closeGuardJournal(); err != nil {
+ t.Fatal(err)
+ }
+ if raw, err := os.ReadFile(filepath.Join(a.floor.Dir, "completed.json")); err != nil || string(raw) != id {
+ t.Fatalf("completion = %q, %v", raw, err)
+ }
+ plan9aAbsent(t, filepath.Join(a.floor.Dir, updateBegunFile))
+}
+
+// M1-4: a backward recovery restores the old image and still completes the
+// transaction, because it rolled back. This pins the call-site wiring.
+func TestPlan9ABackwardRecoveryRecordsCompletion(t *testing.T) {
+ f, id := plan9aTargetFixture(t)
+ a := f.a
+ old, err := os.ReadFile(systemBinaryPath(a.optDir))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := a.floor.Begin(id, a.dataDir); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(systemPrevPath(a.optDir), old, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(systemBinaryPath(a.optDir), plan9aNewBody, 0755); err != nil {
+ t.Fatal(err)
+ }
+ // The install staged carlos.prev's plan; recovery reuses it rather than
+ // exec'ing a binary (floor.Run needs root-owned paths).
+ prevDir := filepath.Join(a.stagingDir(), "prev")
+ units := filepath.Join(prevDir, "etc", "systemd", "system")
+ if err := os.MkdirAll(units, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(units, "carlos-edge.service"), []byte("[Unit]\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(prevDir, "etc", "tmpfiles.d"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(prevDir, "etc", "tmpfiles.d", "carlos.conf"), []byte("d /run/carlos\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeJSONDurable(filepath.Join(prevDir, "plan.json"), bootstrapPlan{Enabled: []string{"carlos-edge.service"}}, 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.journal(systemJournal{Phase: systemPhaseInstalled, Target: "new-build", PrevBuild: "old-build", StartedAt: f.now}); err != nil {
+ t.Fatal(err)
+ }
+ f.edgeStartedThisBoot = false
+ if err := a.Run(t.Context(), true); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := os.ReadFile(systemBinaryPath(a.optDir)); string(got) != string(old) {
+ t.Fatal("old image not restored")
+ }
+ if r := f.result(); r == nil || r.Outcome != systemOutcomeRolledBack {
+ t.Fatalf("result = %+v", r)
+ }
+ if raw, err := os.ReadFile(filepath.Join(a.floor.Dir, "completed.json")); err != nil || string(raw) != id {
+ t.Fatalf("rolled-back transaction not completed: %q, %v", raw, err)
+ }
+ plan9aAbsent(t, filepath.Join(a.floor.Dir, updateBegunFile), a.file("journal.json"))
+}
+
+// M1-4: a crash after the rollback was recorded and the journal removed,
+// before completion, still completes on the old image.
+func TestPlan9ARolledBackMarkerCompletesAfterCrash(t *testing.T) {
+ f, id := plan9aTargetFixture(t)
+ a := f.a
+ if err := a.floor.Begin(id, a.dataDir); err != nil {
+ t.Fatal(err)
+ }
+ if err := hostfloor.MarkRolledBack(a.floor.Dir, id); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Run(t.Context(), true); err != nil {
+ t.Fatal(err)
+ }
+ if raw, err := os.ReadFile(filepath.Join(a.floor.Dir, "completed.json")); err != nil || string(raw) != id {
+ t.Fatalf("completion = %q, %v", raw, err)
+ }
+}
+
+// M1-4: closing a rollback under someone else's marker refuses and keeps
+// the journal; it never completes the local transaction instead.
+func TestPlan9ARollbackCloseRefusesForeignMarker(t *testing.T) {
+ f, _ := plan9aTargetFixture(t)
+ a := f.a
+ marker := filepath.Join(a.floor.Dir, updateBegunFile)
+ if err := os.WriteFile(marker, []byte(`{"id":"other"}`), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.journal(systemJournal{Phase: systemPhaseUnits, Target: "new-build", PrevBuild: "old-build", StartedAt: f.now}); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.closeGuardRollback(); err == nil {
+ t.Fatal("foreign marker accepted")
+ }
+ if _, err := os.Stat(a.file("journal.json")); err != nil {
+ t.Fatal("journal discarded", err)
+ }
+ plan9aAbsent(t, filepath.Join(a.floor.Dir, "completed.json"))
+}
+
+// M1-3: guard-arm refuses while root's update state is anywhere in the
+// guard directory, including main's region marker and its temporary file.
+func TestPlan9AGuardArmRefusesUpdateState(t *testing.T) {
+ for _, name := range []string{updateBegunFile, updateBegunFile + ".tmp", updateBegunFile + ".old"} {
+ t.Run(name, func(t *testing.T) {
+ guard, root := t.TempDir(), t.TempDir()
+ if err := os.WriteFile(filepath.Join(guard, name), []byte(`{"target":"v2"}`), 0644); err != nil {
+ t.Fatal(err)
+ }
+ err := guardArmIdle(guard, root)
+ if !errors.Is(err, hostfloor.ErrFloor) || !strings.Contains(err.Error(), name) {
+ t.Fatalf("guard-arm with %s = %v", name, err)
+ }
+ })
+ }
+ guard, root := t.TempDir(), t.TempDir()
+ for _, name := range []string{"policy.json", "carlos", ".guard-123", "completed.json"} {
+ if err := os.WriteFile(filepath.Join(guard, name), nil, 0644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := guardArmIdle(guard, root); err != nil {
+ t.Fatalf("settled guard directory refused: %v", err)
+ }
+ if err := guardArmIdle(filepath.Join(guard, "absent"), root); err != nil {
+ t.Fatalf("absent guard directory refused: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "journal.json"), []byte("{}"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := guardArmIdle(guard, root); !errors.Is(err, hostfloor.ErrFloor) {
+ t.Fatalf("open journal = %v", err)
+ }
+}
+
+// M1-2: guard-arm creates the controller directory, owned by the service
+// account, so the armed update path can prove no controller is active.
+func TestPlan9AGuardArmCreatesControllerDir(t *testing.T) {
+ f, id := guardRecoveryFixture(t)
+ a := f.a
+ if err := os.RemoveAll(filepath.Join(a.dataDir, hostexecDir)); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.floor.Begin(id, a.dataDir); err == nil {
+ t.Fatal("precondition: armed update began without a controller directory")
+ }
+ me, err := user.Current()
+ if err != nil {
+ t.Skip(err)
+ }
+ original := lookupServiceUser
+ t.Cleanup(func() { lookupServiceUser = original })
+ var asked []string
+ lookupServiceUser = func(name string) (*user.User, error) { asked = append(asked, name); return me, nil }
+ if err := guardArmControllers(a.dataDir); err != nil {
+ t.Fatal(err)
+ }
+ if len(asked) != 1 || asked[0] != serviceUser {
+ t.Fatalf("looked up %v, want %s", asked, serviceUser)
+ }
+ fi, err := os.Lstat(filepath.Join(a.dataDir, hostexecDir))
+ if err != nil || !fi.IsDir() || fi.Mode().Perm() != 0o770 {
+ t.Fatalf("controller dir = %v, %v", fi, err)
+ }
+ if err := a.floor.Begin(id, a.dataDir); err != nil {
+ t.Fatalf("armed update still refused: %v", err)
+ }
+ lookupServiceUser = func(string) (*user.User, error) { return nil, user.UnknownUserError(serviceUser) }
+ if err := guardArmControllers(t.TempDir()); err == nil {
+ t.Fatal("arming proceeded without the service account")
+ }
+ lookupServiceUser = func(string) (*user.User, error) {
+ return &user.User{Uid: "x", Gid: strconv.Itoa(os.Getgid())}, nil
+ }
+ if err := guardArmControllers(t.TempDir()); err == nil {
+ t.Fatal("arming accepted a malformed uid")
+ }
+}
+
+// I-2: an edge restart inside an apply pulls in the recover unit. On an
+// armed box guard-exec recover hands the journal to the lock holder at once
+// instead of waiting out systemRecoverLockWait.
+func TestPlan9AGuardRecoverYieldsToHeldApplyLock(t *testing.T) {
+ opt := t.TempDir()
+ if held, err := recoverLockHeld(opt); err != nil || held {
+ t.Fatalf("no lock file = %v, %v", held, err)
+ }
+ plan9aAbsent(t, filepath.Join(systemRootDir(opt), systemLockFile))
+ if err := os.MkdirAll(systemRootDir(opt), 0755); err != nil {
+ t.Fatal(err)
+ }
+ lock, err := os.OpenFile(filepath.Join(systemRootDir(opt), systemLockFile), os.O_CREATE|os.O_RDWR, 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer lock.Close()
+ if held, err := recoverLockHeld(opt); err != nil || held {
+ t.Fatalf("free lock = %v, %v", held, err)
+ }
+ if err := flockExclusiveNonblock(lock); err != nil {
+ t.Skipf("flock unsupported: %v", err)
+ }
+ if held, err := recoverLockHeld(opt); err != nil || !held {
+ t.Fatalf("held lock = %v, %v", held, err)
+ }
+ lock.Close()
+ if held, err := recoverLockHeld(opt); err != nil || held {
+ t.Fatalf("released lock = %v, %v", held, err)
+ }
+ again, err := os.OpenFile(filepath.Join(systemRootDir(opt), systemLockFile), os.O_RDWR, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer again.Close()
+ if err := flockExclusiveNonblock(again); err != nil {
+ t.Fatalf("probe left the lock held: %v", err)
+ }
+}
+```
+
+
+Implementation.
+
+#### New file: `internal/hostfloor/begun.go`
+
+```go
+package hostfloor
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+)
+
+// Begun is root's update-begun marker for a floor transaction. Begin writes
+// it with the reservation ID before the first box-changing step. RolledBack
+// is set by the backward paths before the journal is removed, so completion
+// can tell a finished rollback from a transaction that never changed the box
+// (Opus re-review M1-4).
+type Begun struct {
+ ID string `json:"id"`
+ RolledBack bool `json:"rolled_back,omitempty"`
+}
+
+// ReadBegun reads the marker in dir. An absent marker is os.ErrNotExist; one
+// that does not decode or names no transaction is ErrFloor.
+func ReadBegun(dir string) (Begun, error) {
+ raw, err := os.ReadFile(filepath.Join(dir, "update-begun.json"))
+ if err != nil {
+ return Begun{}, err
+ }
+ var b Begun
+ if json.Unmarshal(raw, &b) != nil || b.ID == "" {
+ return Begun{}, ErrFloor
+ }
+ return b, nil
+}
+
+// MarkRolledBack records durably that transaction id rolled back. It never
+// creates a marker: an absent one is os.ErrNotExist, and a marker for any
+// other transaction is ErrFloor and is left as it is.
+func MarkRolledBack(dir, id string) error {
+ b, err := ReadBegun(dir)
+ if err != nil {
+ return err
+ }
+ if b.ID != id {
+ return ErrFloor
+ }
+ if b.RolledBack {
+ return nil
+ }
+ b.RolledBack = true
+ raw, err := json.Marshal(b)
+ if err != nil {
+ return err
+ }
+ return Durable(filepath.Join(dir, "update-begun.json"), raw, 0644)
+}
+```
+
+#### New file: `internal/hostfloor/controllers_linux.go`
+
+```go
+//go:build linux
+
+package hostfloor
+
+import (
+ "errors"
+ "fmt"
+ "path/filepath"
+
+ "golang.org/x/sys/unix"
+)
+
+// EnsureControllerDir creates <dataDir>/.hostexec, the claim controllers'
+// state directory (claim D6), and settles an existing one: a real directory
+// owned by uid:gid, mode 2770, like /data/hostguard. The floor-armed update
+// path refuses while the directory is missing (CheckControllersIdle), so
+// guard-arm creates it (Opus re-review M1-2).
+//
+// Neither dataDir nor .hostexec may be a symlink. Ownership and mode are set
+// through the opened descriptor, so a path swapped after the open changes
+// nothing but the directory that was checked. Existing records are kept.
+func EnsureControllerDir(dataDir string, uid, gid int) error {
+ if !filepath.IsAbs(dataDir) || filepath.Clean(dataDir) != dataDir {
+ return ErrFloor
+ }
+ parent, err := unix.Open(dataDir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
+ if err != nil {
+ return fmt.Errorf("open %s: %w", dataDir, err)
+ }
+ defer unix.Close(parent)
+ if err = unix.Mkdirat(parent, ".hostexec", 0o700); err != nil && !errors.Is(err, unix.EEXIST) {
+ return fmt.Errorf("create %s/.hostexec: %w", dataDir, err)
+ }
+ fd, err := unix.Openat(parent, ".hostexec", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
+ if err != nil {
+ return fmt.Errorf("%w: %s/.hostexec is not a directory: %v", ErrFloor, dataDir, err)
+ }
+ defer unix.Close(fd)
+ var st unix.Stat_t
+ if err = unix.Fstat(fd, &st); err != nil {
+ return err
+ }
+ if st.Mode&unix.S_IFMT != unix.S_IFDIR {
+ return ErrFloor
+ }
+ if err = unix.Fchown(fd, uid, gid); err != nil {
+ return err
+ }
+ if err = unix.Fchmod(fd, 0o2770); err != nil {
+ return err
+ }
+ if err = unix.Fsync(fd); err != nil {
+ return err
+ }
+ return unix.Fsync(parent)
+}
+```
+
+#### New file: `internal/hostfloor/controllers_other.go`
+
+```go
+//go:build !linux
+
+package hostfloor
+
+func EnsureControllerDir(string, int, int) error { return ErrFloor }
+```
+
+#### Patch: `internal/hostfloor/remote.go`
+
+```diff
+--- a/internal/hostfloor/remote.go
++++ b/internal/hostfloor/remote.go
+@@ -7,6 +7,7 @@
+ "github.com/aws/smithy-go"
+ "github.com/carlosframework/platform/internal/release"
+ "net"
++ "os"
+ )
+
+ func Offline(err error) bool {
+@@ -39,3 +40,39 @@
+ }
+ return nil
+ }
++
++// CheckRemotePolicy is guard-exec's comparison of the local policy with the
++// published floor (D1). An armed policy is the prospective floor, persisted
++// before publication, so a store holding neither floor object is the
++// expected state and the local policy, already enforced, stands (Opus 8b
++// review I-1). Every other answer refuses unless it matches: a half-written
++// pair, a malformed or unauthorized object, a different digest, or no
++// remote floor at all once the policy is enforced. An unreachable store is
++// offline, as in CheckRemote.
++func CheckRemotePolicy(ctx context.Context, s release.Store, pub ed25519.PublicKey, bucket string, p Policy, local Verified) error {
++ raw, _, rawErr := s.Get(ctx, Key)
++ auth, _, authErr := s.Get(ctx, AuthKey)
++ if Offline(rawErr) || Offline(authErr) {
++ return nil
++ }
++ if errors.Is(rawErr, os.ErrNotExist) && errors.Is(authErr, os.ErrNotExist) {
++ if p.Mode == "armed" {
++ return nil
++ }
++ return ErrFloor
++ }
++ if rawErr != nil {
++ return rawErr
++ }
++ if authErr != nil {
++ return authErr
++ }
++ remote, err := Verify(pub, bucket, raw, auth)
++ if err != nil {
++ return err
++ }
++ if remote.Digest != local.Digest {
++ return ErrFloor
++ }
++ return nil
++}
+```
+
+#### Patch: `internal/host/guardarm.go`
+
+```diff
+--- a/internal/host/guardarm.go
++++ b/internal/host/guardarm.go
+@@ -3,15 +3,20 @@
+ import (
+ "context"
+ "encoding/json"
++ "errors"
+ "flag"
++ "fmt"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/version"
+ "log"
+ "os"
++ "os/user"
+ "path/filepath"
+ "runtime"
++ "strconv"
++ "strings"
+ )
+
+ func RunSystemGuardArm(args []string) {
+@@ -64,8 +69,8 @@
+ if err = hostfloor.CheckImage(d.PublicKey, f, identity, version.Build, platform, hash); err != nil {
+ log.Fatal(err)
+ }
+- if _, err = os.Stat(filepath.Join(systemRootDir(release.OptDir()), "journal.json")); !os.IsNotExist(err) {
+- log.Fatal(hostfloor.ErrFloor)
++ if err = guardArmIdle(d.Dir, systemRootDir(release.OptDir())); err != nil {
++ log.Fatal(err)
+ }
+ for _, path := range []string{systemBinaryPath(release.OptDir()), systemPrevPath(release.OptDir())} {
+ candidate, err := hostfloor.OpenProtected(path)
+@@ -108,6 +113,9 @@
+ if release.VerifyRootEnvelope(pub, wire, &invitation) != nil || invitation.Bucket != d.Bucket || invitation.Reference != enrollment.Box || invitation.Enrollment != enrollment.Enrollment {
+ log.Fatal(hostfloor.ErrFloor)
+ }
++ }
++ if err = guardArmControllers(dataDir()); err != nil {
++ log.Fatal(err)
+ }
+ for _, dir := range []string{d.Dir, filepath.Join(d.Dir, "identities")} {
+ if err = os.MkdirAll(dir, 0755); err != nil {
+@@ -179,3 +187,53 @@
+ log.Fatal(err)
+ }
+ }
++
++// guardArmIdle refuses to arm while root's update state is on the box: an
++// open journal, or any update-begun.json* in the guard directory. Main's
++// region marker, its temporary file or a stale copy beside an armed policy
++// would wedge every guarded update and recovery (Opus re-review M1-3).
++// guard-arm calls it holding the apply lock, so no update writes either
++// while it looks.
++func guardArmIdle(guardDir, rootDir string) error {
++ journal := filepath.Join(rootDir, "journal.json")
++ if _, err := os.Lstat(journal); err == nil {
++ return fmt.Errorf("%w: %s is open; let the update finish or recover first", hostfloor.ErrFloor, journal)
++ } else if !errors.Is(err, os.ErrNotExist) {
++ return err
++ }
++ entries, err := os.ReadDir(guardDir)
++ if errors.Is(err, os.ErrNotExist) {
++ return nil
++ }
++ if err != nil {
++ return err
++ }
++ for _, e := range entries {
++ if strings.HasPrefix(e.Name(), updateBegunFile) {
++ return fmt.Errorf("%w: %s is present; let the update finish or recover first", hostfloor.ErrFloor, filepath.Join(guardDir, e.Name()))
++ }
++ }
++ return nil
++}
++
++// lookupServiceUser resolves the controllers' account; a test seam.
++var lookupServiceUser = user.Lookup
++
++// guardArmControllers creates <DataDir>/.hostexec for the service account
++// before any armed state is written. The armed update path refuses while it
++// is missing (Opus re-review M1-2).
++func guardArmControllers(dataDir string) error {
++ u, err := lookupServiceUser(serviceUser)
++ if err != nil {
++ return err
++ }
++ uid, err := strconv.Atoi(u.Uid)
++ if err != nil {
++ return fmt.Errorf("%w: %s has uid %q", hostfloor.ErrFloor, serviceUser, u.Uid)
++ }
++ gid, err := strconv.Atoi(u.Gid)
++ if err != nil {
++ return fmt.Errorf("%w: %s has gid %q", hostfloor.ErrFloor, serviceUser, u.Gid)
++ }
++ return hostfloor.EnsureControllerDir(dataDir, uid, gid)
++}
+```
+
+#### Patch: `internal/host/guardexec.go`
+
+```diff
+--- a/internal/host/guardexec.go
++++ b/internal/host/guardexec.go
+@@ -9,6 +9,7 @@
+ "os"
+ "path/filepath"
+ "runtime"
++ "syscall"
+ "time"
+ )
+
+@@ -35,11 +36,21 @@
+ if err != nil {
+ log.Fatal(err)
+ }
++ if args[0] == "recover" {
++ held, err := recoverLockHeld(release.OptDir())
++ if err != nil {
++ log.Fatal(err)
++ }
++ if held {
++ log.Print("system guard-exec recover: an apply holds the lock; it recovers at its own start")
++ return
++ }
++ }
+ if policy.Mode != "unpublished" {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ store, pub, e := openSystemStore(ctx)
+ if e == nil {
+- e = hostfloor.CheckRemote(ctx, store, pub, d.Bucket, local)
++ e = hostfloor.CheckRemotePolicy(ctx, store, pub, d.Bucket, policy, local)
+ }
+ cancel()
+ if e != nil && !hostfloor.Offline(e) {
+@@ -78,3 +89,26 @@
+ log.Fatal(err)
+ }
+ }
++
++// recoverLockHeld reports whether another process holds the apply lock.
++// Only root's apply, recover and guard-arm take it, and an apply recovers
++// any journal itself at its start, so guard-exec recover leaves the box to
++// the holder at once. The edge drop-in Requires= the recover unit, so every
++// edge restart inside an apply starts a recover; waiting out
++// systemRecoverLockWait there kept the restarted edge down for 30 s (Opus 8b
++// review I-2). A missing lock file is a free lock and is not created.
++func recoverLockHeld(optDir string) (bool, error) {
++ lock, err := os.OpenFile(filepath.Join(systemRootDir(optDir), systemLockFile), os.O_RDWR, 0)
++ if errors.Is(err, os.ErrNotExist) {
++ return false, nil
++ }
++ if err != nil {
++ return false, err
++ }
++ defer lock.Close()
++ err = flockExclusiveNonblock(lock)
++ if errors.Is(err, syscall.EWOULDBLOCK) {
++ return true, nil
++ }
++ return false, err
++}
+```
+
+#### Patch: `internal/host/guardtransaction.go`
+
+```diff
+--- a/internal/host/guardtransaction.go
++++ b/internal/host/guardtransaction.go
+@@ -29,8 +29,15 @@
+ return p.Mode != "unpublished", err
+ }
+ func (a *systemApplier) guardImage(path string) error {
+- if !a.guarded() {
+- return nil
++ _, err := a.guardImageHash(path)
++ return err
++}
++
++// guardImageHash is guardImage, returning the verified image's SHA-256
++// ("" when no floor guards this box).
++func (a *systemApplier) guardImageHash(path string) (string, error) {
++ if !a.guarded() {
++ return "", nil
+ }
+ open := hostfloor.OpenProtected
+ if a.guardOpen != nil {
+@@ -38,39 +45,113 @@
+ }
+ f, err := open(path)
+ if err != nil {
+- return err
++ return "", err
+ }
+ defer f.Close()
+ hash, err := a.floor.VerifyFile(f, a.platform)
+ if err != nil {
+- return err
++ return "", err
+ }
+ p, _, err := a.floor.Load()
+ if err != nil || p.Mode == "unpublished" {
+- return err
++ return hash, err
+ }
+ tx, err := a.guardTransaction()
+ if err != nil {
+ if os.IsNotExist(err) {
+ if j, e := readSystemJournal(a.optDir); e == nil && j == nil && path == systemBinaryPath(a.optDir) {
+- return nil
++ return hash, nil
+ }
+ }
+- return err
++ return "", err
+ }
+ var roster hostactors.Roster
+ if release.VerifyRootEnvelope(a.floor.PublicKey, tx.Roster, &roster) != nil || roster.Validate(a.floor.Bucket) != nil {
+- return hostfloor.ErrFloor
++ return "", hostfloor.ErrFloor
+ }
+ reservation, ok := roster.Activations[tx.ID]
+ _, floor, err := a.floor.Load()
+ if err != nil {
+- return err
++ return "", err
+ }
+ if !ok || reservation.FloorSHA256 != floor.Digest || reservation.Actors[tx.Actor] != tx.Enrollment || !slices.Contains(reservation.Hashes[tx.Actor], hash) {
+- return hostfloor.ErrFloor
+- }
+- return nil
++ return "", hostfloor.ErrFloor
++ }
++ return hash, nil
++}
++
++// guardTarget is the transaction's target image: the middle of the three
++// hashes prepareGuard selected the reservation by, read from the signed
++// roster root persisted with the transaction.
++func (a *systemApplier) guardTarget(tx guardTransaction) (string, error) {
++ var roster hostactors.Roster
++ if release.VerifyRootEnvelope(a.floor.PublicKey, tx.Roster, &roster) != nil || roster.Validate(a.floor.Bucket) != nil {
++ return "", hostfloor.ErrFloor
++ }
++ hashes := roster.Activations[tx.ID].Hashes[tx.Actor]
++ if len(hashes) != 3 || hashes[1] == "" {
++ return "", hostfloor.ErrFloor
++ }
++ return hashes[1], nil
++}
++
++// guardFinished reports whether a transaction with no journal open has
++// finished: the verified image on disk is its target, or root's marker
++// records that it rolled back (Opus re-review M1-4). A transaction that is
++// neither never changed the box: it was refused or deferred before its
++// first destructive step, or abandoned at the rename.
++func (a *systemApplier) guardFinished(tx guardTransaction) (bool, error) {
++ hash, err := a.guardImageHash(systemBinaryPath(a.optDir))
++ if err != nil {
++ return false, err
++ }
++ begun, err := hostfloor.ReadBegun(a.floor.Dir)
++ if err == nil {
++ if begun.ID != tx.ID {
++ return false, hostfloor.ErrFloor
++ }
++ if begun.RolledBack {
++ return true, nil
++ }
++ } else if !errors.Is(err, os.ErrNotExist) {
++ return false, err
++ }
++ target, err := a.guardTarget(tx)
++ if err != nil {
++ return false, err
++ }
++ return hash == target, nil
++}
++
++// settleGuard closes a transaction with no journal open. A finished one is
++// completed, once no controller is active, as before. One that never
++// changed the box records no completion: root withdraws its own marker and
++// transaction, so neither wedges the box nor reports the old image as the
++// result, and the console's reservation stays held for the agent's retry
++// or an operator. The withdrawal does not wait for controllers: it gives up
++// only root's intent, on a box it did not change.
++func (a *systemApplier) settleGuard(tx guardTransaction, beforeCompletion func() error) error {
++ finished, err := a.guardFinished(tx)
++ if err != nil {
++ return err
++ }
++ if !finished {
++ if err := hostfloor.RemoveDurable(filepath.Join(a.floor.Dir, "update-begun.json")); err != nil {
++ return err
++ }
++ return hostfloor.RemoveDurable(filepath.Join(a.floor.Dir, "activation.json"))
++ }
++ if beforeCompletion != nil {
++ if err := beforeCompletion(); err != nil {
++ return err
++ }
++ }
++ // Completion is durable before the marker disappears. Replaying either
++ // write is harmless; readers never report completion with a marker present.
++ if err = hostfloor.Durable(filepath.Join(a.floor.Dir, "completed.json"), []byte(tx.ID), 0644); err != nil {
++ return err
++ }
++ return hostfloor.RemoveDurable(filepath.Join(a.floor.Dir, "update-begun.json"))
+ }
+ func (a *systemApplier) guardTransaction() (guardTransaction, error) {
+ raw, err := os.ReadFile(filepath.Join(a.floor.Dir, "activation.json"))
+@@ -168,15 +249,9 @@
+ if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
+ return readErr
+ }
+- if err := hostfloor.CheckControllersIdle(a.dataDir); err != nil {
+- return err
+- }
+- return a.endGuard()
+- }
+- if err != nil {
+- return err
+- }
+- if err := hostfloor.CheckControllersIdle(a.dataDir); err != nil {
++ return a.settleGuard(tx, a.controllersIdle)
++ }
++ if err != nil {
+ return err
+ }
+ tx, err := a.guardTransaction()
+@@ -190,7 +265,10 @@
+ return hostfloor.ErrFloor
+ }
+ if j == nil {
+- return a.endGuard()
++ return a.settleGuard(tx, a.controllersIdle)
++ }
++ if err := a.controllersIdle(); err != nil {
++ return err
+ }
+ if j.Phase == systemPhaseBackingUp {
+ return a.guardImage(systemBinaryPath(a.optDir))
+@@ -214,16 +292,10 @@
+ } else if j != nil {
+ return hostfloor.ErrFloor
+ }
+- if err = a.guardImage(systemBinaryPath(a.optDir)); err != nil {
+- return err
+- }
+- // Completion is durable before the marker disappears. Replaying either
+- // write is harmless; readers never report completion with a marker present.
+- if err = hostfloor.Durable(filepath.Join(a.floor.Dir, "completed.json"), []byte(tx.ID), 0644); err != nil {
+- return err
+- }
+- return hostfloor.RemoveDurable(filepath.Join(a.floor.Dir, "update-begun.json"))
+-}
++ return a.settleGuard(tx, nil)
++}
++
++func (a *systemApplier) controllersIdle() error { return hostfloor.CheckControllersIdle(a.dataDir) }
+ func (a *systemApplier) closeGuardJournal() error {
+ if !a.guarded() {
+ a.removeJournal()
+```
+
+#### Patch: `internal/host/systemparticipation.go`
+
+```diff
+--- a/internal/host/systemparticipation.go
++++ b/internal/host/systemparticipation.go
+@@ -136,3 +136,28 @@
+ a.ops.logf("system apply: %v", err)
+ }
+ }
++
++// closeGuardRollback is closeGuardJournal for the backward paths: rollback
++// and backward recovery. On a floor-armed box it first records in root's
++// marker that the transaction rolled back, before the journal goes, so the
++// completion that follows is recorded for an image that is not the target
++// (Opus re-review M1-4). A marker for another transaction refuses and keeps
++// the journal. With no armed floor this is closeGuardJournal, as on main.
++func (a *systemApplier) closeGuardRollback() error {
++ active, err := a.activeFloor()
++ if err != nil {
++ return err
++ }
++ if active {
++ tx, err := a.guardTransaction()
++ if err != nil {
++ return err
++ }
++ // No marker means none was begun, and settleGuard then records no
++ // completion: the reservation stays held, which is the safe side.
++ if err := hostfloor.MarkRolledBack(a.floor.Dir, tx.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
++ return err
++ }
++ }
++ return a.closeGuardJournal()
++}
+```
+
+#### Patch: `internal/host/systemapply.go`
+
+```diff
+--- a/internal/host/systemapply.go
++++ b/internal/host/systemapply.go
+@@ -1,6 +1,7 @@
+ package host
+
+ import (
++ "bytes"
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+@@ -1471,11 +1472,11 @@
+ if err := a.gate(ctx, j.PrevBuild); err != nil {
+ err := failed("previous build failed its gate too", err)
+ // The journal finish, under activationGuard (moves floor and hold
+- // rule first), closed through closeGuardJournal (the claim release
+- // floor's completion) inside it.
++ // rule first), closed through closeGuardRollback (the claim release
++ // floor's completion of a rolled-back transaction) inside it.
+ var closeErr error
+ if gerr := a.activationGuard(restored, restored, func() error {
+- closeErr = a.closeGuardJournal()
++ closeErr = a.closeGuardRollback()
+ return nil
+ }); gerr != nil {
+ return fmt.Errorf("%v; the journal stays: %v", err, gerr)
+@@ -1488,14 +1489,15 @@
+ if err := a.arm(plan); err != nil {
+ a.ops.logf("system apply: arm previous plan: %v", err)
+ }
+- // closeGuardJournal's own error is main's: returned as it is, not as a
+- // rollback failure (the journal is already gone by then).
++ // closeGuardRollback's own error is main's: returned as it is, not as a
++ // rollback failure (the journal is already gone by then, unless the
++ // armed floor refused to record the rollback, which keeps it).
+ var closeErr error
+ if err := a.activationGuard(restored, restored, func() error {
+ if err := a.writeResult(req, systemOutcomeRolledBack, reason); err != nil {
+ return err
+ }
+- closeErr = a.closeGuardJournal()
++ closeErr = a.closeGuardRollback()
+ return nil
+ }); err != nil {
+ return failed("finish", err) // journal kept
+@@ -1731,13 +1733,13 @@
+ }
+ // The backward finish is guarded like every other: a floor raised since
+ // the restore's rename keeps the journal, and the next run judges again.
+- // closeGuardJournal's own error is main's, returned as main returns it.
++ // closeGuardRollback's own error is main's, returned as main returns it.
+ var closeErr error
+ if err := a.activationGuard(restored, restored, func() error {
+ if err := a.writeResult(req, systemOutcomeRolledBack, "interrupted at "+j.Phase); err != nil {
+ return err
+ }
+- closeErr = a.closeGuardJournal()
++ closeErr = a.closeGuardRollback()
+ return nil
+ }); err != nil {
+ return false, fmt.Errorf("system apply: recovery: %w", err) // journal kept
+```
+
+### Task A2: make activation reservations recoverable and require current evidence authority
+
+Invariant: only a fully published floor/roster can reserve an executable change; release requires completion or an authenticated exclusion; cancellation cannot let a delayed publication land; and only a currently active, unfenced different box can witness termination.
+
+Covers I-4, M1-5, M-2's readers, M-3, M-6 and M-8. The test files also contain B6's census-key regression; run that case only after B6's implementation. Add tests first and observe missing cancel route/verb, missing exclusions, accepted invalid writers, report overwrites and wrong rollback status. Then apply these exact patches.
+
+`ReleaseActivation` uses the census's D7 exclusions: a validated enrollment fence, or root-signed loss records binding the exact enrollment and current quarantine revision. Silence, stale reports and unreadable evidence never exclude an active box. Every remaining reserved actor must complete the same activation on an authorized verified image.
+
+Reserve only when the signed roster is `published` and binds the exact verified floor digest. Reuse the exact prepared publication and request digest. A held incompatible reservation names its ID and returns conflict. Rollback CAS conflict returns 409 without retrying the history pop.
+
+Register `POST /api/cli/system/activation/cancel` with zero service roles, behind the existing `apiSystemRoller` person gate: ops-account owner or named `CARLOS_SYSTEM_OPERATORS`. Input is channel, optional router component, optional expected activation ID. The existing cancellation primitive fences the prepared pointer ETag before removing the reservation. Published or moved pointers and changed reservations return 409; missing reservation 404; nonoperators bare 404; read-only consoles 503. Keep bounded strict JSON, signing and audit checks. Add `carlos system cancel-activation --channel stable [--activation ID] [--yes]` and its CLI documentation. The closed-door count becomes 43. Extend the mutation census with a real authorized cancellation probe proving removal, preserved current version and refusal of stale delayed publication.
+
+Every fence consumer now verifies a currently active writer, exact enrollment, matching region/account and a different target box. Its own fence must be absent; any present, malformed or unreadable own fence refuses. A self-asserted `observed_at` does not grant historical authority after fencing/quarantine. This intentionally supersedes the old comment promising writer-retirement survival. If historical evidence retention is required, Paul must rule an authenticated historical-authority contract; neither create-only storage nor a claimed timestamp proves it. The fixture previously signing its own death now uses a separate valid writer; its assertions remain intact.
+
+A console refuses to overwrite *any* fresh report with a different process ID, including one written before this process started. There is no predecessor exemption. Stale, malformed and out-of-tolerance future reports retain main's replaceability; writes use the checked ETag. An ordinary restart may therefore wait for the old report's 15-minute freshness window. Immediate takeover would need positive predecessor-death evidence, not a guessed startup timestamp. Distinct simultaneously running replicas require distinct console IDs.
+
+```bash
+nice -n 10 go test -p 1 ./internal/hostactors ./internal/hostfence ./internal/console . -run 'TestPlan9(ReleaseActivation|FenceWriter|ClaimFence|ReserveHostUpdate|RollbackCAS|OperatorCancels|CancelRefuses|ConsoleReport|SystemVerb)|TestEveryCLIDoorNamesItsRoles|TestEveryClosedDoorRefusesAServiceCredential' -count=1
+nice -n 10 go test -p 1 ./internal/console -run '^TestClaimConsoleMutationInvariant' -count=1
+```
+
+The second check is required because this task creates a new mutation door. Its accepted-effect probe cannot be replaced with an expected 404 fixture. A3 supplies the separate resource-policy restriction; these reader checks alone do not establish exclusive writer credentials.
+
+
+Tests first.
+
+#### New file: `internal/hostactors/plan9_b_test.go`
+
+```go
+package hostactors
+
+import (
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+ "github.com/carlosframework/platform/internal/termfence"
+)
+
+// plan9World is a published deployment with three active boxes: a and b
+// hold an executable activation reservation, w is a regional fence writer.
+type plan9World struct {
+ d Directory
+ sg release.Signer
+ floor hostfloor.Verified
+ now time.Time
+ hash string
+ target string
+}
+
+const plan9Activation = "act"
+
+func plan9Fixture(t *testing.T, edit func(*Roster)) (plan9World, Observed) {
+ t.Helper()
+ ctx := context.Background()
+ pub, key, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ store := release.NewDirStore(t.TempDir())
+ sg := release.RootSigner(key)
+ w := plan9World{sg: sg, now: time.Now().UTC(), hash: strings.Repeat("a", 64), target: strings.Repeat("b", 64)}
+ rel := release.SystemRelease{Version: "v1.0.0", Source: "release:v1.0.0", ReleaseTag: "v1.0.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{"linux-amd64": {Key: release.SystemArtifactKey("v1.0.0", "linux-amd64"), SHA256: w.hash}}}
+ if _, err := release.PromoteSystem(ctx, store, sg, "router", "stable", rel, release.SystemPromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ wire, _, err := store.Get(ctx, release.SystemPointerKey("router", "stable"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := release.PublishSystemIdentity(ctx, store, pub, wire); err != nil {
+ t.Fatal(err)
+ }
+ if err := hostfloor.Publish(ctx, store, sg, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"}); err != nil {
+ t.Fatal(err)
+ }
+ w.d = Directory{Store: store, PublicKey: pub, Bucket: "bucket"}
+ if w.floor, err = hostfloor.Read(ctx, store, pub, "bucket"); err != nil {
+ t.Fatal(err)
+ }
+ r := Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, Publication: "published", FloorSHA256: w.floor.Digest, Boxes: map[string]Box{}, Consoles: map[string]Console{}, Holds: map[string]Reservation{}, Activations: map[string]Reservation{}}
+ for i, ref := range []string{"a", "b", "w"} {
+ r.Boxes[ref] = Box{Enrollment: strings.Repeat(string(rune('1'+i)), 32), Namespace: "platform", BoxID: ref, Label: ref, Region: "ie", Pool: "default", Cloud: Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-" + ref}, EvidenceSHA256: strings.Repeat("e", 64), State: "active", LossIDs: []string{}}
+ }
+ h := Reservation{ID: plan9Activation, FloorSHA256: w.floor.Digest, Actors: map[string]string{}, Releases: map[string]Report{}, Hashes: map[string][]string{}}
+ for _, ref := range []string{"a", "b"} {
+ en := r.Boxes[ref].Enrollment
+ h.Actors["box:"+ref] = en
+ h.Releases["box:"+ref] = Report{Enrollment: en}
+ h.Hashes["box:"+ref] = []string{w.hash, w.target, w.hash}
+ }
+ r.Activations[plan9Activation] = h
+ if edit != nil {
+ edit(&r)
+ }
+ raw, err := release.SignRootEnvelope(sg, r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.Put(ctx, Key, raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ w.report(t, "a", true)
+ o, err := w.d.Read(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return w, o
+}
+
+// report publishes ref's private status as a box that finished (or did not
+// finish) the reserved activation on its current image.
+func (w plan9World) report(t *testing.T, ref string, complete bool) {
+ t.Helper()
+ o, err := w.d.Read(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ b := o.Roster.Boxes[ref]
+ box := status.Box{GeneratedAt: w.now, Metrics: &status.BoxMetrics{Enrollment: b.Enrollment, InstanceID: b.Cloud.InstanceID, Release: "v1.0.0", ReleaseTag: "v1.0.0", Platform: "linux-amd64", BinarySHA256: w.hash, MovesProtocol: 1, BootID: "boot", FloorSHA256: w.floor.Digest, FloorGuardReady: true, SystemChannel: "stable", ActivationID: plan9Activation, ActivationComplete: complete}}
+ raw, err := json.Marshal(box)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, etag, _ := w.d.Store.Get(context.Background(), "status/boxes/"+ref+".json")
+ if _, err := w.d.Store.Put(context.Background(), "status/boxes/"+ref+".json", raw, etag); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// fence creates target's termination fence, observed at the given time and
+// written by writer, exactly as a regional worker persists it.
+func (w plan9World) fence(t *testing.T, target, writer string, at time.Time, state string) {
+ t.Helper()
+ o, err := w.d.Read(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, by := o.Roster.Boxes[target], o.Roster.Boxes[writer]
+ f := termfence.Record{V: 1, Box: target, Enrollment: b.Enrollment, Cloud: b.Cloud, State: state, ObservedAt: at.UTC().Format(time.RFC3339Nano), Via: termfence.ViaDescribe, RequestID: "request", WrittenBy: termfence.WrittenBy{Box: writer, Enrollment: by.Enrollment}}
+ raw, err := json.Marshal(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := release.CreateExact(context.Background(), w.d.Store, "control/box-fences/"+b.Enrollment+".json", raw); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// quarantine marks ref quarantined in the roster edit and returns the loss
+// record a root-signed operator declaration would have created for it.
+func plan9Quarantine(r *Roster, ref, loss string) {
+ b := r.Boxes[ref]
+ b.State = "quarantined"
+ b.QuarantineRevision = 1
+ b.LossIDs = []string{loss}
+ r.Boxes[ref] = b
+}
+
+func (w plan9World) loss(t *testing.T, ref, id string, fencedAt time.Time) {
+ t.Helper()
+ o, err := w.d.Read(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ b := o.Roster.Boxes[ref]
+ record := hostclaim.LossRecord{
+ V: 1, ID: id, State: "prepared", Actor: "operator", AcceptedAt: fencedAt,
+ Host: "shop.bab.oncarlos.com", HolderIncarnation: strings.Repeat("a", 64), HolderStamp: 3, HolderSession: 2, ExpectedClaimGen: 4, ExpectedClaimETag: `"claim"`,
+ OpID: "op", OpKind: hostclaim.KindMoving, OpPhase: "precommit", Box: ref, Enrollment: b.Enrollment, QuarantineRevision: b.QuarantineRevision,
+ CloudObservation: hostclaim.LossCloudObservation{ObservedAt: fencedAt, Account: "123", Region: "eu-west-1", InstanceID: b.Cloud.InstanceID, State: "stopped", RequestID: "request-1"},
+ OperatorAttestation: hostclaim.LossOperatorAttestation{MaintainComputeFence: true, ColdQuarantineStartOnly: true},
+ FencedAt: fencedAt,
+ ConfirmedConsequences: hostclaim.LossConsequences{RevokeExecution: true, RemoveParticipation: true, RecoveryIntent: json.RawMessage(`{"kind":"lost-target"}`), TimersPotentiallyLost: true},
+ Timers: hostclaim.TimerLoss{Knowledge: hostclaim.TimersUnknown},
+ }
+ if _, err := hostclaim.CreateLossRecord(context.Background(), w.d.Store, w.sg, record); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// I-4 and D7: a held activation is released only by verified completion of
+// every reserved actor, except an actor excluded through the valid
+// enrollment-qualified termination fence or its durable quarantine
+// authority. Silence, unreadable fence authority and an invalid fence never
+// exclude.
+func TestPlan9ReleaseActivationExcludesOnlyFencedOrQuarantinedActors(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ released bool
+ }{
+ {"silent", false},
+ {"incomplete", false},
+ {"fenced", true},
+ {"fenced-other-incomplete", false},
+ {"fence-nonterminal", false},
+ {"fence-self-written", false},
+ {"fence-unreadable", false},
+ {"quarantined", true},
+ {"quarantined-loss-missing", false},
+ {"both-complete", true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := context.Background()
+ var edit func(*Roster)
+ if strings.HasPrefix(tc.name, "quarantined") {
+ edit = func(r *Roster) { plan9Quarantine(r, "b", "loss-b") }
+ }
+ w, _ := plan9Fixture(t, edit)
+ switch tc.name {
+ case "incomplete":
+ w.report(t, "b", false)
+ case "fenced":
+ w.fence(t, "b", "w", w.now.Add(-time.Minute), "terminated")
+ case "fenced-other-incomplete":
+ w.fence(t, "b", "w", w.now.Add(-time.Minute), "terminated")
+ w.report(t, "a", false)
+ case "fence-nonterminal":
+ w.fence(t, "b", "w", w.now.Add(-time.Minute), "stopped")
+ case "fence-self-written":
+ w.fence(t, "b", "b", w.now.Add(-time.Minute), "terminated")
+ case "fence-unreadable":
+ w.fence(t, "b", "w", w.now.Add(-time.Minute), "terminated")
+ w.d.Store = brokenFenceStore{w.d.Store}
+ case "quarantined":
+ w.loss(t, "b", "loss-b", w.now.Add(-time.Hour))
+ case "both-complete":
+ w.report(t, "b", true)
+ }
+ err := w.d.ReleaseActivation(ctx, w.sg, plan9Activation, w.now)
+ after, readErr := w.d.Read(ctx)
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ _, held := after.Roster.Activations[plan9Activation]
+ if tc.released {
+ if err != nil || held {
+ t.Fatalf("%s: release = %v, held = %v; want released", tc.name, err, held)
+ }
+ return
+ }
+ if err == nil || !held {
+ t.Fatalf("%s: release = %v, held = %v; want refusal and a held reservation", tc.name, err, held)
+ }
+ })
+ }
+}
+
+// M1-5: require a currently active and unfenced writer.
+func TestPlan9FenceWriterMustBeActiveAndUnfencedNow(t *testing.T) {
+ observed := time.Now().UTC().Add(-time.Hour)
+ for _, tc := range []struct {
+ name string
+ valid bool
+ }{
+ {"active-writer", true},
+ {"self-written", false},
+ {"writer-fenced-before", false},
+ {"writer-fenced-same-instant", false},
+ {"writer-fenced-after", false},
+ {"writer-fence-malformed", false},
+ {"writer-fence-unreadable", false},
+ {"writer-quarantined-before", false},
+ {"writer-quarantined-after", false},
+ {"writer-quarantined-loss-missing", false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := context.Background()
+ var edit func(*Roster)
+ if strings.HasPrefix(tc.name, "writer-quarantined") {
+ edit = func(r *Roster) { plan9Quarantine(r, "w", "loss-w") }
+ }
+ w, _ := plan9Fixture(t, edit)
+ writer := "w"
+ if tc.name == "self-written" {
+ writer = "b"
+ }
+ w.fence(t, "b", writer, observed, "terminated")
+ o, err := w.d.Read(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ writerFence := "control/box-fences/" + o.Roster.Boxes["w"].Enrollment + ".json"
+ switch tc.name {
+ case "writer-fenced-before":
+ w.fence(t, "w", "a", observed.Add(-time.Minute), "terminated")
+ case "writer-fenced-same-instant":
+ w.fence(t, "w", "a", observed, "terminated")
+ case "writer-fenced-after":
+ w.fence(t, "w", "a", observed.Add(time.Minute), "terminated")
+ case "writer-fence-malformed":
+ if err := release.CreateExact(ctx, w.d.Store, writerFence, []byte("{")); err != nil {
+ t.Fatal(err)
+ }
+ case "writer-fence-unreadable":
+ w.d.Store = plan9KeyFailStore{Store: w.d.Store, key: writerFence}
+ case "writer-quarantined-before":
+ w.loss(t, "w", "loss-w", observed.Add(-time.Minute))
+ case "writer-quarantined-after":
+ w.loss(t, "w", "loss-w", observed.Add(time.Minute))
+ }
+ fenced, err := w.d.TerminationFence(ctx, o, "b", time.Now())
+ if tc.valid != (err == nil && fenced) {
+ t.Fatalf("%s: fenced = %v, err = %v; want valid = %v", tc.name, fenced, err, tc.valid)
+ }
+ if !tc.valid && fenced {
+ t.Fatalf("%s: invalid evidence excluded the box", tc.name)
+ }
+ })
+ }
+}
+
+type plan9KeyFailStore struct {
+ release.Store
+ key string
+}
+
+func (s plan9KeyFailStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == s.key {
+ return nil, "", errors.New("writer fence unavailable")
+ }
+ return s.Store.Get(ctx, key)
+}
+
+// I2: binding a census key rewrites the roster outside Replace. It must
+// stay behind the published floor (older binaries strict-decode the roster
+// and would refuse census_public_key) and bind only an active, unfenced
+// enrollment, read fresh rather than from a caller-supplied copy.
+func TestPlan9BindCensusKeyRequiresPublishedFloorAndActiveEnrollment(t *testing.T) {
+ for _, tc := range []string{"published-active", "unpublished", "preparing", "floor-mismatch", "quarantined", "retired", "fenced", "fence-unreadable", "stale-observed", "forged-observed"} {
+ t.Run(tc, func(t *testing.T) {
+ ctx := context.Background()
+ w, _ := plan9Fixture(t, func(r *Roster) {
+ r.Activations = map[string]Reservation{}
+ switch tc {
+ case "unpublished":
+ r.Publication, r.FloorSHA256 = "", ""
+ case "preparing":
+ r.Publication = "preparing"
+ case "floor-mismatch":
+ r.FloorSHA256 = strings.Repeat("f", 64)
+ case "quarantined":
+ plan9Quarantine(r, "b", "loss-b")
+ case "retired":
+ plan9Quarantine(r, "b", "loss-b")
+ b := r.Boxes["b"]
+ b.State = "retired"
+ r.Boxes["b"] = b
+ }
+ })
+ base := w.d.Store
+ switch tc {
+ case "fenced":
+ w.fence(t, "b", "w", w.now.Add(-time.Minute), "terminated")
+ case "fence-unreadable":
+ w.d.Store = brokenFenceStore{w.d.Store}
+ }
+ before, err := w.d.Read(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, key, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ binding := CensusKeyBinding{Bucket: "bucket", Box: "b", Enrollment: before.Roster.Boxes["b"].Enrollment, PublicKey: pub}
+ raw, err := json.Marshal(binding)
+ if err != nil {
+ t.Fatal(err)
+ }
+ input := before
+ switch tc {
+ case "stale-observed":
+ // A concurrent writer moved the roster after the caller read it.
+ next := Clone(before.Roster)
+ next.RequestLimitNS = 120000000000
+ if _, err := w.d.Replace(ctx, w.sg, before, next); err != nil {
+ t.Fatal(err)
+ }
+ case "forged-observed":
+ // The caller's copy claims b is active while the signed roster
+ // says otherwise; only the signed roster decides.
+ o, err := w.d.Read(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ r := Clone(o.Roster)
+ plan9Quarantine(&r, "b", "loss-b")
+ signed, err := release.SignRootEnvelope(w.sg, r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ etag, err := w.d.Store.Put(ctx, Key, signed, o.ETag)
+ if err != nil {
+ t.Fatal(err)
+ }
+ input = Observed{Roster: before.Roster, ETag: etag}
+ }
+ got, err := w.d.BindCensusKey(ctx, w.sg, input, binding, ed25519.Sign(key, raw))
+ if tc == "published-active" {
+ if err != nil || !ed25519.PublicKey(got.Roster.Boxes["b"].CensusPublicKey).Equal(pub) {
+ t.Fatalf("bind = %v", err)
+ }
+ return
+ }
+ if err == nil {
+ t.Fatalf("%s: census key bound", tc)
+ }
+ after, readErr := (Directory{Store: base, PublicKey: w.d.PublicKey, Bucket: "bucket"}).Read(ctx)
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ if len(after.Roster.Boxes["b"].CensusPublicKey) != 0 {
+ t.Fatalf("%s: census key written despite refusal", tc)
+ }
+ })
+ }
+}
+```
+
+#### New file: `internal/hostfence/plan9_b_test.go`
+
+```go
+package hostfence
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/service/ec2/types"
+ "github.com/carlosframework/platform/internal/awsinv"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// M1-5: journal evidence requires current writer authority.
+func TestPlan9ClaimFenceRequiresWriterUnfencedNow(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ offset time.Duration
+ valid bool
+ }{
+ {"writer-unfenced", 0, true},
+ {"writer-fenced-before", -time.Minute, false},
+ {"writer-fenced-same-instant", 0, false},
+ {"writer-fenced-after", time.Minute, false},
+ {"writer-fence-malformed", 0, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := context.Background()
+ w, s := terminationFixture(t)
+ w.Reader = func(context.Context, string) (awsinv.ExactEC2API, error) {
+ return terminatedEC2{state: types.InstanceStateNameTerminated, owner: "123"}, nil
+ }
+ got, err := w.Describe(ctx, "dead")
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err := s.Get(ctx, got.Key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var seen Termination
+ if err := json.Unmarshal(raw, &seen); err != nil {
+ t.Fatal(err)
+ }
+ observed, err := time.Parse(time.RFC3339Nano, seen.ObservedAt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err := w.Directory.Read(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ self, dead := o.Roster.Boxes["worker"], o.Roster.Boxes["dead"]
+ if tc.name != "writer-unfenced" {
+ body := []byte("{")
+ if tc.name != "writer-fence-malformed" {
+ f := Termination{V: 1, Box: "worker", Enrollment: self.Enrollment, Cloud: self.Cloud, State: "terminated", ObservedAt: observed.Add(tc.offset).Format(time.RFC3339Nano), Via: "describe", RequestID: "request", WrittenBy: Worker{Box: "dead", Enrollment: dead.Enrollment}}
+ if body, err = json.Marshal(f); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := release.CreateExact(ctx, s, FenceKey(self.Enrollment), body); err != nil {
+ t.Fatal(err)
+ }
+ }
+ _, err = hostclaim.VerifyFence(ctx, s, ClaimFenceAuthority{Directory: w.Directory, Now: w.Now}, ClaimFenceRef(got))
+ if (err == nil) != tc.valid {
+ t.Fatalf("%s: verify = %v; want valid = %v", tc.name, err, tc.valid)
+ }
+ })
+ }
+}
+```
+
+#### New file: `internal/console/plan9_b_test.go`
+
+```go
+package console
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// plan9ActivationWorld is activationFixture's published deployment, built
+// on an existing bucket and key so the HTTP doors can be exercised: stable
+// points at v1.0.0 with v1.1.0 in its history, the floor is published and
+// one active box reports v1.0.0 on stable.
+func plan9ActivationWorld(t *testing.T, store release.Store, key ed25519.PrivateKey) release.SystemRelease {
+ t.Helper()
+ ctx := context.Background()
+ sg := release.RootSigner(key)
+ pub := sg.RootPublicKey()
+ old := release.SystemRelease{Version: "v1.0.0", Source: "release:v1.0.0", ReleaseTag: "v1.0.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{"linux-amd64": {Key: release.SystemArtifactKey("v1.0.0", "linux-amd64"), SHA256: strings.Repeat("a", 64)}}}
+ next := release.SystemRelease{Version: "v1.1.0", Source: "release:v1.1.0", ReleaseTag: "v1.1.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{"linux-amd64": {Key: release.SystemArtifactKey("v1.1.0", "linux-amd64"), SHA256: strings.Repeat("b", 64)}}}
+ for _, rel := range []release.SystemRelease{next, old} {
+ if _, err := release.PromoteSystem(ctx, store, sg, "router", "stable", rel, release.SystemPromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err := store.Get(ctx, release.SystemPointerKey("router", "stable"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := release.PublishSystemIdentity(ctx, store, pub, raw); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := hostfloor.Publish(ctx, store, sg, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"}); err != nil {
+ t.Fatal(err)
+ }
+ floor, err := hostfloor.Read(ctx, store, pub, "bucket")
+ if err != nil {
+ t.Fatal(err)
+ }
+ enrollment := strings.Repeat("e", 32)
+ roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, Publication: "published", FloorSHA256: floor.Digest, Boxes: map[string]hostactors.Box{"box": {BoxID: "box", Namespace: "platform", Label: "box", Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-box"}, Enrollment: enrollment, EvidenceSHA256: strings.Repeat("f", 64), State: "active"}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
+ raw, err := release.SignRootEnvelope(sg, roster)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.Put(ctx, hostactors.Key, raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ box := status.Box{Label: "box", GeneratedAt: time.Now(), Metrics: &status.BoxMetrics{Enrollment: enrollment, InstanceID: "i-box", Release: old.Version, ReleaseTag: old.ReleaseTag, Platform: "linux-amd64", BinarySHA256: old.Artifacts["linux-amd64"].SHA256, MovesProtocol: 1, BootID: "boot", FloorSHA256: floor.Digest, FloorGuardReady: true, HostClaimEnabled: true, SystemChannel: "stable", Layout: &status.LayoutReport{Mode: "flat", Rows: map[string]status.RowLayout{}}}}
+ raw, err = json.Marshal(box)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.Put(ctx, "status/boxes/box.json", raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ return next
+}
+
+func plan9Roster(t *testing.T, s *Server) hostactors.Observed {
+ t.Helper()
+ d := hostactors.Directory{Store: s.cfg.Releases, PublicKey: release.RootSigner(s.cfg.Key).RootPublicKey(), Bucket: s.cfg.Bucket}
+ o, err := d.Read(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ return o
+}
+
+// M-8: a host update reservation is created only against a roster that is
+// published at exactly the floor being enforced. A floor whose roster is
+// still preparing, unpublished or bound to another digest is an unfinished
+// publication and refuses, with no reservation and no pointer movement.
+func TestPlan9ReserveHostUpdateRequiresPublishedRosterAtFloor(t *testing.T) {
+ for _, tc := range []string{"preparing", "unpublished", "other-digest"} {
+ t.Run(tc, func(t *testing.T) {
+ ctx := context.Background()
+ s, d, target := activationFixture(t)
+ before := plan9Roster(t, s)
+ r := hostactors.Clone(before.Roster)
+ switch tc {
+ case "preparing":
+ r.Publication = "preparing"
+ case "unpublished":
+ r.Publication, r.FloorSHA256 = "", ""
+ case "other-digest":
+ r.FloorSHA256 = strings.Repeat("0", 64)
+ }
+ raw, err := release.SignRootEnvelope(release.RootSigner(s.cfg.Key), r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := d.Store.Put(ctx, hostactors.Key, raw, before.ETag); err != nil {
+ t.Fatal(err)
+ }
+ pointer, _, err := d.Store.Get(ctx, release.SystemPointerKey("router", "stable"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if id, err := s.reserveHostUpdate(ctx, &target, "stable", "promote", release.SystemPromoteOptions{}); err == nil || id != "" {
+ t.Fatalf("%s: reservation = %q, %v; want refusal", tc, id, err)
+ }
+ if _, err := s.promoteHostUpdate(ctx, target, "router", "stable", release.SystemPromoteOptions{}); err == nil {
+ t.Fatalf("%s: promote published against an unfinished floor publication", tc)
+ }
+ after := plan9Roster(t, s)
+ if len(after.Roster.Activations) != 0 {
+ t.Fatalf("%s: reservation written", tc)
+ }
+ now, _, err := d.Store.Get(ctx, release.SystemPointerKey("router", "stable"))
+ if err != nil || !bytes.Equal(now, pointer) {
+ t.Fatalf("%s: pointer moved: %v", tc, err)
+ }
+ })
+ }
+}
+
+// casOncePointerStore answers one conditional pointer write with the
+// conflict S3 gives when another writer moved the pointer first.
+type casOncePointerStore struct {
+ release.Store
+ puts int
+}
+
+func (s *casOncePointerStore) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) {
+ if key == release.SystemPointerKey("router", "canary") {
+ s.puts++
+ return "", release.ErrCASConflict
+ }
+ return s.Store.Put(ctx, key, raw, etag)
+}
+
+// M-6: with the flag off and no floor, a rollback that loses its pointer
+// CAS answers 409 and does not retry. A retry would pop history again
+// against a pointer the operator never saw.
+func TestPlan9RollbackCASConflictIsAConflictAndNotRetried(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-plan9-rollback")
+ sg := release.RootSigner(fx.priv)
+ rel := func(v string) release.SystemRelease {
+ return release.SystemRelease{Version: v, Artifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: release.SystemArtifactKey(v, "linux-arm64"), SHA256: strings.Repeat("a", 64)}}}
+ }
+ if _, err := release.PromoteSystem(context.Background(), fx.rel, sg, "router", "canary", rel("v1.25.0"), release.SystemPromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(1100 * time.Millisecond)
+ if _, err := release.PromoteSystem(context.Background(), fx.rel, sg, "router", "canary", rel("v1.26.0"), release.SystemPromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ pointer, _, err := fx.rel.Get(context.Background(), release.SystemPointerKey("router", "canary"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ wrapped := &casOncePointerStore{Store: fx.rel}
+ fx.srv.cfg.Releases = wrapped
+ st, out := postJSON(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, owner)
+ if st != http.StatusConflict {
+ t.Fatalf("rollback after a lost CAS = %d %v, want 409", st, out)
+ }
+ if msg, _ := out["error"].(string); strings.Contains(msg, "compare-and-swap") || strings.Contains(msg, "\u2014") {
+ t.Fatalf("409 body leaks the store error or uses an em dash: %q", msg)
+ }
+ if wrapped.puts != 1 {
+ t.Fatalf("pointer writes = %d, want exactly one attempt", wrapped.puts)
+ }
+ now, _, err := fx.rel.Get(context.Background(), release.SystemPointerKey("router", "canary"))
+ if err != nil || !bytes.Equal(now, pointer) {
+ t.Fatalf("pointer changed: %v", err)
+ }
+}
+
+// I-4: an unpublished activation reservation blocks rollback and every
+// other promote of its channel. An ops owner or system operator, on a person
+// session, cancels it: the pointer is fenced at the publication's expected
+// ETag first, so a delayed publication can never land, and only then is
+// the reservation removed. Rollback and promote work again afterwards.
+func TestPlan9OperatorCancelsAnUnpublishedActivation(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-plan9-cancel")
+ fx.srv.cfg.Bucket = "bucket"
+ target := plan9ActivationWorld(t, fx.rel, fx.priv)
+ ctx := context.Background()
+ id, err := fx.srv.reserveHostUpdate(ctx, &target, "stable", "promote", release.SystemPromoteOptions{})
+ if err != nil || id == "" {
+ t.Fatal(id, err)
+ }
+ held := plan9Roster(t, fx.srv)
+ stale := *held.Roster.Activations[id].Publication
+ // The wedge: a different request for the channel is refused while held.
+ if _, err := fx.srv.rollbackHostUpdate(ctx, "router", "stable", "paul@keymail.dev"); err == nil {
+ t.Fatal("rollback succeeded through a held activation")
+ }
+ if fx.srv.cfg.Releases == nil {
+ t.Fatal("fixture has no bucket")
+ }
+ if fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()) != nil {
+ t.Fatal("add member")
+ }
+ member := cliToken(t, fx, "member@keymail.dev", "code-plan9-cancel-member")
+ _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", member)
+ if st, body := postRaw(t, fx.ts.URL+"/api/cli/system/activation/cancel", map[string]string{"channel": "stable"}, member); st != http.StatusNotFound || body != want {
+ t.Fatalf("member cancel = %d %q, want the bare 404", st, body)
+ }
+ if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/activation/cancel", map[string]string{"channel": "stable", "activation": strings.Repeat("0", 32)}, owner); st != http.StatusConflict {
+ t.Fatalf("cancel naming another activation = %d %v, want 409", st, out)
+ }
+ if after := plan9Roster(t, fx.srv); len(after.Roster.Activations) != 1 {
+ t.Fatal("mismatched cancel removed the reservation")
+ }
+ for _, body := range []map[string]string{{"channel": "edge"}, {"channel": ""}, {"channel": "stable", "component": "../etc"}} {
+ if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/activation/cancel", body, owner); st != http.StatusBadRequest {
+ t.Errorf("cancel %v = %d %v, want 400", body, st, out)
+ }
+ }
+ st, out := postJSON(t, fx.ts.URL+"/api/cli/system/activation/cancel", map[string]string{"channel": "stable", "activation": id}, owner)
+ if st != http.StatusOK || str(t, out, "activation") != id {
+ t.Fatalf("cancel = %d %v", st, out)
+ }
+ if after := plan9Roster(t, fx.srv); len(after.Roster.Activations) != 0 {
+ t.Fatal("cancel left the reservation held")
+ }
+ if _, err := release.PublishSystemPublication(ctx, fx.rel, fx.pub, stale); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("delayed publication after cancel: %v", err)
+ }
+ if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/activation/cancel", map[string]string{"channel": "stable"}, owner); st != http.StatusNotFound {
+ t.Fatalf("cancel with nothing held = %d %v, want 404", st, out)
+ }
+ p, err := fx.srv.rollbackHostUpdate(ctx, "router", "stable", "paul@keymail.dev")
+ if err != nil || p.Current.Version != "v1.1.0" {
+ t.Fatalf("rollback after cancel = %v, %v", p, err)
+ }
+}
+
+// I-4: cancellation never reaches past publication. A published activation
+// is released only by completion or the D7 exclusions, so the door answers
+// 409 and the reservation stays held. A read-only console cannot cancel.
+func TestPlan9CancelRefusesAPublishedActivationAndReadOnlyConsole(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-plan9-published")
+ fx.srv.cfg.Bucket = "bucket"
+ target := plan9ActivationWorld(t, fx.rel, fx.priv)
+ p, err := fx.srv.promoteHostUpdate(context.Background(), target, "router", "stable", release.SystemPromoteOptions{})
+ if err != nil || p.ActivationID == "" {
+ t.Fatal(p, err)
+ }
+ st, out := postJSON(t, fx.ts.URL+"/api/cli/system/activation/cancel", map[string]string{"channel": "stable"}, owner)
+ if st != http.StatusConflict {
+ t.Fatalf("cancel of a published activation = %d %v, want 409", st, out)
+ }
+ if msg, _ := out["error"].(string); strings.Contains(msg, "\u2014") || strings.Contains(msg, "compare-and-swap") {
+ t.Fatalf("409 body: %q", msg)
+ }
+ if held := plan9Roster(t, fx.srv); len(held.Roster.Activations) != 1 {
+ t.Fatal("published activation released by cancel")
+ }
+ fx.srv.cfg.Key = nil
+ if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/activation/cancel", map[string]string{"channel": "stable"}, owner); st != http.StatusServiceUnavailable {
+ t.Fatalf("read-only cancel = %d %v, want 503", st, out)
+ }
+}
+
+// M-3: every fresh foreign-process report refuses replacement.
+func TestPlan9ConsoleReportRefusesAFreshForeignProcess(t *testing.T) {
+ started := time.Now().Add(-time.Minute)
+ for _, tc := range []struct {
+ name string
+ existing func() []byte
+ replaced bool
+ }{
+ {"absent", nil, true},
+ {"same-process", func() []byte { return plan9Report("boot:1", time.Now()) }, true},
+ {"foreign-after-start", func() []byte { return plan9Report("boot:2", time.Now()) }, false},
+ {"foreign-before-start", func() []byte { return plan9Report("boot:2", started.Add(-time.Second)) }, false},
+ {"foreign-stale", func() []byte { return plan9Report("boot:2", time.Now().Add(-16*time.Minute)) }, true},
+ {"foreign-future", func() []byte { return plan9Report("boot:2", time.Now().Add(time.Hour)) }, true},
+ {"malformed", func() []byte { return []byte("{") }, true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := t.Context()
+ store := s3ShapedStore{release.NewDirStore(t.TempDir())}
+ const key = "status/consoles/console-2.json"
+ if tc.existing != nil {
+ if _, err := store.Put(ctx, key, tc.existing(), ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ mine := plan9Report("boot:1", time.Now())
+ err := putHostReport(ctx, store, key, mine)
+ got, _, readErr := store.Get(ctx, key)
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ if tc.replaced {
+ if err != nil || !bytes.Equal(got, mine) {
+ t.Fatalf("%s: put = %v; want replaced", tc.name, err)
+ }
+ return
+ }
+ if err == nil || bytes.Equal(got, mine) {
+ t.Fatalf("%s: put = %v; want refusal and the foreign report kept", tc.name, err)
+ }
+ if strings.Contains(err.Error(), "\u2014") {
+ t.Fatalf("error uses an em dash: %v", err)
+ }
+ })
+ }
+}
+
+func plan9Report(process string, at time.Time) []byte {
+ raw, _ := json.Marshal(hostactors.Report{Component: "console", Actor: "console-2", ProcessID: process, GeneratedAt: at.UTC()})
+ return raw
+}
+```
+
+#### New file: `plan9_b_system_test.go`
+
+```go
+package main
+
+import "testing"
+
+// I-4: cancelling an unpublished activation is reachable from the CLI, in
+// both the bare and the component form, with its flags left for the verb.
+func TestPlan9SystemVerbCancelActivation(t *testing.T) {
+ for _, in := range [][]string{
+ {"cancel-activation", "--channel", "stable"},
+ {"router", "cancel-activation", "--channel", "stable"},
+ } {
+ verb, rest, comp := systemVerb(in)
+ if verb != "cancel-activation" || comp != "router" || len(rest) != 2 {
+ t.Errorf("systemVerb(%v) = %q %v %q", in, verb, rest, comp)
+ }
+ }
+}
+```
+
+#### Patch: `internal/hostactors/authority_regression_test.go`
+
+```diff
+--- a/internal/hostactors/authority_regression_test.go
++++ b/internal/hostactors/authority_regression_test.go
+@@ -12,14 +12,22 @@
+ "time"
+ )
+
+-func writeFence(t *testing.T, d Directory, o Observed, ref string, now time.Time, invalid bool) {
++// writeFence records ref's termination as observed by a separate regional
++// writer box. The writer sits in another pool, so no census scope requires
++// it; a box never witnesses its own termination (M1-5).
++func writeFence(t *testing.T, d Directory, o *Observed, ref string, now time.Time, invalid bool) {
+ t.Helper()
+ b := o.Roster.Boxes[ref]
++ writer := b
++ writer.BoxID, writer.Label, writer.Pool = "fence-writer", "fence-writer", "workers"
++ writer.Enrollment = strings.Repeat("9", 32)
++ writer.Cloud.InstanceID = "i-fence-writer"
++ o.Roster.Boxes["fence-writer"] = writer
+ state := "terminated"
+ if invalid {
+ state = "stopped"
+ }
+- f := map[string]any{"v": 1, "box": ref, "enrollment": b.Enrollment, "cloud": b.Cloud, "state": state, "observed_at": now, "via": "describe", "request_id": "request", "written_by": map[string]string{"box": ref, "enrollment": b.Enrollment}}
++ f := map[string]any{"v": 1, "box": ref, "enrollment": b.Enrollment, "cloud": b.Cloud, "state": state, "observed_at": now, "via": "describe", "request_id": "request", "written_by": map[string]string{"box": "fence-writer", "enrollment": writer.Enrollment}}
+ raw, _ := json.Marshal(f)
+ if err := release.CreateExact(context.Background(), d.Store, "control/box-fences/"+b.Enrollment+".json", raw); err != nil {
+ t.Fatal(err)
+@@ -29,7 +37,7 @@
+ for _, invalid := range []bool{false, true} {
+ t.Run(map[bool]string{false: "valid", true: "invalid"}[invalid], func(t *testing.T) {
+ d, o, e, f, scope, now := censusFixture(t)
+- writeFence(t, d, o, "box", now, invalid)
++ writeFence(t, d, &o, "box", now, invalid)
+ reports, err := Census(context.Background(), o, e, d.PublicKey, f, scope, now)
+ if invalid {
+ if err == nil {
+@@ -96,7 +104,7 @@
+ if fault != "good" {
+ return
+ }
+- writeFence(t, d, o, "box", now, false)
++ writeFence(t, d, &o, "box", now, false)
+ for id, cc := range o.Roster.Consoles {
+ r := e.console
+ if id == "starter" {
+@@ -179,7 +187,7 @@
+ d.Store = brokenFenceStore{d.Store}
+ e.d = d
+ } else {
+- writeFence(t, d, o, "box", now, false)
++ writeFence(t, d, &o, "box", now, false)
+ key := "control/box-fences/" + o.Roster.Boxes["box"].Enrollment + ".json"
+ raw, etag, err := d.Store.Get(context.Background(), key)
+ if err != nil {
+@@ -264,7 +272,7 @@
+ r.Boxes["box"] = b
+ _, err = d.Replace(ctx, sg, o, r)
+ case "fenced", "console-fenced", "malformed":
+- writeFence(t, d, o, "box", time.Now(), fault == "malformed")
++ writeFence(t, d, &o, "box", time.Now(), fault == "malformed")
+ case "unreadable":
+ d.Store = brokenFenceStore{d.Store}
+ }
+```
+
+#### Patch: `internal/console/svcroutes_test.go`
+
+```diff
+--- a/internal/console/svcroutes_test.go
++++ b/internal/console/svcroutes_test.go
+@@ -71,7 +71,7 @@
+ // deployment. A credential reaching one of these could point the whole fleet
+ // at a binary of its choosing, which is not a wider version of any workload's
+ // authority \u2014 it is the end of the boundary the roles are drawn inside. One
+-// substring covers all seven doors, and no role may ever reach any of them.
++// substring covers all eight doors, and no role may ever reach any of them.
+ var (
+ mintDoors = []string{"/instance-token", "/door-keys", "/api/cli/services/", "/email/credentials"}
+ fleetDoors = []string{"/api/cli/fleets/"}
+@@ -212,6 +212,10 @@
+ "GET /api/cli/system/jobs/{id}",
+ "PUT /api/cli/system/upload",
+ "POST /api/cli/system/rollback",
++ // Cancelling an unpublished executable activation (claim plan 9, I-4)
++ // frees a channel for the next promote or rollback: the same subject,
++ // the same cut.
++ "POST /api/cli/system/activation/cancel",
+ // The layout door (plan 5): the fleet's instance layout is the
+ // substrate every tenant sits inside, the same cut as the five above.
+ "GET /api/cli/system/layout",
+@@ -899,8 +903,8 @@
+ }
+ seen++
+ }
+- if seen != len(closedDoors) || seen != 42 {
+- t.Errorf("walked %d closed doors, want all %d (and 42 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, the layout read, seven system doors, two deployment-wide antivirus, three platform geoip doors, and four store-entitlement doors)",
++ if seen != len(closedDoors) || seen != 43 {
++ t.Errorf("walked %d closed doors, want all %d (and 43 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, the layout read, eight system doors, two deployment-wide antivirus, three platform geoip doors, and four store-entitlement doors)",
+ seen, len(closedDoors))
+ }
+ }
+```
+
+#### Patch: `internal/console/claim_invariant_fixtures_test.go`
+
+```diff
+--- a/internal/console/claim_invariant_fixtures_test.go
++++ b/internal/console/claim_invariant_fixtures_test.go
+@@ -992,6 +992,7 @@
+ "route POST /api/cli/ops/hosts/{host}/resolve/inspect": claimRead("operator host-loss inspection: reads the claim, the record and the roster and signs a challenge; writes nothing"),
+ "route POST /api/cli/system/antivirus": claimNonApp("the deployment scanning switch (system/antivirus.json)"),
+ "route POST /api/cli/system/layout": claimNonApp("the deployment layout intent (control/layout.json)"),
++ "route POST /api/cli/system/activation/cancel": claimNonApp("cancels an unpublished system activation"),
+ "route POST /api/cli/system/rollback": claimNonApp("rolls the platform system pointer back"),
+ "route POST /api/cli/system/update": claimNonApp("moves the platform system pointer to a signed release"),
+ "route PUT /api/cli/system/upload": claimNonApp("uploads and signs a platform system release"),
+```
+
+#### Patch: `internal/console/claim_invariant_probes_nonapp_test.go`
+
+```diff
+--- a/internal/console/claim_invariant_probes_nonapp_test.go
++++ b/internal/console/claim_invariant_probes_nonapp_test.go
+@@ -8,6 +8,8 @@
+ import (
+ "context"
+ "encoding/json"
++ "errors"
++ "github.com/carlosframework/platform/internal/hostactors"
+ "net/http"
+ "net/url"
+ "strings"
+@@ -692,6 +694,68 @@
+ }
+ if got := cx.systemVersion("canary"); got != "66587fa" {
+ return `the canary pointer names "` + got + `", want 66587fa`
++ }
++ return ""
++ },
++ },
++ "route POST /api/cli/system/activation/cancel": {
++ setup: func(t *testing.T, cx *claimCase) {
++ ctx := context.Background()
++ cx.systemRelease(t, "canary", "66587fa")
++ current, _, err := release.GetSystemPointer(ctx, cx.rel, cx.pub, "router", "canary")
++ if err != nil {
++ t.Fatal(err)
++ }
++ prepared, err := release.PrepareSystemPromotion(ctx, cx.rel, release.RootSigner(cx.priv), "router", "canary", current.Current, release.SystemPromoteOptions{}, "claim-cancel-probe")
++ if err != nil {
++ t.Fatal(err)
++ }
++ d := cx.claimFixtureDirectory()
++ o, err := d.Read(ctx)
++ if err != nil {
++ t.Fatal(err)
++ }
++ h := hostactors.Reservation{ID: prepared.ID, Publication: &prepared, RequestSHA256: strings.Repeat("a", 64), UpdateMode: "promote", FloorSHA256: o.Roster.FloorSHA256, Actors: map[string]string{}, Releases: map[string]hostactors.Report{}}
++ for ref, box := range o.Roster.Boxes {
++ h.Actors["box:"+ref] = box.Enrollment
++ h.Releases["box:"+ref] = hostactors.Report{Enrollment: box.Enrollment}
++ break
++ }
++ next := hostactors.Clone(o.Roster)
++ next.Activations[h.ID] = h
++ raw, err := release.SignRootEnvelope(release.RootSigner(cx.priv), next)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if _, err = cx.rel.Put(ctx, hostactors.Key, raw, o.ETag); err != nil {
++ t.Fatal(err)
++ }
++ raw, err = json.Marshal(prepared)
++ if err != nil {
++ t.Fatal(err)
++ }
++ cx.vars["cancel-publication"] = string(raw)
++ },
++ do: claimAPIReply(http.MethodPost, "/api/cli/system/activation/cancel", `{"channel":"canary","activation":"claim-cancel-probe"}`),
++ body: []string{`"activation":"claim-cancel-probe"`},
++ effect: func(t *testing.T, cx *claimCase, r claimReply) string {
++ ctx := context.Background()
++ o, err := cx.claimFixtureDirectory().Read(ctx)
++ if err != nil {
++ return "cannot read roster after cancellation"
++ }
++ if _, ok := o.Roster.Activations["claim-cancel-probe"]; ok {
++ return "activation remains held"
++ }
++ var p release.SystemPublication
++ if err = json.Unmarshal([]byte(cx.vars["cancel-publication"]), &p); err != nil {
++ t.Fatal(err)
++ }
++ if _, err = release.PublishSystemPublication(ctx, cx.rel, cx.pub, p); !errors.Is(err, release.ErrCASConflict) {
++ return "cancelled publication was not fenced"
++ }
++ if cx.systemVersion("canary") != "66587fa" {
++ return "cancellation changed the running version"
+ }
+ return ""
+ },
+```
+
+
+Implementation.
+
+#### Patch: `internal/hostactors/fence_authority.go`
+
+```diff
+--- a/internal/hostactors/fence_authority.go
++++ b/internal/hostactors/fence_authority.go
+@@ -27,12 +27,15 @@
+ if json.Unmarshal(raw, &f) != nil {
+ return false, ErrRoster
+ }
+- if err := ValidateTerminationFence(o, ref, f, now); err != nil {
++ if err := d.ValidateTerminationFenceAuthority(ctx, o, ref, f, now); err != nil {
+ return false, err
+ }
+ return true, nil
+ }
+
++// ValidateTerminationFence checks the record against the roster alone. It
++// cannot see the writer's own fence or quarantine evidence; every consumer
++// that decides exclusion uses ValidateTerminationFenceAuthority instead.
+ func ValidateTerminationFence(o Observed, ref string, f termfence.Record, now time.Time) error {
+ b, ok := o.Roster.Boxes[ref]
+ at, err := time.Parse(time.RFC3339Nano, f.ObservedAt)
+@@ -42,8 +45,12 @@
+ if (f.Via != termfence.ViaDescribe || f.RequestID == "") && (f.Via != termfence.ViaEvent || f.EventID == "") {
+ return ErrRoster
+ }
++ // A box never witnesses its own termination.
++ if f.WrittenBy.Box == ref {
++ return ErrRoster
++ }
+ writer, ok := o.Roster.Boxes[f.WrittenBy.Box]
+- if !ok || writer.Enrollment != f.WrittenBy.Enrollment || writer.Region != b.Region || writer.Cloud.Account != b.Cloud.Account {
++ if !ok || writer.State != "active" || writer.Enrollment != f.WrittenBy.Enrollment || writer.Region != b.Region || writer.Cloud.Account != b.Cloud.Account {
+ return ErrRoster
+ }
+ _, err = termfence.Verify(
+@@ -55,3 +62,24 @@
+ }
+ return nil
+ }
++
++// ValidateTerminationFenceAuthority requires a currently active, unfenced
++// writer. A self-asserted observation time cannot establish historical authority.
++// Missing evidence is accepted only when the store proves the object absent.
++func (d Directory) ValidateTerminationFenceAuthority(ctx context.Context, o Observed, ref string, f termfence.Record, now time.Time) error {
++ if err := ValidateTerminationFence(o, ref, f, now); err != nil {
++ return err
++ }
++ if d.Store == nil {
++ return ErrRoster
++ }
++ writer := o.Roster.Boxes[f.WrittenBy.Box]
++ _, _, err := d.Store.Get(ctx, "control/box-fences/"+writer.Enrollment+".json")
++ if errors.Is(err, os.ErrNotExist) {
++ return nil
++ }
++ if err != nil {
++ return err
++ }
++ return ErrRoster
++}
+```
+
+#### Patch: `internal/hostactors/activation_complete.go`
+
+```diff
+--- a/internal/hostactors/activation_complete.go
++++ b/internal/hostactors/activation_complete.go
+@@ -2,6 +2,7 @@
+
+ import (
+ "context"
++ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+ "slices"
+@@ -32,6 +33,13 @@
+ if !ok || box.Enrollment != enrollment {
+ return ErrRoster
+ }
++ excluded, err := d.activationExcluded(ctx, o, ref, box, now)
++ if err != nil {
++ return err
++ }
++ if excluded {
++ continue
++ }
+ r, err := d.PrivateBoxReport(ctx, ref, box)
+ if err != nil {
+ return err
+@@ -48,3 +56,42 @@
+ _, err = d.Replace(ctx, sg, o, next)
+ return err
+ }
++
++// activationExcluded applies D7's only exclusions to a reserved actor, the
++// same ones the census applies: a valid enrollment-qualified termination
++// fence, or durable quarantine or retirement authority whose root-signed
++// loss records bind this exact enrollment at its current revision. A
++// silent active actor is never excluded, and any read or validation
++// failure refuses, leaving the reservation conservatively held.
++func (d Directory) activationExcluded(ctx context.Context, o Observed, ref string, b Box, now time.Time) (bool, error) {
++ fenced, err := d.TerminationFence(ctx, o, ref, now)
++ if err != nil {
++ return false, err
++ }
++ if fenced {
++ return true, nil
++ }
++ if b.State == "active" {
++ return false, nil
++ }
++ if len(b.LossIDs) == 0 || b.QuarantineRevision == 0 {
++ return false, ErrRoster
++ }
++ found := false
++ for _, id := range b.LossIDs {
++ loss, err := hostclaim.ReadLossRecord(ctx, d.Store, d.PublicKey, id)
++ if err != nil {
++ return false, err
++ }
++ if loss.Record.Box != ref || loss.Record.Enrollment != b.Enrollment {
++ return false, ErrRoster
++ }
++ if loss.Record.QuarantineRevision == b.QuarantineRevision {
++ found = true
++ }
++ }
++ if !found {
++ return false, ErrRoster
++ }
++ return true, nil
++}
+```
+
+#### Patch: `internal/hostfence/claim_fence.go`
+
+```diff
+--- a/internal/hostfence/claim_fence.go
++++ b/internal/hostfence/claim_fence.go
+@@ -9,9 +9,8 @@
+ "github.com/carlosframework/platform/internal/termfence"
+ )
+
+-// ClaimFenceAuthority authenticates the roster without creating the reverse
+-// hostclaim -> hostactors dependency. Immutable evidence remains valid after
+-// its reporter retires; enrollment/cloud binding must still be retained.
++// ClaimFenceAuthority authenticates current active, unfenced writer standing
++// against the signed roster without a reverse hostclaim dependency.
+ type ClaimFenceAuthority struct {
+ Directory hostactors.Directory
+ Now func() time.Time
+@@ -27,7 +26,7 @@
+ if err != nil {
+ return hostclaim.FenceBinding{}, err
+ }
+- if err := hostactors.ValidateTerminationFence(o, f.Box, f, a.Now()); err != nil {
++ if err := a.Directory.ValidateTerminationFenceAuthority(ctx, o, f.Box, f, a.Now()); err != nil {
+ return hostclaim.FenceBinding{}, err
+ }
+ return hostclaim.FenceBinding{Region: o.Roster.Boxes[f.Box].Region, RosterRevision: o.Roster.Revision, RosterETag: o.ETag}, nil
+```
+
+#### Patch: `internal/hostfence/termination.go`
+
+```diff
+--- a/internal/hostfence/termination.go
++++ b/internal/hostfence/termination.go
+@@ -125,7 +125,7 @@
+ if readErr != nil {
+ return EvidenceRef{}, readErr
+ }
+- if json.Unmarshal(saved, &old) != nil || old.Enrollment != f.Enrollment || old.Cloud != f.Cloud || hostactors.ValidateTerminationFence(o, f.Box, old, w.Now()) != nil {
++ if json.Unmarshal(saved, &old) != nil || old.Enrollment != f.Enrollment || old.Cloud != f.Cloud || w.Directory.ValidateTerminationFenceAuthority(ctx, o, f.Box, old, w.Now()) != nil {
+ return EvidenceRef{}, ErrObservation
+ }
+ raw = saved
+```
+
+#### Patch: `internal/console/hostactivation.go`
+
+```diff
+--- a/internal/console/hostactivation.go
++++ b/internal/console/hostactivation.go
+@@ -10,10 +10,23 @@
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
++ "log"
++ "net/http"
+ "os"
+ "strings"
+ "time"
+ )
++
++// activationHeldError names the executable activation reservation that
++// blocks a channel (I-4), so the operator learns what to wait for or
++// cancel. It is still hostactors.ErrRoster to every errors.Is check.
++type activationHeldError struct{ id, component, channel string }
++
++func (e activationHeldError) Error() string {
++ return "activation " + e.id + " is held for " + e.component + "/" + e.channel + ". It is released when every reserved box reports completion or is excluded by a termination fence or quarantine. If it was never published, cancel it with carlos system cancel-activation"
++}
++
++func (e activationHeldError) Unwrap() error { return hostactors.ErrRoster }
+
+ func (s *Server) reserveHostUpdate(ctx context.Context, rel *release.SystemRelease, channel, mode string, options release.SystemPromoteOptions, expectedETag ...string) (string, error) {
+ pub := release.RootSigner(s.cfg.Key).RootPublicKey()
+@@ -88,6 +101,12 @@
+ if err != nil {
+ return "", err
+ }
++ // M-8: a floor binds host updates only through a roster published at
++ // exactly its digest. A preparing, unpublished or differently bound
++ // roster is an unfinished publication, never permission to reserve.
++ if o.Roster.Publication != "published" || o.Roster.FloorSHA256 != floor.Digest {
++ return "", hostfloor.ErrFloor
++ }
+ requestRelease := *rel
+ requestRelease.ReleasedAt = time.Time{}
+ request, err := json.Marshal(struct {
+@@ -106,7 +125,7 @@
+ continue
+ }
+ if existing.RequestSHA256 != requestID || existing.FloorSHA256 != floor.Digest {
+- return "", hostactors.ErrRoster
++ return "", activationHeldError{id: id, component: existing.Publication.Component, channel: channel}
+ }
+ if _, err := existing.Publication.Verify(pub); err != nil {
+ return "", err
+@@ -201,7 +220,7 @@
+ continue
+ }
+ if h.UpdateMode != "rollback" || h.FloorSHA256 != floor.Digest {
+- return nil, hostactors.ErrRoster
++ return nil, activationHeldError{id: id, component: component, channel: channel}
+ }
+ return d.PublishActivation(ctx, id)
+ }
+@@ -225,3 +244,102 @@
+ }
+ return d.PublishActivation(ctx, id)
+ }
++
++// handleAPISystemActivationCancel is the operator's way out of an executable
++// activation reservation that was never published (I-4). Such a reservation
++// blocks rollback and every other promote of its channel, and nothing else
++// releases it: no box ever saw it, so no box can report completing it.
++//
++// It takes the update doors' gate (apiSystemRoller: a person who owns the
++// ops account or is on CARLOS_SYSTEM_OPERATORS) and the signing key. The
++// channel names the reservation, since at most one is held per channel; an
++// activation ID, when given, must be the one held. CancelActivation fences
++// the pointer at the publication's expected ETag before it removes the
++// reservation, so a delayed publication can never land afterwards, and a
++// publication that already landed answers 409: a published activation is
++// released only by completion or the D7 exclusions, never by this door.
++func (s *Server) handleAPISystemActivationCancel(w http.ResponseWriter, r *http.Request) {
++ addr, _, ok := s.apiSystemRoller(w, r)
++ if !ok {
++ return
++ }
++ if !s.systemBucket(w) {
++ return
++ }
++ if s.cfg.Key == nil {
++ jsonError(w, http.StatusServiceUnavailable, "no signing key on this host, so the console is running read-only")
++ return
++ }
++ var in struct {
++ Component string `json:"component"`
++ Channel string `json:"channel"`
++ Activation string `json:"activation"`
++ }
++ if !readJSONStrictLimit(w, r, &in, 1<<10) {
++ return
++ }
++ if in.Component == "" {
++ in.Component = release.SystemComponentRouter
++ }
++ if in.Component != release.SystemComponentRouter {
++ jsonError(w, http.StatusBadRequest, "unknown component "+in.Component)
++ return
++ }
++ if in.Channel != release.RungCanary && in.Channel != release.RungStable {
++ jsonError(w, http.StatusBadRequest, "channel must be canary or stable")
++ return
++ }
++ target := in.Component + "/" + in.Channel
++ s.systemMu.Lock()
++ running := s.systemRunning[target]
++ s.systemMu.Unlock()
++ if running != "" {
++ writeJSON(w, http.StatusConflict, map[string]string{
++ "error": "a promote of " + target + " is running on this console; watch it or wait for it to finish",
++ "job": running,
++ })
++ return
++ }
++ ctx := r.Context()
++ sg := release.RootSigner(s.cfg.Key)
++ d := hostactors.Directory{Store: s.cfg.Releases, PublicKey: sg.RootPublicKey(), Bucket: s.cfg.Bucket}
++ o, err := d.Read(ctx)
++ if errors.Is(err, os.ErrNotExist) {
++ jsonError(w, http.StatusNotFound, "no activation reservation is held for "+target)
++ return
++ }
++ if err != nil {
++ log.Printf("console: cancel activation %s: read roster: %v", target, err)
++ jsonError(w, http.StatusInternalServerError, "could not read the actor roster; see the console log")
++ return
++ }
++ id := ""
++ for key, h := range o.Roster.Activations {
++ if h.Publication != nil && h.Publication.Component == in.Component && h.Publication.Channel == in.Channel {
++ id = key
++ }
++ }
++ if id == "" {
++ jsonError(w, http.StatusNotFound, "no activation reservation is held for "+target)
++ return
++ }
++ if in.Activation != "" && in.Activation != id {
++ jsonError(w, http.StatusConflict, "the reservation held for "+target+" is "+id+", not "+in.Activation+"; nothing was cancelled")
++ return
++ }
++ err = d.CancelActivation(ctx, sg, id)
++ switch {
++ case errors.Is(err, release.ErrCASConflict):
++ jsonError(w, http.StatusConflict, "activation "+id+" was already published, or its channel pointer has moved, so it cannot be cancelled. It is released when every reserved box reports completion or is excluded by a termination fence or quarantine")
++ return
++ case errors.Is(err, hostactors.ErrRoster):
++ jsonError(w, http.StatusConflict, "activation "+id+" changed while it was being cancelled; nothing was released. Check carlos system status and try again")
++ return
++ case err != nil:
++ log.Printf("console: cancel activation %s on %s: %v", id, target, err)
++ jsonError(w, http.StatusInternalServerError, "could not cancel activation "+id+"; see the console log")
++ return
++ }
++ s.auditSystem(addr, "system-activation-cancel", "cancelled activation "+id+" on "+target)
++ writeJSON(w, http.StatusOK, map[string]string{"component": in.Component, "channel": in.Channel, "activation": id, "state": "cancelled"})
++}
+```
+
+#### Patch: `internal/console/apisystemupdate.go`
+
+```diff
+--- a/internal/console/apisystemupdate.go
++++ b/internal/console/apisystemupdate.go
+@@ -768,7 +768,17 @@
+ }
+ }
+ p, err := s.rollbackHostUpdate(r.Context(), in.Component, in.Channel, addr)
++ var held activationHeldError
+ switch {
++ case errors.Is(err, release.ErrCASConflict):
++ // M-6: the pointer moved between the read and the conditional
++ // write. No retry: a rollback pops history, so repeating it against
++ // a pointer the operator never saw could go back one release too far.
++ jsonError(w, http.StatusConflict, "the "+in.Channel+" pointer moved while this rollback was being prepared, so nothing was rolled back. Check carlos system status, then run the rollback again if it is still wanted")
++ return
++ case errors.As(err, &held):
++ jsonError(w, http.StatusConflict, held.Error())
++ return
+ case errors.Is(err, release.ErrNoSystemHistory):
+ jsonError(w, http.StatusConflict, err.Error())
+ return
+```
+
+#### Patch: `internal/console/authcli.go`
+
+```diff
+--- a/internal/console/authcli.go
++++ b/internal/console/authcli.go
+@@ -921,6 +921,9 @@
+ s.cliRoute("GET /api/cli/system/jobs/{id}", svcrole.Set{}, s.handleAPISystemJob)
+ s.cliRoute("PUT /api/cli/system/upload", svcrole.Set{}, s.handleAPISystemUpload)
+ s.cliRoute("POST /api/cli/system/rollback", svcrole.Set{}, s.handleAPISystemRollback)
++ // I-4: releasing an executable activation reservation that was never
++ // published. The same person gate as rollback (apiSystemRoller).
++ s.cliRoute("POST /api/cli/system/activation/cancel", svcrole.Set{}, s.handleAPISystemActivationCancel)
+ s.cliRoute("GET /api/cli/system/layout", svcrole.Set{}, s.handleAPISystemLayoutGet)
+ s.cliRoute("POST /api/cli/system/layout", svcrole.Set{}, s.handleAPISystemLayoutSet)
+ s.cliRoute("GET /api/cli/system/antivirus", svcrole.Set{}, s.handleAPISystemAntivirusGet)
+```
+
+#### Patch: `internal/console/hostreports.go`
+
+```diff
+--- a/internal/console/hostreports.go
++++ b/internal/console/hostreports.go
+@@ -98,11 +98,35 @@
+ }
+
+ // putHostReport replaces the console's report, creating it when absent.
++//
++// A fresh report from any different process refuses replacement. Process
++// start time cannot prove the earlier process has stopped. A replacement
++// process waits for the old report to expire; the write uses its checked ETag.
+ func putHostReport(ctx context.Context, store release.Store, key string, raw []byte) error {
+- _, etag, err := store.Get(ctx, key)
++ old, etag, err := store.Get(ctx, key)
+ if err != nil && !errors.Is(err, os.ErrNotExist) { // S3Store wraps it
+ return err
++ }
++ if err == nil {
++ if err := hostReportCollision(old, raw, time.Now()); err != nil {
++ return err
++ }
+ }
+ _, err = store.Put(ctx, key, raw, etag)
+ return err
+ }
++
++func hostReportCollision(old, raw []byte, now time.Time) error {
++ var prev, next hostactors.Report
++ if json.Unmarshal(old, &prev) != nil || json.Unmarshal(raw, &next) != nil {
++ return nil
++ }
++ if prev.ProcessID == "" || prev.ProcessID == next.ProcessID || prev.GeneratedAt.IsZero() {
++ return nil
++ }
++ age := now.Sub(prev.GeneratedAt)
++ if age > 15*time.Minute || age < -5*time.Second {
++ return nil
++ }
++ return fmt.Errorf("console report: process %s published this console's report at %s; another console process is using the same CARLOS_CONSOLE_ID", prev.ProcessID, prev.GeneratedAt.Format(time.RFC3339))
++}
+```
+
+#### Patch: `system.go`
+
+```diff
+--- a/system.go
++++ b/system.go
+@@ -49,7 +49,7 @@
+ // untouched in rest for runSystemAntivirusVerb to read.
+ // layout is a family too: migrate|revert|status stay in rest for
+ // runSystemLayout.
+- case "guard-arm", "guard-exec", "update", "status", "rollback", "apply", "antivirus", "layout", "geoip", "help", "-h", "--help":
++ case "guard-arm", "guard-exec", "update", "status", "rollback", "cancel-activation", "apply", "antivirus", "layout", "geoip", "help", "-h", "--help":
+ return args[0], args[1:], component
+ }
+ return "", args, component
+@@ -71,6 +71,8 @@
+ runSystemStatus(rest)
+ case "rollback":
+ runSystemRollback(rest)
++ case "cancel-activation":
++ runSystemCancelActivation(rest)
+ case "antivirus":
+ runSystemAntivirusVerb(rest)
+ case "layout":
+@@ -115,6 +117,9 @@
+ update promote a platform release to a channel; every box on it rolls itself
+ status every box: platform, channel, running build, channel target, last apply outcome
+ rollback point a channel back at the release before this one
++ cancel-activation
++ release a channel's activation reservation that was never
++ published, so promote and rollback work again
+ apply (root oneshot behind carlos-system-update.service; not for operators)
+ layout migrate|revert|status: move every instance into its own directory
+ and namespace, or back; see carlos system layout help
+```
+
+#### New file: `system_activation.go`
+
+```go
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+)
+
+// ---- carlos system cancel-activation -----------------------------------
+
+// runSystemCancelActivation releases an executable activation reservation
+// that was never published (claim plan 9, I-4). While one is held, every
+// other promote and every rollback of its channel is refused, and no box can
+// complete it because no box ever saw it. The console fences the pointer
+// before it removes the reservation, and refuses one that was published:
+// that one is released when its boxes report completion.
+func runSystemCancelActivation(args []string) {
+ fs := flag.NewFlagSet("system cancel-activation", flag.ExitOnError)
+ consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)")
+ channel := fs.String("channel", "", "canary or stable (required)")
+ activation := fs.String("activation", "", "the activation ID you expect; refused if a different one is held")
+ yes := fs.Bool("yes", false, "do not ask")
+ fs.Usage = flagUsage(fs, "")
+ fs.Parse(args)
+ if *channel == "" {
+ log.Fatal("--channel is required")
+ }
+ sess := requireDeployAPISession(*consoleFlag)
+ if !*yes {
+ if !interactiveTerminal() {
+ log.Fatal("no terminal to confirm on; pass --yes")
+ }
+ if !promptYes(stdout, fmt.Sprintf("cancel the unpublished activation held for %s? no box changes release [y/N] ", *channel)) {
+ return
+ }
+ }
+ body := map[string]string{"channel": *channel}
+ if *activation != "" {
+ body["activation"] = *activation
+ }
+ st, raw, err := sess.postJSON(context.Background(), "/api/cli/system/activation/cancel", body)
+ if err != nil {
+ log.Fatalf("carlos system cancel-activation: %v", err)
+ }
+ if st != http.StatusOK {
+ log.Fatalf("carlos system cancel-activation: %s", apiErrorFromBody(st, raw))
+ }
+ var out struct {
+ Activation string `json:"activation"`
+ }
+ _ = json.Unmarshal(raw, &out)
+ fmt.Fprintf(stdout, "%s %s on %s\n", colorFor(stdout).ok("cancelled activation"), out.Activation, *channel)
+}
+```
+
+#### Patch: `docs/site/cli.md`
+
+```diff
+--- a/docs/site/cli.md
++++ b/docs/site/cli.md
+@@ -1866,6 +1866,21 @@
+ carlos system rollback --channel stable
+ ```
+
++#### Cancel an unpublished activation
++
++If an unpublished platform update holds its channel, an ops account owner or
++system operator can cancel that reservation:
++
++```sh
++carlos system cancel-activation --channel stable --activation ACTIVATION_ID
++```
++
++Use the activation ID from the refused update or rollback. The command confirms
++before cancelling; use `--yes` for an already reviewed automation. Cancellation
++prevents a delayed publication from taking effect. It does not change a box's
++release. A published activation stays held until its boxes complete or valid
++fencing evidence excludes them.
++
+ #### Instance layout
+
+ Every instance the activator runs used to live in one shared directory as one user, where it could read its neighbours' files. `layout migrate` moves them out: each instance gets its own directory and, from its next wake, its own mount and pid namespace, where it sees its own files and nothing else. An instance that is asleep is moved at once; one that is awake is left for up to an hour to go to sleep, then stopped briefly and moved. `--force` skips the hour. `--box` scopes the request to one box, so a fleet moves one box at a time with `carlos status --layout` showing each box's rows turn `nested`. Once a box's layout is `nested`, a newly provisioned instance on it starts out `nested`; it is never created in the shared directory first.
+```
+
+### Task A3: restrict fence object writers (M-2)
+
+Invariant: only the exact approved box roles can create `control/box-fences/*`; nobody can replace or delete fence evidence through either the current-object or versioned-object API.
+
+Main's module already denies unfenced overwrites and both `s3:DeleteObject` and `s3:DeleteObjectVersion` for this prefix. It does not restrict which principal can create the first record. Add the exact-role deny below, retaining the existing create-only and never-delete denies. An empty approved role set denies everyone; wildcard role ARNs are rejected. This variable is a deployment input, not inferred from `written_by` or a heartbeat.
+
+Add the complete native test file first. On main its exact-writer assertion/variable is absent. In an initialized module run `tofu test -filter=tests/fence_writers.tftest.hcl`, then apply the implementation and repeat that test plus `tests/evidence.tftest.hcl`. Authoring did not initialize providers or run those native tests; this remains an implementation check. Also inspect the missing restriction:
+
+```bash
+rg -n 'BoxRecordsAreCreateOnly|BoxRecordsAreNeverDeleted|OnlyBoxRolesWriteTerminationFences|box_fence_writer_role_arns' infra/modules/deployment-buckets
+```
+
+The last two identifiers are absent on main. That source check is a regression witness, not proof of the effective IAM contract. After the code below, run:
+
+```bash
+tofu fmt -check infra/modules/deployment-buckets
+```
+
+Authoring verified formatting only. Do not run `tofu apply` during implementation-plan authoring. Before arming, Paul must approve the exact deployment role inventory and resource-policy change. Wire the approved roles in the private deployment root, review the read plan and test the effective policy in the designated sandbox: an approved box role can create a new exact enrollment fence only with `If-None-Match: *`; a console with distinct credentials, operator and unrelated role cannot create one; no principal can overwrite, delete or delete a version. Include role-session credentials and multipart completion. Preserve the existing IAM grants for box evidence reads and narrowly scoped region describes; this deny adds no permission. Review lifecycle expiry and alternate administrator paths so no deletion mechanism silently defeats the retained-evidence requirement. No live IAM proof or deployment change was performed here.
+
+Private deployment roots must consume this module at the certified release tag and supply the new variable before the policy is applied. Do not land an empty default into an active fence-producing deployment without that migration. The exact role ARNs, account and region are a Paul deployment-scope decision, not values this generic plan can invent.
+
+**Decision for Paul: credential separation.** Main's console and agent can share a host role, and remote hosts can use IAM users. A bucket policy sees the principal, not the process. Merely naming that shared host role cannot keep its console from writing a fence. Choose dedicated box-writer roles whose credentials console/tenant processes cannot obtain, including a remote-host migration, or specify a different authenticated fence-signing authority and reader contract. The patch is a role-level policy template pending that deployment decision. Do not claim M-2's effective permission property closed until distinct credentials and real denied-operation witnesses prove it. The empty default is fail-closed, not permission to disrupt existing writers.
+
+
+Tests first.
+
+#### New file: `infra/modules/deployment-buckets/tests/fence_writers.tftest.hcl`
+
+```hcl
+mock_provider "aws" {}
+
+override_resource {
+ target = aws_s3_bucket.deployment
+ values = { arn = "arn:aws:s3:::dep" }
+}
+
+variables {
+ deployment_bucket_name = "dep"
+ data_bucket_name = "data"
+}
+
+run "only_exact_box_roles_create_fences" {
+ command = plan
+ variables {
+ box_fence_writer_role_arns = ["arn:aws:iam::123456789012:role/box-worker"]
+ }
+ assert {
+ condition = jsonencode([
+ for s in jsondecode(aws_s3_bucket_policy.deployment_box_records.policy).Statement : s if s.Sid == "OnlyBoxRolesWriteTerminationFences"
+ ]) == jsonencode([{
+ Sid = "OnlyBoxRolesWriteTerminationFences"
+ Effect = "Deny"
+ Principal = "*"
+ Action = "s3:PutObject"
+ Resource = "arn:aws:s3:::dep/control/box-fences/*"
+ Condition = { ArnNotEquals = { "aws:PrincipalArn" = ["arn:aws:iam::123456789012:role/box-worker"] } }
+ }])
+ error_message = "Only exact approved box roles may create termination fences."
+ }
+}
+
+run "empty_box_role_inventory_denies_everyone" {
+ command = plan
+ assert {
+ condition = jsonencode([
+ for s in jsondecode(aws_s3_bucket_policy.deployment_box_records.policy).Statement : s.Condition.ArnNotEquals["aws:PrincipalArn"] if s.Sid == "OnlyBoxRolesWriteTerminationFences"
+ ]) == jsonencode([["arn:aws:iam::000000000000:role/nobody"]])
+ error_message = "An empty box-role inventory must grant no fence writer."
+ }
+}
+
+run "wildcard_writer_roles_are_refused" {
+ command = plan
+ variables {
+ box_fence_writer_role_arns = ["arn:aws:iam::123456789012:role/*"]
+ }
+ expect_failures = [var.box_fence_writer_role_arns]
+}
+```
+
+
+Implementation.
+
+#### Patch: `infra/modules/deployment-buckets/main.tf`
+
+```diff
+--- a/infra/modules/deployment-buckets/main.tf
++++ b/infra/modules/deployment-buckets/main.tf
+@@ -129,6 +129,18 @@
+ Resource = [for p in local.box_evidence_prefixes : "${aws_s3_bucket.deployment.arn}/${p}/*"]
+ },
+ {
++ Sid = "OnlyBoxRolesWriteTerminationFences"
++ Effect = "Deny"
++ Principal = "*"
++ Action = "s3:PutObject"
++ Resource = "${aws_s3_bucket.deployment.arn}/control/box-fences/*"
++ Condition = {
++ ArnNotEquals = {
++ "aws:PrincipalArn" = length(var.box_fence_writer_role_arns) > 0 ? var.box_fence_writer_role_arns : ["arn:aws:iam::000000000000:role/nobody"]
++ }
++ }
++ },
++ {
+ # With no operators named, the list is one ARN nobody has, so the
+ # Deny covers everyone.
+ Sid = "OnlyOperatorsWriteBinds"
+```
+
+#### Patch: `infra/modules/deployment-buckets/variables.tf`
+
+```diff
+--- a/infra/modules/deployment-buckets/variables.tf
++++ b/infra/modules/deployment-buckets/variables.tf
+@@ -13,3 +13,17 @@
+ type = list(string)
+ default = []
+ }
++
++variable "box_fence_writer_role_arns" {
++ description = "Exact enrolled box role ARNs allowed to create termination fences. Empty refuses every writer."
++ type = list(string)
++ default = []
++
++ validation {
++ condition = alltrue([
++ for arn in var.box_fence_writer_role_arns :
++ can(regex("^arn:[a-z0-9-]+:iam::[0-9]{12}:role/[^*?]+$", arn))
++ ])
++ error_message = "Fence writers must be exact IAM role ARNs without wildcards."
++ }
++}
+```
+
+
+## Group B: activation blockers
+
+### Task B1: initialize every registry connection (6b-N1)
+
+Invariant: replacing a poisoned claim transaction connection preserves the registry's busy timeout, WAL mode and synchronous NORMAL setting. The first connection and ordinary flag-off behavior retain main's pragma values.
+
+Add the tests first. `TestClaimTransactionReplacementConnectionKeepsOpenPragmas` ends a transaction behind its wrapper, making ROLLBACK fail and discard the connection. A connection-local cache marker proves replacement happened. Main then yields SQLite defaults instead of busy_timeout 5000 and synchronous NORMAL. Install a private SQLite driver with a connection hook and use it through `sql.OpenDB`; each Connect runs the same pragmas and closes on error. Keep schema and migration ordering and the existing one-connection limit.
+
+```bash
+nice -n 10 go test -p 1 ./internal/registry -run 'TestClaimTransactionReplacementConnectionKeepsOpenPragmas|TestOpenPragmasUnchangedOnFirstConnection|TestClaimTransaction' -count=1
+```
+
+The lazy journal namespace problem is separate and remains B7; registry pragma initialization is not a journal-race fix.
+
+
+Tests first.
+
+#### New file: `internal/registry/plan9_reconnect_pragmas_test.go`
+
+```go
+package registry
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+)
+
+// 6b-N1: a claim transaction whose ROLLBACK (or NORMAL restore) fails
+// discards its pooled connection. database/sql then opens a replacement, and
+// that replacement must carry Open's pragmas: busy_timeout 5000, WAL and
+// synchronous NORMAL, not SQLite's defaults (busy_timeout 0, synchronous
+// FULL).
+func TestClaimTransactionReplacementConnectionKeepsOpenPragmas(t *testing.T) {
+ r, err := Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.Close()
+ ctx := context.Background()
+ // A connection-local marker: it survives only while the pool keeps the
+ // same connection, so its loss proves the replacement happened.
+ if _, err := r.sql.Exec("PRAGMA cache_size=-1234"); err != nil {
+ t.Fatal(err)
+ }
+ tx, err := r.beginClaimTransaction(ctx, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // End the transaction behind the wrapper so its ROLLBACK fails and the
+ // connection is discarded with driver.ErrBadConn.
+ if _, err := tx.ExecContext(ctx, "COMMIT"); err != nil {
+ t.Fatal(err)
+ }
+ if err := tx.Rollback(); err == nil {
+ t.Fatal("ROLLBACK after COMMIT succeeded; the discard path was not exercised")
+ }
+ var cache, busy, sync int
+ var mode string
+ if err := r.sql.QueryRow("PRAGMA cache_size").Scan(&cache); err != nil {
+ t.Fatal(err)
+ }
+ if cache == -1234 {
+ t.Fatal("the failed claim transaction's connection was kept; expected a replacement")
+ }
+ if err := r.sql.QueryRow("PRAGMA busy_timeout").Scan(&busy); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.sql.QueryRow("PRAGMA synchronous").Scan(&sync); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.sql.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil {
+ t.Fatal(err)
+ }
+ if busy != 5000 || sync != 1 || mode != "wal" {
+ t.Fatalf("replacement connection pragmas: busy_timeout=%d synchronous=%d journal_mode=%s, want 5000, 1 (NORMAL), wal", busy, sync, mode)
+ }
+}
+
+// The first connection keeps exactly main's pragmas.
+func TestOpenPragmasUnchangedOnFirstConnection(t *testing.T) {
+ r, err := Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.Close()
+ var busy, sync int
+ var mode string
+ if err := r.sql.QueryRow("PRAGMA busy_timeout").Scan(&busy); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.sql.QueryRow("PRAGMA synchronous").Scan(&sync); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.sql.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil {
+ t.Fatal(err)
+ }
+ if busy != 5000 || sync != 1 || mode != "wal" {
+ t.Fatalf("first connection pragmas: busy_timeout=%d synchronous=%d journal_mode=%s", busy, sync, mode)
+ }
+}
+```
+
+
+Implementation.
+
+#### Patch: `internal/registry/registry.go`
+
+```diff
+--- a/internal/registry/registry.go
++++ b/internal/registry/registry.go
+@@ -13,6 +13,7 @@
+ "context"
+ "crypto/sha256"
+ "database/sql"
++ "database/sql/driver"
+ "errors"
+ "fmt"
+ "os"
+@@ -20,7 +21,7 @@
+ "strings"
+ "time"
+
+- _ "modernc.org/sqlite"
++ "modernc.org/sqlite"
+ )
+
+ type Registry struct {
+@@ -434,24 +435,48 @@
+ return false
+ }
+
++// registryPragmas run on every connection the pool opens, not once on the
++// first. busy_timeout FIRST: two processes open this DB concurrently (the
++// edge starting while `carlos add` runs), and journal_mode=WAL takes a lock \u2014
++// with no timeout set yet it fails immediately with SQLITE_BUSY. A claim
++// transaction discards its connection when ROLLBACK or the NORMAL restore
++// fails (6b-N1); the replacement must not run with SQLite's defaults
++// (busy_timeout 0, synchronous FULL).
++var registryPragmas = []string{
++ "PRAGMA busy_timeout=5000",
++ "PRAGMA journal_mode=WAL",
++ "PRAGMA synchronous=NORMAL",
++}
++
++func applyRegistryPragmas(conn sqlite.ExecQuerierContext, _ string) error {
++ for _, p := range registryPragmas {
++ if _, err := conn.ExecContext(context.Background(), p, nil); err != nil {
++ return err
++ }
++ }
++ return nil
++}
++
++// registryConnector opens every pooled connection through a driver whose
++// connection hook applies registryPragmas.
++type registryConnector struct {
++ d *sqlite.Driver
++ path string
++}
++
++func (c registryConnector) Connect(context.Context) (driver.Conn, error) { return c.d.Open(c.path) }
++func (c registryConnector) Driver() driver.Driver { return c.d }
++
+ func Open(path string) (*Registry, error) {
+- d, err := sql.Open("sqlite", path)
+- if err != nil {
+- return nil, err
+- }
++ drv := &sqlite.Driver{}
++ drv.RegisterConnectionHook(applyRegistryPragmas)
++ d := sql.OpenDB(registryConnector{d: drv, path: path})
+ // One writer at a time keeps WAL happy.
+ d.SetMaxOpenConns(1)
+- // busy_timeout FIRST: two processes open this DB concurrently (the edge
+- // starting while `carlos add` runs), and journal_mode=WAL takes a lock \u2014
+- // with no timeout set yet it fails immediately with SQLITE_BUSY.
+- for _, p := range []string{
+- "PRAGMA busy_timeout=5000",
+- "PRAGMA journal_mode=WAL",
+- "PRAGMA synchronous=NORMAL",
+- } {
+- if _, err := d.Exec(p); err != nil {
+- return nil, err
+- }
++ // Open the first connection now, so a pragma failure is Open's error as
++ // it was when the pragmas ran here.
++ if err := d.Ping(); err != nil {
++ return nil, err
+ }
+ r := &Registry{sql: d, path: path}
+ if err := r.migrate(); err != nil {
+```
+
+### Task B2: preserve an enabled root unit when host.env cannot be parsed (6b-N2)
+
+Invariant: unrelated host.env syntax cannot silently remove claim evidence/root-control wiring from a box whose installed control unit is enabled. Flag-off unit bytes and the successful-parse path remain main's behavior.
+
+Add the three apply/bootstrap cases and the adopt repair case first. A parse error with an already installed unit carrying `EnvironmentFile=-/etc/carlos/host.env` preserves that file. Do not rerender it from a guessed false flag, restart it, or swallow other write/read errors. Apply the same narrow fix to system apply, bootstrap and adopt's `ensureSystemUnits`: the latter had the same rewrite path on actual main even though the review expected it already to preserve the file.
+
+```bash
+nice -n 10 go test -p 1 ./internal/host -run 'TestPlan9A(ApplyKeeps|BootstrapKeeps|FlagOffControl|AdoptKeeps)|TestClaimControl|TestBootstrap' -count=1
+```
+
+The systemapply patch follows A1's rollback hunks exactly. No unit string changes in the flag-off render are included.
+
+
+Tests first.
+
+#### New file: `internal/host/plan9_a_hostenv_test.go`
+
+```go
+package host
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// plan9aOddHostEnv enables the claim but carries an unrelated line that
+// readClaimUnitSettings cannot parse.
+var plan9aOddHostEnv = []string{
+ "CARLOS_HOST_CLAIM=1\nCARLOS_OTHER=\"unterminated\n",
+ "CARLOS_HOST_CLAIM=1\nCARLOS_OTHER=a\\\n",
+ "CARLOS_HOST_CLAIM='1\n",
+}
+
+func plan9aEnabledControl(t *testing.T) []byte {
+ t.Helper()
+ legacy, err := unitsFS.ReadFile("units/" + claimControlUnit)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return claimUnitContent(claimControlUnit, legacy, claimUnitSettings{enabled: true, data: "/data", opt: "/opt/carlos"})
+}
+
+func plan9aWriteFile(t *testing.T, path string, body []byte) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, body, 0644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// 6b-N2: system apply on an enabled box leaves the installed control unit
+// alone, unrestarted, when host.env cannot be parsed.
+func TestPlan9AApplyKeepsEnabledControlUnitOnHostEnvError(t *testing.T) {
+ for _, env := range plan9aOddHostEnv {
+ t.Run(env, func(t *testing.T) {
+ f := newApplyFixture(t, "stable", "v1.0.0")
+ plan9aWriteFile(t, filepath.Join(f.a.etcDir, "carlos", "host.env"), []byte(env))
+ if _, err := readClaimUnitSettings(filepath.Join(f.a.etcDir, "carlos", "host.env")); err == nil {
+ t.Fatal("precondition: host.env parsed")
+ }
+ installed := plan9aEnabledControl(t)
+ live := filepath.Join(f.a.etcDir, "systemd", "system", claimControlUnit)
+ plan9aWriteFile(t, live, installed)
+ stage := t.TempDir()
+ if err := bootstrap(stage, nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.a.installUnits(t.Context(), stage, nil); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := os.ReadFile(live); !bytes.Equal(got, installed) {
+ t.Fatalf("enabled control unit flipped:\n%s", got)
+ }
+ if f.called("systemctl try-restart " + claimControlUnit) {
+ t.Fatal("control unit restarted")
+ }
+ })
+ }
+}
+
+// 6b-N2: the live bootstrap likewise keeps an enabled control unit.
+func TestPlan9ABootstrapKeepsEnabledControlUnitOnHostEnvError(t *testing.T) {
+ for _, env := range plan9aOddHostEnv {
+ t.Run(env, func(t *testing.T) {
+ root := t.TempDir()
+ plan9aWriteFile(t, filepath.Join(root, "etc", "carlos", "host.env"), []byte(env))
+ installed := plan9aEnabledControl(t)
+ live := filepath.Join(root, "etc", "systemd", "system", claimControlUnit)
+ plan9aWriteFile(t, live, installed)
+ if err := bootstrap(root, nil); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := os.ReadFile(live); !bytes.Equal(got, installed) {
+ t.Fatalf("enabled control unit flipped:\n%s", got)
+ }
+ })
+ }
+}
+
+// Flag-off stays main's: an installed unit without the claim environment
+// is rendered from the template exactly as before, whatever host.env holds.
+func TestPlan9AFlagOffControlUnitUnchangedOnHostEnvError(t *testing.T) {
+ legacy, err := unitsFS.ReadFile("units/" + claimControlUnit)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for name, installed := range map[string][]byte{"absent": nil, "template": legacy, "older": []byte("[Unit]\nDescription=older template\n")} {
+ t.Run(name, func(t *testing.T) {
+ f := newApplyFixture(t, "stable", "v1.0.0")
+ plan9aWriteFile(t, filepath.Join(f.a.etcDir, "carlos", "host.env"), []byte(plan9aOddHostEnv[0]))
+ live := filepath.Join(f.a.etcDir, "systemd", "system", claimControlUnit)
+ if installed != nil {
+ plan9aWriteFile(t, live, installed)
+ }
+ stage := t.TempDir()
+ if err := bootstrap(stage, nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.a.installUnits(t.Context(), stage, nil); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := os.ReadFile(live); !bytes.Equal(got, legacy) {
+ t.Fatal("flag-off apply did not render main's unit")
+ }
+ root := t.TempDir()
+ plan9aWriteFile(t, filepath.Join(root, "etc", "carlos", "host.env"), []byte(plan9aOddHostEnv[0]))
+ rootLive := filepath.Join(root, "etc", "systemd", "system", claimControlUnit)
+ if installed != nil {
+ plan9aWriteFile(t, rootLive, installed)
+ }
+ if err := bootstrap(root, nil); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := os.ReadFile(rootLive); !bytes.Equal(got, legacy) {
+ t.Fatal("flag-off bootstrap did not render main's unit")
+ }
+ })
+ }
+}
+```
+
+#### New file: `internal/host/plan9_a_adopt_test.go`
+
+```go
+package host
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// 6b-N2, adopt's half: repairing the system units keeps an enabled control
+// unit when host.env cannot be parsed. A flag-off one is still restored to
+// main's bytes (TestClaimBadUnitSettingsStillRepairSystemUnits).
+func TestPlan9AAdoptKeepsEnabledControlUnitOnHostEnvError(t *testing.T) {
+ for _, env := range plan9aOddHostEnv {
+ t.Run(env, func(t *testing.T) {
+ etc := t.TempDir()
+ plan9aWriteFile(t, filepath.Join(etc, "carlos", "host.env"), []byte(env))
+ installed := plan9aEnabledControl(t)
+ live := filepath.Join(etc, "systemd", "system", claimControlUnit)
+ plan9aWriteFile(t, live, installed)
+ ctl := func(args ...string) (string, error) { return "", nil }
+ probe := func(args ...string) (string, error) { return "inactive\n", nil }
+ ensureSystemUnits(etc, filepath.Join(t.TempDir(), "lock"), ctl, probe, t.Logf)
+ if got, _ := os.ReadFile(live); !bytes.Equal(got, installed) {
+ t.Fatalf("adopt flipped the enabled control unit:\n%s", got)
+ }
+ for _, name := range systemUnitFiles {
+ if _, err := os.Stat(filepath.Join(etc, "systemd", "system", name)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ })
+ }
+}
+```
+
+
+Implementation.
+
+#### Patch: `internal/host/bootstrap.go`
+
+```diff
+--- a/internal/host/bootstrap.go
++++ b/internal/host/bootstrap.go
+@@ -12,6 +12,7 @@
+ import (
+ "github.com/carlosframework/platform/internal/cliflag"
+
++ "bytes"
+ "embed"
+ "encoding/json"
+ "errors"
+@@ -372,15 +373,20 @@
+ if live {
+ hostEnv = claimBootstrapHostEnv
+ }
+- claimSettings, err := readClaimUnitSettings(hostEnv)
+- if err != nil {
+- fmt.Fprintf(os.Stderr, "bootstrap: instance-control unit settings: %v\n", err)
++ claimSettings, settingsErr := readClaimUnitSettings(hostEnv)
++ if settingsErr != nil {
++ fmt.Fprintf(os.Stderr, "bootstrap: instance-control unit settings: %v\n", settingsErr)
+ claimSettings = claimUnitSettings{}
+ }
+ beforeControl, err := os.ReadFile(filepath.Join(root, "etc", "systemd", "system", claimControlUnit))
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
++ // A host.env this build cannot parse says nothing about the claim
++ // (6b-N2). A control unit already rendered for an enabled box stays as
++ // it is, unrestarted, as adopt leaves it; a flag-off one is rendered as
++ // main renders it.
++ keepControl := settingsErr != nil && bytes.Contains(beforeControl, []byte(claimControlEnvironment))
+ var afterControl []byte
+ out := io.Writer(os.Stdout)
+ if planJSON {
+@@ -518,10 +524,15 @@
+ if err != nil {
+ return fmt.Errorf("read embedded unit %s: %v", name, err)
+ }
++ path := filepath.Join(unitDir, name)
+ if name == claimControlUnit {
++ if keepControl {
++ afterControl = beforeControl
++ fmt.Fprintf(out, " kept %s (host.env cannot be parsed)\n", path)
++ continue
++ }
+ afterControl = claimUnitContent(name, content, claimSettings)
+ }
+- path := filepath.Join(unitDir, name)
+ if err := os.WriteFile(path, claimUnitContent(name, content, claimSettings), 0o644); err != nil {
+ return fmt.Errorf("write %s: %v", path, err)
+ }
+```
+
+#### Patch: `internal/host/systemunits.go`
+
+```diff
+--- a/internal/host/systemunits.go
++++ b/internal/host/systemunits.go
+@@ -117,6 +117,11 @@
+ return
+ }
+ for _, u := range systemUnitFiles {
++ // A host.env this pass cannot parse says nothing about the claim
++ // (6b-N2): a control unit rendered for an enabled box stays as it is.
++ if u == claimControlUnit && settingsErr != nil && bytes.Contains(installed, []byte(claimControlEnvironment)) {
++ continue
++ }
+ b, err := unitsFS.ReadFile("units/" + u)
+ if err != nil {
+ logf("adopt: system units: %v", err)
+```
+
+#### Patch: `internal/host/systemapply.go`
+
+```diff
+--- a/internal/host/systemapply.go
++++ b/internal/host/systemapply.go
+@@ -1192,6 +1192,11 @@
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
++ // A host.env this build cannot parse says nothing about the claim
++ // (6b-N2). A control unit already rendered for an enabled box stays as
++ // it is, unrestarted, as adopt leaves it; a flag-off one is rendered as
++ // main renders it.
++ keepControl := settingsErr != nil && bytes.Contains(beforeControl, []byte(claimControlEnvironment))
+ entries, err := os.ReadDir(filepath.Join(stagedRoot, "etc", "systemd", "system"))
+ if err != nil {
+ return err
+@@ -1201,6 +1206,10 @@
+ continue
+ }
+ if e.Name() == claimControlUnit {
++ if keepControl {
++ a.ops.logf("system apply: %s kept as installed: host.env cannot be parsed", claimControlUnit)
++ continue
++ }
+ b, err := os.ReadFile(filepath.Join(stagedRoot, "etc", "systemd", "system", e.Name()))
+ if err != nil {
+ return err
+```
+
+### Task B3: narrow reset grants and finish the production authority audit (R2, R3, R4, 5-M10)
+
+Invariant: a wake-retry reset registration can authorize only its exact holder's reset-shaped transition; census authority comes from verified enrollment signatures; only root's instance-control process signs stop receipts. A missing production owner or remote stop adapter remains refusal.
+
+**R2, complete code below.** Add `plan9_c2_test.go` first. Main admits generation-only changes, stamp changes, retired-incarnation additions and a reset for another registered incarnation under the reset grant. The exact reset and non-reset registration compatibility cases already pass. Retain the registration kind and incarnation in the writer-issued StartHandle and lifecycle grant. Require the old/new claim bytes to differ only in generation and the holder's fresh open wake-retry episode: a new episode ID, nonempty reset request, no reserved attempts, no not-before time and no attempt. Other registration kinds and exclusive operation grants retain main's behavior. Admission liveness still gates every write. The comparison binds the entire claim, not just the obvious holder fields.
+
+```bash
+nice -n 10 go test -p 1 ./internal/hostops -run 'TestPlan9C2|TestC7|ClaimMove|WakeRetry|WakeReset|Lifecycle|Grant|TestReconcileFrozenBackfillRepairsLegacyHolder' -count=1
+nice -n 10 go test -p 1 ./internal/console -run 'WakeRetryReset|WakeReset' -count=1
+```
+
+**R4 is already asserted on main.** `ClaimOwnerBindings.claimEvidenceCoordinator` refuses an EvidenceKey for agent and adopt and gives them no signer. Only `instance-control` calls ConfigureStopEvidence with the enrolled private key. Stop publication itself refuses a missing full signer. Retain and run the existing assertion rather than duplicate it:
+
+```bash
+nice -n 10 go test -p 1 ./internal/host -run '^TestClaimEvidenceProviderConfiguresOnlyRootSigner$' -count=1
+```
+
+That test also refuses a wrong deployment, wrong enrollment, wrong store and unenrolled key. The activation topology must mount the private evidence key only in root instance-control; the owner provider must not hand it to agent/adopt. Root-process signing here uses the enrolled box evidence key, not permission to distribute the deployment release key. C3 must prove the real filesystem and process topology.
+
+**5-M10 is confirmed through its readers.** The classifier permits grantless create-only `control/host-census/*` reports but refuses overwrite/delete; the new pin test asserts this existing contract and is green on main. `ProductionAuthority.census` verifies each ed25519 signature against the signed roster's per-box CensusPublicKey and binds box, enrollment, reservation, roster revision, completeness, installation records and any manifest digest. `PublishBackfill` goes through that same reader. The existing forged-report case in `TestReconcileFrozenBackfillRepairsLegacyHolder` refuses even a forgery whose bytes are listed in the digest manifest. This satisfies the review's requested signature alternative; no fabricated census grant is added.
+
+Grantless creation can still squat a report key with garbage and stall that reservation. It cannot authorize retirement. A separate liveness improvement could introduce an enrollment/reservation-scoped writer grant, but needs an explicit box-side grant contract. No readiness claim here treats an invalid or missing report as empty.
+
+**R3 remains a decision-gated activation blocker, not a completed wiring patch.** The executable source is `cmd/console/main.go`. It never constructs cfg.WakeRetryReset. Its Claims handle is available and rebound by claim runtime; NewID is mechanical, but Authority and StopAttempt are not supplied by a complete production adapter. The only real stop implementation, ClaimAssembly.stopRetryAttempt, is box-local and uses the local controller socket. ClaimStopHTTP/CollectClaimStop are Unix-socket drain mechanisms, not a remote console-to-box API.
+
+The box binary separately has no concrete ClaimOwnerProvider and no production RegisterProductionClaimOwners caller. The interface needs RetirementAuthority, mode-scoped EvidenceKey, Identity, Local, Controllers, Data, Inputs, InputPolicy, MovesPolicy, Evidence, LeaseSet, View, Authorize, AdmissionEvidence, PublicationEvidence and Close. Passing nils, permissive callbacks or a raw deletion fallback would grant authority the spec does not define. A provider registered only in cmd/console would not wire the agent/adopt/root processes.
+
+**Paul decision, recommendation:** define a typed request/receipt path through the signed control plane. The console checks its own current active/unfenced enrollment and operator gate, registers the exact reset, writes a bounded idempotent stop request for the observed holder/episode/attempt, and waits for the owning box's root process to publish its enrolled-key stop evidence. Specify request schema, issuer authorization, incarnation/claim-ETag binding, expiry/uncertain outcome, replay, cancellation, response verification and recovery before writing the adapter. The existing bucket transport can carry this protocol, but the reset-specific request protocol does not exist yet. Keep same-app registration renewal and exact attempt reread/CAS. Never treat a lost response as proof the attempt stopped.
+
+Alternatives are a new authenticated Tailscale RPC with equivalent evidence, or an explicitly limited interim reset that only handles non-attempting episodes and refuses all live attempts. The latter does not complete R3's full reset contract. Do not give console root's signing key to avoid the transport decision.
+
+The owner implementation must supply the three named missing contract groups: Plan 8 local controller/deadline authority; Plan 8b signed roster/floor/quarantine authority; and moves inputs/publication/regional evidence. Register it once in each relevant box executable startup before constructing enabled assembly, with mode-specific key access and owned Close cleanup. Leave the flag-off branch untouched. The PreFlipGuard one-box-region condition in C3 belongs in this adapter contract too. Refresh this task with complete adapter and wiring code after the ruling, then verify a real console request stops the exact remote attempt and conditionally resets it without box commands. Also prove missing, fenced, stale and changed-attempt cases refuse before effects; do not count the current 503 as delivered functionality.
+
+Checks that establish the current gap:
+
+```bash
+rg -n 'WakeRetryReset|HostClaim' cmd/console/main.go internal/console/claim_runtime.go internal/console/hostwakereset.go
+rg -n 'RegisterProductionClaimOwners|OpenClaimOwners|ClaimOwnerProvider|stopRetryAttempt' --glob '*.go' .
+nice -n 10 go test -p 1 ./internal/host -run 'TestClaim.*(Missing|Owner|Assembly|Evidence)' -count=1
+```
+
+The exact reset regressions, existing real reset-door tests, root signer assertion and the entire hostops package passed in the prototype. No remote production reset or owner provider was implemented or claimed verified.
+
+
+Tests first.
+
+#### New file: `internal/hostops/plan9_c2_test.go`
+
+```go
+package hostops
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+)
+
+// wakeResetFixture builds a serving claim whose holder has an attempting
+// wake-retry episode, as a reset finds it after StopAttempt.
+func wakeResetFixture(t *testing.T) (*ControlWriter, hostclaim.Observed, func() time.Time) {
+ t.Helper()
+ w, raw, clock := testWriter(t)
+ ctx := t.Context()
+ seedAdmission(t, raw, "bab", "demo", openAdmission("app"))
+ host := "x.example"
+ nh := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/demo/instances/x.example.json", Account: "bab", App: "demo", Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: clock()}
+ record := []byte(`{"schema":1,"host":"x.example","account":"bab","app":"demo","region":"ie","created_at":"2026-10-07T12:00:00Z"}`)
+ o, err := hostclaim.Read(ctx, raw, host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tr := range []func(*hostclaim.Claim) error{
+ hostclaim.BeginCreate(nh, record, "create", "console", clock()),
+ hostclaim.CompleteCreate("create", "box"),
+ } {
+ if o, err = hostclaim.Update(ctx, raw, o, tr); err != nil {
+ t.Fatal(err)
+ }
+ }
+ attempt := hostclaim.WakeRetryAttempt{ID: "a1", Box: "box", Enrollment: "enr", BootID: "boot", ControllerID: "ctl", InvocationID: "inv", LaunchInputs: "in"}
+ if o, err = hostclaim.Update(ctx, raw, o, hostclaim.ReserveWakeRetryAttempt(hostclaim.WakeRetryReservation{HolderIncarnation: o.Claim.Holder.Incarnation, EpisodeID: "ep1", Attempt: attempt, Now: clock()})); err != nil {
+ t.Fatal(err)
+ }
+ return w, o, clock
+}
+
+// claimStep returns the exact before and after bytes of one transition on
+// o, as hostclaim.Update would write them.
+func claimStep(t *testing.T, o hostclaim.Observed, tr func(*hostclaim.Claim) error) MutationTarget {
+ t.Helper()
+ before, err := json.Marshal(o.Claim)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var next hostclaim.Claim
+ if err := json.Unmarshal(before, &next); err != nil {
+ t.Fatal(err)
+ }
+ if err := tr(&next); err != nil {
+ t.Fatal(err)
+ }
+ next.Gen++
+ after, err := json.Marshal(&next)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return MutationTarget{Method: "Put", Key: "control/hosts/" + o.Host + ".json", Before: before, After: after}
+}
+
+func registerFor(t *testing.T, w *ControlWriter, o hostclaim.Observed, id, kind, incarnation string, clock func() time.Time) context.Context {
+ t.Helper()
+ h, err := w.RegisterStart(t.Context(), "bab", "demo", "app", id, Registration{Kind: kind, Host: o.Host, Owner: testOwner, AppID: "app", Epoch: 1, Claim: SavedObservation(o), Incarnation: incarnation, CreatedAt: clock()}, clock())
+ if err != nil {
+ t.Fatal(err)
+ }
+ return h.Lifecycle(t.Context())
+}
+
+func resetStep(incarnation, observed, episode, request string) func(*hostclaim.Claim) error {
+ return hostclaim.ResetWakeRetry(hostclaim.WakeRetryReset{HolderIncarnation: incarnation, ObservedEpisode: observed, NewEpisodeID: episode, OperatorRequest: request, AttemptSettled: true})
+}
+
+// R2: a reset-wake-retry registration's lifecycle grant admits only the
+// exact ResetWakeRetry transition for its registered holder incarnation.
+// Any other same-app claim write under it is refused.
+func TestPlan9C2ResetGrantAdmitsOnlyResetWakeRetry(t *testing.T) {
+ w, o, clock := wakeResetFixture(t)
+ inc := o.Claim.Holder.Incarnation
+ ctx := registerFor(t, w, o, "reset", RegistrationWakeRetryReset, inc, clock)
+ cases := []struct {
+ name string
+ tr func(*hostclaim.Claim) error
+ admit bool
+ }{
+ {"exact reset", resetStep(inc, "ep1", "ep2", "req"), true},
+ {"generation only", func(*hostclaim.Claim) error { return nil }, false},
+ {"stamp bump", func(c *hostclaim.Claim) error { c.Holder.Stamp++; return nil }, false},
+ {"reset plus stamp", func(c *hostclaim.Claim) error {
+ if err := resetStep(inc, "ep1", "ep2", "req")(c); err != nil {
+ return err
+ }
+ c.Holder.Stamp++
+ return nil
+ }, false},
+ {"reset plus retired incarnation", func(c *hostclaim.Claim) error {
+ if err := resetStep(inc, "ep1", "ep2", "req")(c); err != nil {
+ return err
+ }
+ c.RetiredIncarnations = append(c.RetiredIncarnations, "old")
+ return nil
+ }, false},
+ {"drop wake retry", func(c *hostclaim.Claim) error { c.Holder.WakeRetry = nil; return nil }, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ err := authorizeClaimWrite(ctx, w.raw, o.Host, claimStep(t, o, tc.tr))
+ if tc.admit && err != nil {
+ t.Fatalf("exact reset refused: %v", err)
+ }
+ if !tc.admit && !errors.Is(err, ErrPending) {
+ t.Fatalf("reset grant admitted %s: %v", tc.name, err)
+ }
+ })
+ }
+}
+
+// R2: the reset grant is bound to the incarnation the registration saved,
+// not to whichever holder the claim names at write time.
+func TestPlan9C2ResetGrantRefusesOtherIncarnation(t *testing.T) {
+ w, o, clock := wakeResetFixture(t)
+ ctx := registerFor(t, w, o, "reset", RegistrationWakeRetryReset, "some-other-incarnation", clock)
+ err := authorizeClaimWrite(ctx, w.raw, o.Host, claimStep(t, o, resetStep(o.Claim.Holder.Incarnation, "ep1", "ep2", "req")))
+ if !errors.Is(err, ErrPending) {
+ t.Fatalf("reset grant admitted a reset for an unregistered incarnation: %v", err)
+ }
+}
+
+// R2 keeps every other registration kind's grant as main has it.
+func TestPlan9C2OtherKindsKeepSameAppGrant(t *testing.T) {
+ w, o, clock := wakeResetFixture(t)
+ ctx := registerFor(t, w, o, "create2", "create", o.Claim.Holder.Incarnation, clock)
+ if err := authorizeClaimWrite(ctx, w.raw, o.Host, claimStep(t, o, func(*hostclaim.Claim) error { return nil })); err != nil {
+ t.Fatalf("create grant lost its same-app write: %v", err)
+ }
+}
+
+// 5-M10: a host-census report needs no lifecycle grant, but only as a
+// create-only Put. Authenticity is the reader's: ProductionAuthority.census
+// verifies each report against the roster's per-box census key (see the
+// forged-report case in TestReconcileFrozenBackfillRepairsLegacyHolder).
+func TestPlan9C2CensusReportsAreCreateOnlyWithoutGrant(t *testing.T) {
+ w, raw, _ := testWriter(t)
+ key := "control/host-census/res/box.json"
+ for _, tc := range []struct {
+ name string
+ t MutationTarget
+ ok bool
+ }{
+ {"create", MutationTarget{Method: "Put", Key: key, After: []byte(`{}`)}, true},
+ {"overwrite", MutationTarget{Method: "Put", Key: key, ETag: "tag", After: []byte(`{}`)}, false},
+ {"delete", MutationTarget{Method: "Delete", Key: key, ETag: "tag"}, false},
+ } {
+ family, matched, err := lifecycleFamily(t.Context(), raw, w, tc.t)
+ if !matched || (tc.ok && (err != nil || family != "lifecycle-census")) || (!tc.ok && !errors.Is(err, ErrIdentity)) {
+ t.Fatalf("%s: %q %v %v", tc.name, family, matched, err)
+ }
+ }
+}
+```
+
+
+Implementation.
+
+#### Patch: `internal/hostops/control_writer.go`
+
+```diff
+--- a/internal/hostops/control_writer.go
++++ b/internal/hostops/control_writer.go
+@@ -159,6 +159,9 @@
+ account, app, appID, id string
+ owner RegistrationOwner
+ epoch uint64
++ // kind and incarnation are the registration's: a reset-wake-retry
++ // grant is bound to them (review R2).
++ kind, incarnation string
+ }
+
+ func (h *StartHandle) ID() string { return h.id }
+@@ -183,7 +186,7 @@
+ if _, err = w.updateAdmission(ctx, before, func(a *AppAdmission) error { return registerStart(a, id, r, now) }); err != nil {
+ return nil, err
+ }
+- return &StartHandle{w: w, account: account, app: app, appID: appID, id: id, owner: r.Owner, epoch: r.Epoch}, nil
++ return &StartHandle{w: w, account: account, app: app, appID: appID, id: id, owner: r.Owner, epoch: r.Epoch, kind: r.Kind, incarnation: r.Incarnation}, nil
+ }
+
+ // Bind and Renew retry a lost CAS: bindStart is idempotent for the same
+```
+
+#### Patch: `internal/hostops/lifecycle_grant.go`
+
+```diff
+--- a/internal/hostops/lifecycle_grant.go
++++ b/internal/hostops/lifecycle_grant.go
+@@ -38,13 +38,17 @@
+ owner RegistrationOwner
+ epoch uint64
+ w *ControlWriter
++ // registration and incarnation are a StartHandle's registration kind
++ // and saved holder incarnation. A reset-wake-retry grant admits only
++ // the exact ResetWakeRetry transition for that incarnation (review R2).
++ registration, incarnation string
+ }
+
+ // Lifecycle returns ctx carrying this registration's claim grant. The grant
+ // is checked against admission on every claim write: it dies with the
+ // registration.
+ func (h *StartHandle) Lifecycle(ctx context.Context) context.Context {
+- return withLifecycleGrant(ctx, lifecycleGrant{kind: "app", account: h.account, app: h.app, appID: h.appID, operation: h.id, owner: h.owner, epoch: h.epoch, w: h.w})
++ return withLifecycleGrant(ctx, lifecycleGrant{kind: "app", account: h.account, app: h.app, appID: h.appID, operation: h.id, owner: h.owner, epoch: h.epoch, w: h.w, registration: h.kind, incarnation: h.incarnation})
+ }
+
+ // Lifecycle returns ctx carrying this operation's claim grant. The grant is
+@@ -167,7 +171,7 @@
+ }
+ switch g.kind {
+ case "app":
+- if !appGrantAllows(g, old, next) {
++ if !appGrantAllows(g, old, next) || g.registration == RegistrationWakeRetryReset && !resetWakeRetryTransition(g.incarnation, old, next) {
+ continue
+ }
+ if live, err := grantLive(ctx, raw, g); err != nil {
+@@ -217,6 +221,31 @@
+ return sameApp(oh, g) || sameApp(nh, g) || opOf(old).ID == g.operation || opOf(next).ID == g.operation
+ }
+
++// resetWakeRetryTransition reports whether old->next is exactly what
++// hostclaim.ResetWakeRetry writes for the registered holder incarnation:
++// the same claim with gen advanced and the holder's wake-retry episode
++// replaced by a fresh open episode that carries the operator request.
++func resetWakeRetryTransition(incarnation string, old, next *hostclaim.Claim) bool {
++ oh, nh := holderOf(old), holderOf(next)
++ if incarnation == "" || oh == nil || nh == nil || oh.Incarnation != incarnation || oh.WakeRetry == nil || nh.WakeRetry == nil {
++ return false
++ }
++ r := nh.WakeRetry
++ if r.EpisodeID == "" || r.EpisodeID == oh.WakeRetry.EpisodeID || r.State != "open" || r.ResetRequest == "" ||
++ r.AttemptsReserved != 0 || !r.NotBefore.IsZero() || r.Attempt != nil {
++ return false
++ }
++ want, holder := *old, *oh
++ holder.WakeRetry = r
++ want.Holder, want.Gen = &holder, next.Gen
++ a, err := json.Marshal(&want)
++ if err != nil {
++ return false
++ }
++ b, err := json.Marshal(next)
++ return err == nil && bytes.Equal(a, b)
++}
++
+ func recoveryGrantAllows(ctx context.Context, raw release.Store, w *ControlWriter, host string, old, next *hostclaim.Claim) (bool, error) {
+ oh, nh := holderOf(old), holderOf(next)
+ if old != nil {
+```
+
+### Task B4: decide and specify SQL transaction authority (5-I3)
+
+Invariant: one admitted ingest batch may write its exact app incarnation while admission drains; a different incarnation, expired issuer or settled effect cannot write. Network authority checks never hold the console SQLite connection, and one transaction does not perform a remote check per statement.
+
+This is a Paul decision, not a mechanical cache. On `76f728a9`, `withAutoAppSQL` calls the exclusive `RunAppMutation` runner, `permitGrant` calls `RequireAppMutation`, and `claimBinding.authorize` invokes that callback before writes and `claimTx.Commit`. `RequireAppMutation` re-reads S3 admission. Removing those reads while retaining only their result would change authority: `AppMutationPermit.valid` currently requires the live registration and issued effect. A positive result at BEGIN is not authority for an arbitrarily delayed COMMIT.
+
+Choose among these options:
+
+1. Recommended: shared ingest registrations plus a durable, transaction-scoped SQL effect. Register one ingest batch with the actual console/enrollment/controller and app ID/epoch before acquiring SQL. Do not invent a host or saved claim to fit the existing host-start registration schema. Define an explicit ingest registration kind, its validation, drain and recovery rules. Prepare one permit per `(account, app, app_id, epoch, operation, effect, mode)` before taking a connection. Its local check validates the immutable scope, request cancellation, deadline and an unrevoked issued-effect handle before each write and commit, without S3. All statements in that transaction share the prepared permit; do not cache across callbacks, transactions or incarnations. Close the transaction and release the connection before settlement, renewal, marker/report reads or other S3 calls. Stop issuing new transactions on ambiguous renewal. A transaction already issued remains a drain obligation until positive settlement; expiry alone cannot clear it. Paul must approve that completion authority and the recovery proof, including a process stopped after its last local deadline check. An unjoined SQL effect stays uncertain, never treated as not-applied from a missing response.
+2. Keep the exclusive operation but prepare one transaction permit outside SQL. This fixes connection starvation and repeated reads while retaining serialization of all app ingest. It still requires a ruled in-flight completion contract and does not satisfy the shared-ingest part of 5-I3.
+3. Build a separate durable local command queue. Fetch authority, persist a command and issue SQL from a local worker. This adds a second recovery journal and requires a defined relationship to app admission; it is more work than option 1.
+
+Required decision record: registration wire shape; maximum transaction lifetime and clock source; authority for a transaction whose registration expires or admission closes; durable SQL receipt schema and location; proof required after console death; recovery ordering between SQL receipt and effect settlement; whether mail delivery's existing non-expiring operation remains distinct from ingest. Do not weaken the mail shared gate or let an ingest handle obtain a lifecycle grant. Every new strict-decoded wire field joins Group C's floor inventory.
+
+The complete acceptance tests below compile on the base and currently fail. They establish the connection and caching regressions, not the missing lifecycle proof. Do not include these decision tests in a landing branch until the chosen implementation makes them pass. Do not implement option 1 by changing `claimBinding.check` to an unconditional success callback.
+
+After Paul's ruling, extend the existing `admission.go`, `control_writer.go`, `effect_runner.go`, `mutation_permit.go`, `claim_sql_grants.go`, `store_gate.go`, `claim_sql_auto.go` and `claim_mail_sql.go` contracts. Audit every `WithAppMutation` callback, including restore/trash/purge projection grants. The callback-only rule must cover `check`, not merely the callback's body. Add failing tests for concurrent ingests, purge waiting for both, expiry/renewal loss, retained views and prepared statements, delayed COMMIT, console death before/after SQL commit, cross-app and recreated-app refusal, and no S3 call while either the SQL connection or transaction is held. Preserve all existing statement-trigger, cascade, mode and raw-write refusal tests. Refresh this task with complete implementation blocks and replay them before calling 5-I3 closed.
+
+Command, expected red on this plan's unruled prototype:
+
+```bash
+nice -n 10 go test -p 1 ./internal/console -run '^TestPlan9SQL' -count=1
+```
+
+Observed: the write-time authority callback sees one held connection; a transaction containing two updates performs four checks. This task remains an activation blocker and is not claimed green by the other focused tests.
+
+
+Tests first.
+
+#### New file: `internal/console/plan9_sql_decision_test.go`
+
+```go
+package console
+
+import (
+ "context"
+ "fmt"
+ "testing"
+)
+
+// These acceptance tests stay red until Paul selects the transaction contract.
+func TestPlan9SQLAuthorityChecksDoNotHoldConnection(t *testing.T) {
+ s := openGatedStore(t)
+ seedGateApp(t, s)
+ check := func(context.Context, claimSQLScope) error {
+ if n := s.rawPool.Stats().InUse; n != 0 {
+ return fmt.Errorf("authority read holds %d SQLite connections", n)
+ }
+ return nil
+ }
+ err := s.withClaimSQL(gateCtx(t), []claimSQLScope{gateScope}, check, func(v *Store) error {
+ _, err := v.db.Exec(`UPDATE audit SET action='checked' WHERE account_id=7 AND app='blog'`)
+ return err
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestPlan9SQLChecksOncePerTransaction(t *testing.T) {
+ s := openGatedStore(t)
+ seedGateApp(t, s)
+ checks := 0
+ err := s.withClaimSQL(gateCtx(t), []claimSQLScope{gateScope}, func(context.Context, claimSQLScope) error {
+ checks++
+ return nil
+ }, func(v *Store) error {
+ tx, err := v.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ for _, action := range []string{"first", "second"} {
+ if _, err := tx.Exec(`UPDATE audit SET action=? WHERE account_id=7 AND app='blog'`, action); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if checks != 1 {
+ t.Fatalf("remote checks=%d, want one preparation before acquiring connection", checks)
+ }
+}
+```
+
+### Task B5: keep claim responses appropriate to each door (5-M7)
+
+Invariant: an accepted browser operation renders HTML with its operation ID and phase, while API clients retain JSON; a dependency error cannot expose a bucket key, signed object, SQL error or backend credential. A 202 still means the request's own durable operation, never a foreign operation inferred from a claim.
+
+Add the response tests first. They initially fail to compile because the new response mapper and HTML renderer do not exist. Then apply the complete patches below. The domain attach, permanent app purge, app placement and fleet placement browser doors use the common escaped message template. API placement, create and delete keep their JSON response shapes. Instance/sidecar pending-list failures use the same stable error mapper; they no longer expose raw store errors. Unknown claim failures are 503 with stable public copy; pending/conflict failures are 409. Detailed errors stay in the existing server log. Preserve all authentication, CSRF, owner, freshness and durable-operation gates.
+
+The purge integration fixture now reads the operation from canonical admission and proves its HTML page names that same operation and phase; it still drives the real census/recovery/sweep. It no longer assumes that a browser response is JSON.
+
+```bash
+nice -n 10 go test -p 1 ./internal/console -run '^Test(Plan9ClaimResponse|Plan9BrowserClaim|AppDomain|ClaimPurgeDoorCompletes|ClaimDisabled|ClaimConsoleMutationInvariant)' -count=1
+```
+
+Authoring result before other task prototypes: passed, 200.875 seconds. The response-only tests also pass in the run where Task B4's two SQL acceptance tests fail as intended. No test was skipped to claim those SQL decisions resolved.
+
+
+Tests first.
+
+#### New file: `internal/console/plan9_response_test.go`
+
+```go
+package console
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostops"
+)
+
+func TestPlan9ClaimResponseDoesNotExposeInternalErrors(t *testing.T) {
+ for _, tc := range []struct {
+ err error
+ status int
+ }{
+ {fmt.Errorf("private-bucket/key: %w", hostops.ErrPending), http.StatusConflict},
+ {fmt.Errorf("private-bucket/key: %w", hostclaim.ErrConflict), http.StatusConflict},
+ {errors.New("private-bucket/key: secret backend failure"), http.StatusServiceUnavailable},
+ } {
+ ref := claimResponseRefusal(tc.err)
+ if ref.Status != tc.status || strings.Contains(ref.Msg, "private-bucket") || strings.Contains(ref.Msg, "secret") {
+ t.Fatalf("unsafe response: %+v", ref)
+ }
+ }
+}
+
+func TestPlan9BrowserClaimAcceptanceIsHTML(t *testing.T) {
+ fx := newConsoleFixture(t)
+ r := httptest.NewRequest(http.MethodPost, "/apps/kass/domains/attach", nil)
+ w := httptest.NewRecorder()
+ fx.srv.renderClaimAccepted(w, r, "<host>", "<operation>", "<phase>")
+ if w.Code != http.StatusAccepted || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/html") {
+ t.Fatalf("response = %d %s", w.Code, w.Body.String())
+ }
+ body := w.Body.String()
+ if strings.Contains(body, "<operation>") || !strings.Contains(body, "&lt;operation&gt;") || w.Header().Get("Retry-After") != "2" {
+ t.Fatalf("unsafe or unhelpful acceptance: %s", body)
+ }
+}
+```
+
+#### Patch: `internal/console/claim_invariant_purge_test.go`
+
+```diff
+--- a/internal/console/claim_invariant_purge_test.go
++++ b/internal/console/claim_invariant_purge_test.go
+@@ -17,6 +17,7 @@
+ "net/url"
+ "slices"
+ "sort"
++ "strings"
+ "testing"
+ "time"
+
+@@ -128,10 +129,15 @@
+ if status != http.StatusAccepted {
+ return status
+ }
+- op := claimReplyField(t, out, "operation_id")
+- if phase := claimReplyField(t, out, "phase"); phase != "reserved" {
+- t.Fatalf("purge %s accepted in phase %q, want reserved", op, phase)
++ admission, err := hostops.ReadCanonicalAdmission(context.Background(), cx.rel, "bab", "titogo")
++ if err != nil || admission.Value.Op == nil {
++ t.Fatalf("missing accepted purge: %v", err)
+ }
++ op := admission.Value.Op.ID
++ if !strings.Contains(out, op) || !strings.Contains(out, "reserved") || !strings.Contains(out, "<html") {
++ t.Fatalf("purge acceptance is not an HTML status page: %s", out)
++ }
++
+ if !cx.ownsPurge(t, "bab", "titogo", op) {
+ t.Fatalf("purge answered 202 for %s, but titogo's admission holds no operation", op)
+ }
+```
+
+
+Implementation.
+
+#### New file: `internal/console/claim_response.go`
+
+```go
+package console
+
+import (
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostops"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const claimResponseUnavailable = "This operation is temporarily unavailable. Try again shortly."
+
+func claimResponseRefusal(err error) *doorRefusal {
+ log.Printf("console: claim request refused: %v", err)
+ if errors.Is(err, hostops.ErrPending) || errors.Is(err, hostclaim.ErrConflict) || errors.Is(err, hostclaim.ErrWrongState) || errors.Is(err, release.ErrCASConflict) {
+ return &doorRefusal{http.StatusConflict, claimRetireBusyMsg}
+ }
+ return &doorRefusal{http.StatusServiceUnavailable, claimResponseUnavailable}
+}
+
+func writeClaimResponseError(w http.ResponseWriter, err error) {
+ ref := claimResponseRefusal(err)
+ w.Header().Set("Retry-After", "2")
+ jsonError(w, ref.Status, ref.Msg)
+}
+
+func (s *Server) renderClaimAccepted(w http.ResponseWriter, r *http.Request, subject, operation, phase string) {
+ w.Header().Set("Retry-After", "2")
+ s.renderMessage(w, r, http.StatusAccepted, "Operation accepted", fmt.Sprintf("Operation %s for %s is %s. It is still in progress. Check the app's status before trying again.", operation, subject, phase))
+}
+```
+
+#### Patch: `internal/console/appdomains.go`
+
+```diff
+--- a/internal/console/appdomains.go
++++ b/internal/console/appdomains.go
+@@ -79,7 +79,7 @@
+ return
+ }
+ if res.OperationID != "" {
+- writeJSON(w, http.StatusAccepted, map[string]string{"host": host, "operation_id": res.OperationID, "phase": res.OperationPhase})
++ s.renderClaimAccepted(w, r, host, res.OperationID, res.OperationPhase)
+ return
+ }
+ msg := "Attached " + host + " \u2014 it now aliases the app's existing route."
+```
+
+#### Patch: `internal/console/claim_create.go`
+
+```diff
+--- a/internal/console/claim_create.go
++++ b/internal/console/claim_create.go
+@@ -2,7 +2,6 @@
+
+ import (
+ "context"
+- "errors"
+ "net/http"
+ "sync"
+ "time"
+@@ -128,11 +127,7 @@
+ writeJSON(w, http.StatusAccepted, map[string]string{"operation_id": fresh.Claim.Op.ID, "phase": phase, "host": fresh.Host})
+ return false
+ }
+- if errors.Is(err, hostclaim.ErrConflict) {
+- jsonError(w, http.StatusConflict, err.Error())
+- } else {
+- jsonError(w, http.StatusBadGateway, err.Error())
+- }
++ writeClaimResponseError(w, err)
+ return false
+ }
+
+```
+
+#### Patch: `internal/console/apiinstances.go`
+
+```diff
+--- a/internal/console/apiinstances.go
++++ b/internal/console/apiinstances.go
+@@ -481,7 +481,7 @@
+ if s.cfg.HostClaim {
+ pending, err := s.claimPendingViews(ctx, acct.Sqid, app, "instance")
+ if err != nil {
+- jsonError(w, http.StatusBadGateway, err.Error())
++ writeClaimResponseError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"instances": matched[start:end], "truncated": truncated, "total": total, "counts": counts, "next_cursor": next, "operations": pending})
+@@ -1190,7 +1190,7 @@
+ return prepared, err
+ })
+ if createErr != nil {
+- jsonError(w, http.StatusConflict, createErr.Error())
++ writeClaimResponseError(w, createErr)
+ return
+ }
+ defer claimRequest.Close()
+```
+
+#### Patch: `internal/console/apisidecars.go`
+
+```diff
+--- a/internal/console/apisidecars.go
++++ b/internal/console/apisidecars.go
+@@ -156,7 +156,7 @@
+ if s.cfg.HostClaim {
+ pending, err := s.claimPendingViews(ctx, acct.Sqid, app, "sidecar")
+ if err != nil {
+- jsonError(w, http.StatusBadGateway, err.Error())
++ writeClaimResponseError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"sidecars": out, "truncated": truncated, "operations": pending})
+@@ -356,7 +356,7 @@
+ return prepared, err
+ })
+ if err != nil {
+- jsonError(w, http.StatusConflict, err.Error())
++ writeClaimResponseError(w, err)
+ return
+ }
+ defer request.Close()
+```
+
+#### Patch: `internal/console/claim_first_route.go`
+
+```diff
+--- a/internal/console/claim_first_route.go
++++ b/internal/console/claim_first_route.go
+@@ -22,7 +22,7 @@
+ return record, err
+ })
+ if err != nil {
+- return attachResult{}, &doorRefusal{http.StatusConflict, err.Error()}
++ return attachResult{}, claimResponseRefusal(err)
+ }
+ defer request.Close()
+ err = request.Authority.RecordEffects(request.Context, request.Observed, request.Record)
+@@ -34,7 +34,7 @@
+ if err != nil {
+ fresh, readErr := hostclaim.Read(context.WithoutCancel(ctx), s.cfg.Releases, host)
+ if readErr != nil || fresh.Absent || fresh.Claim.Holder == nil || fresh.Claim.Holder.Incarnation != h.Incarnation || fresh.Claim.Op.ID != request.Observed.Claim.Op.ID {
+- return attachResult{}, &doorRefusal{http.StatusConflict, hostops.ErrPending.Error()}
++ return attachResult{}, claimResponseRefusal(hostops.ErrPending)
+ }
+ result.OperationID = fresh.Claim.Op.ID
+ result.OperationPhase = fresh.Claim.Op.Phase
+```
+
+#### Patch: `internal/console/ownership.go`
+
+```diff
+--- a/internal/console/ownership.go
++++ b/internal/console/ownership.go
+@@ -410,32 +410,32 @@
+ }
+ if s.cfg.HostClaim {
+ if s.cfg.ClaimPurge == nil {
+- jsonError(w, http.StatusServiceUnavailable, hostops.ErrContract.Error())
++ s.renderDoorRefusal(w, r, claimResponseRefusal(hostops.ErrContract))
+ return
+ }
+ id, err := s.cfg.ClaimPurge.Request(r.Context(), acct.Sqid, app,
+ s.cfg.ClaimPurge.Controller.NewID(), "owner", addr)
+ if err != nil && !errors.Is(err, hostops.ErrPending) {
+- jsonError(w, http.StatusConflict, err.Error())
++ s.renderDoorRefusal(w, r, claimResponseRefusal(err))
+ return
+ }
+ if id == "" {
+- jsonError(w, http.StatusConflict, hostops.ErrPending.Error())
++ s.renderDoorRefusal(w, r, claimResponseRefusal(hostops.ErrPending))
+ return
+ }
+ journal, _, readErr := hostops.ReadPurge(r.Context(), s.cfg.Releases, acct.Sqid, app)
+ if readErr == nil && journal.ID == id {
+- writeJSON(w, http.StatusAccepted, map[string]any{"operation_id": id, "phase": journal.Phase})
++ s.renderClaimAccepted(w, r, app, id, journal.Phase)
+ return
+ }
+ if errors.Is(readErr, os.ErrNotExist) {
+ // Only this call's successful final completion reaches this branch.
+ if err == nil {
+- writeJSON(w, http.StatusOK, map[string]any{"operation_id": id, "phase": "done"})
++ s.renderMessage(w, r, http.StatusOK, "Operation complete", "The app was permanently deleted.")
+ return
+ }
+ }
+- jsonError(w, http.StatusServiceUnavailable, hostops.ErrPending.Error())
++ s.renderDoorRefusal(w, r, claimResponseRefusal(hostops.ErrContract))
+ return
+ }
+
+```
+
+#### Patch: `internal/console/fleets.go`
+
+```diff
+--- a/internal/console/fleets.go
++++ b/internal/console/fleets.go
+@@ -610,28 +610,29 @@
+ // this request has not actually committed to.
+ if s.cfg.HostClaim {
+ if s.cfg.ClaimPlacement == nil {
+- return http.StatusServiceUnavailable, hostops.ErrContract.Error()
++ return http.StatusServiceUnavailable, claimResponseUnavailable
+ }
+ id, err := s.cfg.ClaimPlacement.Start(ctx, acct.Sqid, app, target, "console")
+ if err != nil && !errors.Is(err, hostops.ErrPending) {
+- return http.StatusConflict, err.Error()
++ ref := claimResponseRefusal(err)
++ return ref.Status, ref.Msg
+ }
+ if id == "" {
+- return http.StatusConflict, hostops.ErrPending.Error()
++ return http.StatusConflict, claimRetireBusyMsg
+ }
+ appID, err := s.store.ClaimAppID(acct.ID, app)
+ if err != nil {
+- return http.StatusServiceUnavailable, err.Error()
++ return http.StatusServiceUnavailable, claimResponseUnavailable
+ }
+ admission, err := hostops.ReadAdmission(ctx, s.cfg.Releases, acct.Sqid, app, appID)
+ if err != nil {
+- return http.StatusServiceUnavailable, err.Error()
++ return http.StatusServiceUnavailable, claimResponseUnavailable
+ }
+ if admission.Value.Op == nil {
+ return 0, ""
+ }
+ if admission.Value.Op.ID != id {
+- return http.StatusConflict, hostops.ErrPending.Error()
++ return http.StatusConflict, claimRetireBusyMsg
+ }
+ return http.StatusAccepted, id
+ }
+@@ -782,7 +783,7 @@
+ http.NotFound(w, r)
+ return
+ case status == http.StatusAccepted:
+- writeJSON(w, http.StatusAccepted, map[string]any{"operation_id": msg, "phase": "pending"})
++ s.renderClaimAccepted(w, r, app, msg, "pending")
+ return
+ case status != 0:
+ back("err", msg)
+@@ -831,7 +832,7 @@
+ http.NotFound(w, r)
+ return
+ case status == http.StatusAccepted:
+- writeJSON(w, http.StatusAccepted, map[string]any{"operation_id": msg, "phase": "pending"})
++ s.renderClaimAccepted(w, r, app, msg, "pending")
+ return
+ case status != 0:
+ back("err", msg)
+```
+
+#### Patch: `internal/console/apihostclaim.go`
+
+```diff
+--- a/internal/console/apihostclaim.go
++++ b/internal/console/apihostclaim.go
+@@ -58,7 +58,7 @@
+ if in.Action == "readmit" {
+ ref, b, err := svc.Directory.Resolve(r.Context(), in.Box)
+ if err != nil {
+- jsonError(w, 409, err.Error())
++ writeClaimResponseError(w, err)
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{"box": ref, "enrollment": b.Enrollment, "revision": b.QuarantineRevision, "loss_ids": b.LossIDs})
+@@ -70,7 +70,7 @@
+ }
+ inspected, err := svc.Inspect(r.Context(), actor, r.PathValue("host"), in.Box)
+ if err != nil {
+- jsonError(w, 409, err.Error())
++ writeClaimResponseError(w, err)
+ return
+ }
+ json.NewEncoder(w).Encode(inspected)
+```
+
+### Task B6: finish the first-review census-key authority and check M5
+
+Invariant: a census public key is bound once by proof of possession to a currently active, unfenced enrollment on the exact published floor and current signed roster, never to stale, preparing or quarantined authority.
+
+**I2 is still open on 76f728a9.** Main validates the proof and enrollment but lacks this complete publication/floor/active check. The regression was added with A2's shared hostactors test file. Run it red now, then apply the patch. Re-read the signed roster and require the caller's exact ETag/revision; verify the remote floor, published digest and active state; refuse any own fence. Keep first binding write-once and exact replay idempotent.
+
+```bash
+nice -n 10 go test -p 1 ./internal/hostactors -run '^TestPlan9BindCensusKeyRequiresPublishedFloorAndActiveEnrollment$' -count=1
+```
+
+**First-review M5 remains a rollout requirement:** main already has `Holder.Acq` and strict readers. It cannot prove the deployed reader set understands `acq`; C1 certifies that before acquisition/backfill writes. Do not remove the field or treat source compilation as fleet readiness.
+
+Separately, the integration report's flag-off `StartHostReports` release observation is intentional pre-activation authority today: arming needs reports and floor updates need reservations before the claim flag. Do not gate all reporting/release on `HostClaim` and wedge armed updates. Keep its signed-floor/reservation/report checks. This is the existing pre-flag authority path, and this plan preserves it; no legacy reporter start condition changes.
+
+
+Implementation.
+
+#### Patch: `internal/hostactors/censuskey.go`
+
+```diff
+--- a/internal/hostactors/censuskey.go
++++ b/internal/hostactors/censuskey.go
+@@ -6,7 +6,9 @@
+ "crypto/ed25519"
+ "encoding/json"
+ "math"
++ "time"
+
++ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+ )
+
+@@ -21,8 +23,18 @@
+ }
+
+ func (d Directory) BindCensusKey(ctx context.Context, signer release.Signer, before Observed, binding CensusKeyBinding, proof []byte) (Observed, error) {
++ // This write bypasses Replace's membership gate, so it decides against
++ // the signed roster at the caller's ETag, never a caller-supplied copy.
++ fresh, err := d.Read(ctx)
++ if err != nil {
++ return Observed{}, err
++ }
++ if before.ETag == "" || fresh.ETag != before.ETag {
++ return Observed{}, ErrRoster
++ }
++ before = fresh
+ b, ok := before.Roster.Boxes[binding.Box]
+- if !ok || binding.Bucket != d.Bucket || binding.Enrollment != b.Enrollment || len(binding.PublicKey) != ed25519.PublicKeySize || before.ETag == "" || before.Roster.Revision == math.MaxUint64 {
++ if !ok || binding.Bucket != d.Bucket || binding.Enrollment != b.Enrollment || len(binding.PublicKey) != ed25519.PublicKeySize || before.Roster.Revision == math.MaxUint64 {
+ return Observed{}, ErrRoster
+ }
+ raw, err := json.Marshal(binding)
+@@ -30,6 +42,26 @@
+ return Observed{}, err
+ }
+ if !ed25519.Verify(binding.PublicKey, raw, proof) {
++ return Observed{}, ErrRoster
++ }
++ // I2: binaries older than census_public_key refuse the strict-decoded
++ // roster once a key is bound, so binding waits for the published floor
++ // that excludes them. Only an active, unfenced enrollment is bound.
++ if before.Roster.Publication != "published" || b.State != "active" {
++ return Observed{}, ErrRoster
++ }
++ floor, err := hostfloor.Read(ctx, d.Store, d.PublicKey, d.Bucket)
++ if err != nil {
++ return Observed{}, err
++ }
++ if floor.Digest != before.Roster.FloorSHA256 {
++ return Observed{}, ErrRoster
++ }
++ fenced, err := d.TerminationFence(ctx, before, binding.Box, time.Now())
++ if err != nil {
++ return Observed{}, err
++ }
++ if fenced {
+ return Observed{}, ErrRoster
+ }
+ if len(b.CensusPublicKey) != 0 {
+```
+
+### Task B7: decide a journal namespace that SQLite cannot race (6b-N3)
+
+Invariant: the file SQLite actually opens, including every replacement connection, WAL/SHM open, checkpoint and close, is the authorized journal; no rejected open may modify another inode. A checked pathname followed by a lazy database open is not that invariant.
+
+**Decision for Paul.** The spec requires this property but does not choose a SQLite VFS or a separate journal authority. The attempted connector pinned a checked descriptor but passed the original path to SQLite, compared `PRAGMA database_list` and re-statted the name. Review found it could change a substituted regular database's bytes before refusing. A deterministic test observed that failure. Comparing names after open also leaves an ABA swap. Temporarily lowering one thread's filesystem UID only around Connect does not cover later sidecar opens, and is not an approved architecture. That candidate is discarded; no implementation block here claims to fix N3.
+
+Options:
+
+1. Recommended: a root-owned journal service and namespace, with narrow typed append/read/settle operations and root-controlled paths inaccessible to the tenant/agent UID. Define durable request identity, fsync/replay behavior and the exact trusted writer boundary. Reuse the already needed root launch/stop authority rather than allowing every agent-owned path to be root-openable. The namespace's ancestors, main file and sidecars must stay protected for the connection lifetime. This needs a specified transport and migration from the existing agent-owned journal.
+2. A reviewed SQLite VFS/connector that consumes verified directory/file descriptors, anchors every sidecar open, checks ownership/link count and refuses rename/substitution across the entire connection lifetime. Passing `/proc/self/fd/N` alone is not enough: SQLite name canonicalization and WAL/SHM naming must be proven with the pinned library.
+3. Retain path opens in an agent-owned directory with pre/post lstat or mode checks. Reject this option: the checks do not bind SQLite's descriptor, and the directory owner can replace names or modes.
+
+The complete acceptance test below compiles on main and fails deterministically: drain the idle pool, replace the checked journal name with another regular SQLite file, then call the real `LaunchJournal.Create`. Main reopens and modifies that replacement. This requires no privileged process or race scheduler. It is decision-gated like B4; keep it out of a landing branch until a ruled implementation makes it pass.
+
+```bash
+nice -n 10 go test -p 1 ./internal/hostbox -run '^TestPlan9JournalReplacementRemainsUntouched$' -count=1
+```
+
+Observed red: `replacement database modified: write error=<nil>`. Add separate initial-open, ancestor symlink/rename, main-file ABA/hardlink, late WAL/SHM, reopen, close/checkpoint and same-UID hostile-tenant tests to the chosen implementation. Run with actual root/tenant identities in C3's approved witness. Refresh this task with complete chosen code and replay it before activation. A correct pragma hook from B1 is not a substitute.
+
+
+Tests first.
+
+#### New file: `internal/hostbox/plan9_journal_identity_test.go`
+
+```go
+package hostbox
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// A pool replacement must not open a different inode under a checked name.
+// This is a decision-gated acceptance test, not a proposed connector fix.
+func TestPlan9JournalReplacementRemainsUntouched(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "launches.db")
+ replacement := filepath.Join(dir, "unrelated.db")
+ db, err := sql.Open("sqlite", replacement)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = db.Exec("CREATE TABLE launches (identity TEXT PRIMARY KEY, body BLOB NOT NULL)"); err != nil {
+ t.Fatal(err)
+ }
+ if err = db.Close(); err != nil {
+ t.Fatal(err)
+ }
+ before, err := os.ReadFile(replacement)
+ if err != nil {
+ t.Fatal(err)
+ }
+ j, err := OpenLaunchJournal(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j.Close()
+ j.db.SetMaxIdleConns(0)
+ if err := os.Rename(path, path+".pinned"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Rename(replacement, path); err != nil {
+ t.Fatal(err)
+ }
+ in := LaunchInventory{Target: hostguard.FenceTarget{Host: "app.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: "enrollment", BootID: "boot", ControllerID: "controller"}, ControllerPID: 1, ProcessStart: "1", NoState: true, Groups: []LaunchGroup{{RelativePath: "session", Device: 1, Inode: 2}}}
+ writeErr := j.Create(context.Background(), in)
+ after, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(before, after) {
+ t.Fatalf("replacement database modified: write error=%v", writeErr)
+ }
+ if writeErr == nil {
+ t.Fatal("replacement inode accepted")
+ }
+}
+```
+
+## Group C: rollout ordering and witnesses
+
+### Task C1: certify every strict-decoded field (R1, 5-M4, first-review M5)
+
+Invariant: no actor receives bytes its decoder or signed-receipt verifier cannot interpret. The floor is the first real tagged release containing the complete implementation and owner integrations, not a branch SHA or a guessed version. Optional fields are not backward compatible with a strict decoder once a writer emits them.
+
+The mandatory release inventory is:
+
+| Field | Main's reader/writer contract | Required witness |
+| --- | --- | --- |
+| `discovery_bound`, `discovery_boxes` | `hostclaim.Op` and discovery/retirement | Strict decode of a populated discovery claim on every participating binary. |
+| `request_limit_ns` | `hostclaim.Participant`, roster request bound and `hostops.StopReceipt` | The same bounded-request value survives signed byte-exact receipt verification. |
+| `region`, `claim_etag`, `observed_boot_id` | `hostops.StopReceipt` plus claim regional bindings | Root, console and regional readers verify identical receipt bytes and reject a changed value. |
+| `park_target`, `evidence_refs` | `hostclaim.Op` and retirement evidence | Populated claims round-trip, including every keyed evidence reference. |
+| `acq` | `hostclaim.Holder`, records and ownership backfill | Strict read of the first acquired holder before #232 can write it. |
+
+Also inventory `Box.CensusPublicKey`, the release identity fields and every new field approved for Task B4. The first-review M5 remains open as a deployment ordering requirement: `acq` is on main, but its presence in the type does not prove that every deployed reader understands it. Do not remove the field to make old readers pass. All named fields must be in the release certification, even when their test fixture normally omits them.
+
+Check the exact candidate source and run the reader tests serially:
+
+```bash
+rg -n 'json:"(discovery_bound|discovery_boxes|request_limit_ns|region|claim_etag|observed_boot_id|park_target|evidence_refs|acq)' internal/hostclaim internal/hostops internal/hostactors
+rg -n 'DisallowUnknownFields|VerifyStop|CensusPublicKey' internal/hostclaim internal/hostops internal/hostactors internal/hostbox
+nice -n 10 go test -p 1 ./internal/hostclaim ./internal/hostactors ./internal/hostops ./internal/hostbox -count=1
+```
+
+Retain a certification bundle with the exact git tip, signed release snapshot, router and console artifact hashes for every architecture, minimum protocol and release tag, all populated wire fixtures and the passing reader/receipt results. Run the fixtures with the actual release binaries selected for the deployment. A test run against source does not certify an older process.
+
+### Task C2: Paul approves the publication, guard and flag sequence
+
+Invariant: no publication or executable change creates an interval in which an enrolled offline actor can return below the floor.
+
+**Decision for Paul: the requested order and the ruled order disagree.** The brief requests `floor publication -> guard-arm sequence -> flag`. That sequence is recorded here as requested, but cannot execute against main's `Directory.PublishFloor`: publication verifies reports with `FloorGuardReady`, and D1 requires arming before publication. I-1 exists precisely because an armed box must function before the remote pair exists. Removing that prerequisite would reopen the offline-boot hole. Do not change `PublishFloor` to permit the requested order implicitly.
+
+Recommendation: treat floor *selection and preparation* as the first milestone, retain D1's actual publish-after-arming sequence, and have Paul confirm that interpretation. If Paul requires actual publication first, revise D1 and its safety proof before implementing that alternative. This plan authorizes neither sequence in a live deployment.
+
+Subject to that decision, these are the exact ordered milestones. **Each numbered milestone requires its own explicit Paul approval**, scoped to the deployment account, region, bucket, domains, actors, permissions, traffic, cost, duration and cleanup. An approval to author or implement this plan is not an activation approval.
+
+1. **Paul approval: select and prepare the floor release.** Resolve Task B4 and all owner-provider and isolation blockers. Certify the release inventory in C1. Reconcile the sole signed roster against deployment configuration, fleet/pool membership, route and claim references, and every independently running console, including offline actors. Allocate distinct console IDs. Resolve unaccounted actors; heartbeat expiry cannot remove them. Stage the signed identity snapshots and prospective floor policy through the approved release workflow. No remote floor publication yet.
+2. **Paul approval: install and arm guards.** Ship the certified release with `CARLOS_HOST_CLAIM` unset. Set up the checked controller directory, identity snapshots and explicit unpublished root policy. Finish or recover outstanding updates; check current, `.prev`, every journal and `update-begun.json*`. On each exact roster enrollment, invoke the existing privileged deployment workflow's `carlos system guard-arm --policy <approved-policy-file> --identity <signed-identity-file> --enrollment <verified-enrollment-file>`; console hosts also pass `--console <protected-console-executable>`. These filenames are runbook inputs, never release tags or unsigned hash assertions. Confirm all drop-ins, root-owned paths and bootstrap/console replacement paths use the guard. Restart/reboot only within this approved scope. Collect fresh matching-digest readiness reports from every actor. Do not arm by manually deleting a leftover marker.
+3. **Paul approval: publish the floor.** After every required armed acknowledgement and the prepublication boot/recovery witnesses pass, use the deployment's approved release workflow calling `Directory.PublishFloor`. Freeze membership with the roster CAS, verify the complete census, publish authorization first and exact floor bytes second, both create-only, then CAS the roster to `published`. Verify both signatures/digests and the roster revision from fresh reads. Identical bytes are idempotent; a half-pair or different digest remains refusal. Retry a torn publication with its original bytes. Do not invent an undocumented CLI or use direct S3 edits as a rollout shortcut.
+4. **Paul approval: verify the guards after publication.** Require the same floor digest in all box and console reports and test current/previous/recovery paths with the certified binary. Prove an incompatible image is refused before execution and a compatible offline recovery succeeds. Prove an edge restart during apply does not wait for recover's update lock. Interruption leaves either verifiable completion or the original held reservation and journal. Verify fence/report IAM and process uniqueness. This is the postpublication guard verification milestone; it is not first-time arming.
+5. **Paul approval: enable the flag.** Only after A and B are complete and all C3 witnesses have passed, set `CARLOS_HOST_CLAIM` in the approved actors' real startup environments. Wait for fresh per-process `HostClaimEnabled` reports, correct enrollment/process/boot identity and matching floor readiness. Use cold-only admission. A nonempty value, including `0`, enables the flag. Do not start protected work during partial rollout. Verify the operator reset and cancellation paths without a service credential gaining operator authority. Region HA/relocation remains separately gated on its complete adapters and two-box evidence.
+
+The floor publication milestone, the guard sequence and the flag milestone are separate approvals. No local test in this document changes an actor, flag, floor, IAM policy or service.
+
+**Downgrade policy.** Arming is monotone and survives flag removal, network loss and remote-object deletion. Never roll an armed actor below its local authenticated floor. Once a new strict-decoded field or receipt shape has been written, forbid downgrade to an older reader even if the flag is disabled. Keep every possible recovery/bootstrap image at or above the certified floor, with authenticated identities and compatible persisted state. An automatic rollback remains inside its original signed activation reservation and approved recovery hashes; it does not grant a general downgrade. A later wish to support older receipt readers requires an explicitly versioned receipt migration and reviewed compatibility plan, not a flag toggle. Failed recovery keeps journals, markers and evidence. Retained staging (M-7) remains owned by its outstanding journal; do not clean it as an arming workaround.
+
+### Task C3: execute the Plan 6b rollout witnesses
+
+Invariant: filesystem mocks and successful compilation never count as proof of privileged process containment or complete stop evidence.
+
+Carry these witnesses from Plan 6b unchanged in substance, into a separately approved sandbox or deployment rehearsal. Record the release hash, machine/enrollment, boot ID, command, expected refusal or effect, and captured evidence for each:
+
+1. Compare flag-off startup, reconcile, timer bookkeeping and root verbs against the same built binary. An unset flag opens no claim provider and starts no claim worker. Ordinary routes and timers retain main's behavior.
+2. Implement and review the reservation-bearing root launch protocol before allowing any enabled launch shape. Through the real isolated launcher, run `TestClaimTenantCannotWriteContainmentOrJournals` with disposable host journals and verified same-UID writable delegated controls, including `io`. Require actual `EROFS` for cgroup controls and hidden journals inside the tenant namespace. Run the flag-off baseline and then the claim-reservation path. A skip, ordinary permission denial or baseline-only pass proves nothing about claim activation. Flat, foreign, seed, systemd, standalone-sidecar and co-spawn paths must still refuse before launch effects when unsupported.
+3. Stop a process whose forked child holds SQLite, WAL and SHM descriptors. Include renamed/deleted files, a late WAL, bounded outbound requests, and a timer request paused after admission. No receipt until every producer and descendant joins and the descriptor scan is complete. Unreadable proc entries and incomplete inventory refuse.
+4. Crash the agent after role intention, child mkdir, reservation, prelaunch commit, restore, materialization, Start and readiness. Inject restore/materialization failure. Restart with a new controller and roster revision: root must act on the old durable tuple and retain spent retries. Exercise all five failures, agent restart, changed release/stamp, exact backend health, and conditional operator reset. Non-operators and service credentials cannot reset.
+5. Crash root after fencing, drain, durable receipt, archival fsync and SQL deletion. Replay the same receipt bytes and original evidence time. Failed publication must retry after the row disappears. Preserve a replacement row or path.
+6. Reboot with a pending stop. Use the real kernel boot ID to prove the old controller cannot exist. Reconstruct positive evidence from the retained inventory/journal. Missing same-boot paths and reused new-boot paths refuse. A durable fence denies returning work before provider refresh.
+7. Recover the predecessor's receipt across controller restart and reboot, then exercise two consecutive cold admissions. Retain the predecessor stop through the successor barrier. Crash/replay each park and row-restore phase, create a newer timer registration, and replay completed handover. Preserve its registration/bookkeeping and never reuse predecessor database bytes as a new writable database.
+8. Lose each regional lease with a remote request blocked. Cancel the request, close each handle once, and forbid later success. A remotely accepted request with no response remains uncertain until the original evidence time plus the verification horizon; retries cannot refresh that time.
+9. Remove each owner provider in turn. Its enabled operation must refuse by the missing contract before claim, retry, launch, materialization, deletion or substitute-receipt effects. Exercise Plan 5's positive complete empty census for initial session zero before blank-installation cold starts.
+
+Main still refuses enabled tenant launches pending the reservation-bearing isolated protocol. Returning-box cleanup from a loss/quarantine record needs its owner's durable cleanup adapter. M-4's missing `ConsoleProcesses` and `RetirementReceiptSHA256` producers keep compute-bound console publication/readmission refused. Do not turn any of these refusals into guessed evidence. They remain explicit activation dependencies even after this plan's local fixes pass.
+
+**Carried-over M7, moving-to-relocating:** `PreFlipGuard` must prove the spec §1.1 one-box-region precondition from the same current signed placement/roster authority as its conversion decision before a caller grants conversion. Main's record decision and claim CAS binding do not by themselves prove that regional cardinality. Before enabling the caller, document this in the owner adapter contract and witness refusal for a two-box or unreadable region. Do not infer cardinality from the one failed source or from a missing heartbeat. The regional owner/provider decision in B3 must settle this alongside the other absent adapters.
+
+```bash
+rg -n 'PreFlipGuard|CheckMovingBeforeFlip|ConvertMovingToRelocating' internal/hostclaim internal/hostops
+nice -n 10 go test -p 1 ./internal/hostclaim ./internal/hostops -run 'Test.*(Relocat|Moving|MoveRecovery)' -count=1
+```
+
+These existing tests are source checks, not a claim that the missing cardinality witness or production adapter has been implemented.
+
+## Self-review and verification
+
+The deliverable changes only this plan in the design worktree. There are no git writes or live operations. The author exported main to a disposable directory, prototyped the complete decided fixes, and reviewed authority-bearing work with explicitly selected Opus delegates. Final main was checked again through read-only `git ls-remote`: `76f728a910ff9c78856620801e712650b962f2cd`.
+
+Review tightened two candidate exceptions: a currently fenced/quarantined writer cannot recover authority by claiming an earlier observation timestamp, and a fresh report from another process cannot be overwritten merely because this process started later. Both exceptions were removed before the combined green run. Review also rejected the journal connector that still handed SQLite an attacker-replaceable pathname. B7 records its actual failing witness and the unruled architecture; it is not in the decided implementation.
+
+Coverage:
+
+| Discussion item | Plan location and disposition |
+| --- | --- |
+| I-1, I-2, M1-2, M1-3, M1-4 | A1, complete guard/recovery patches and tests; pending-controller crash recovery remains explicitly blocked. |
+| I-4, M-3, M-6, M-8 | A2, complete reservation, cancellation, report and status fixes; CAS rollback is not retried. |
+| M1-5 / M-2 | A2 reader authority; A3 complete policy template/tests, pending distinct writer credentials and real IAM witnesses. |
+| 6b-N1 / 6b-N2 | B1 and B2, complete reconnect and unit-preservation patches/tests. |
+| 6b-N3 | B7, compiling deterministic red test, rejected candidate and explicit design decision. |
+| 5-I3 | B4, compiling red acceptance tests and explicit transaction-authority decision. |
+| 5-M7 | B5, complete browser/API response fixes and integration witness. |
+| R2 / R3 / R4 / 5-M10 | B3, narrow reset authority, production wiring decision and root/report authority audit. |
+| Integration M-5 | B6, preserve the existing pre-flag report/release authority required by arming. |
+| First-review I2 / M5 | B6, published active census-key binding; C1 certifies `acq` reader floor. |
+| R1 / 5-M4 | C1, every named strict field and exact binary/receipt certification. |
+| M-4 / M-7 | C2/C3, missing evidence producers remain refusal; journal-owned staging retained. |
+| First-review M7 | C3, one-box-region guard contract and production witness. |
+| I-3 / I-5 / M1-1 | Already fixed on main; no duplicate implementation. Keep wrapped-not-found, bounded binary reads and locked/torn marker recovery regression coverage. |
+
+Executed focused checks were serial under `flock`, `nice -n 10`, `go test -p 1`, using the allowed shared cache after the default cache proved read-only. The combined prototype run passed hostactors, hostfence, hostfloor, host, registry, console and the root CLI package. The console run included the complete mutation invariant and took 218.005 seconds. The response-only integration run passed in 200.875 seconds. The guard delegate's broader apply/recovery regression pass, hostexec tests, vet and Darwin/Windows builds passed in its isolated exact-file overlay; the combined prototype then rechecked the new guard tests and the adopt fix.
+
+Actual negative evidence: the clean-base registry replacement lost `busy_timeout` (0 instead of 5000) and `synchronous` (2 instead of NORMAL/1). B4's authority callback observed the held SQL connection and four checks for a two-write transaction. B7's replaced journal inode was modified with no error. The rejected journal candidate also modified a substituted database before returning its refusal. B4/B7 remain red by design; excluding them from a decided-fix green command does not close them.
+
+A3 received `tofu fmt -check`, not provider initialization, native `tofu test` execution or an effective IAM test. No production/cloud permission property is claimed verified. The future implementation must run the complete native policy tests and approved role-session witnesses. C3's privileged launch, root/tenant isolation, forked descriptor, reboot and remote-request uncertainty witnesses were not run by this plan-authoring session.
+
+Before landing the later implementation, refresh against current amadan main, make every resolved task's tests pass, complete the chosen design tasks and their exact code, run the repository's build/vet/test/gofmt and cross-build gates, and obtain authority review on the final branch. Push and land only through the reviewed amadan branch workflow. Local compilation and inert landing are not approval to arm or activate.
+
+Final replay extracted every new file and patch from this document into a fresh main export, applied with `patch --fuzz=0`, and compared all resulting bytes with the reviewed prototype. `go build -p 1 ./...` passed and `go test -p 1 ./... -run '^$'` compiled all 93 package entries. Exported trees have no usable VCS metadata, so those authoring commands used `GOFLAGS=-buildvcs=false`; the initial build without it refused VCS stamping, not source compilation. The final extracted-tree focused run passed all new decided Go tests plus reset-door, root-signer, census-forgery and instance/sidecar list regressions. `gofmt -l .` was empty and module `tofu fmt -check` passed. The two final pending-list error changes were re-extracted, byte-compared and tested after the broad compile. No full runtime suite, live deployment or privileged isolation witness is claimed.
+
+Decisions still required before completion of activation readiness: B4's transaction completion/recovery authority; B7's journal service or pinned VFS; B3's remote stop protocol and concrete mode-scoped owner adapters; A3's distinct box-writer credentials; A1's pending-controller crash recovery; and C2's publication/arming order. All C2 rollout milestones then require separate explicit Paul approvals. The plan is reviewable now; arming and activation are not authorized by it.