# Zero-downtime deploys: flag-readiness Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Close the code items amadan #272 owes before `CARLOS_SWAP=overlap` may be set on any box, and turn the claims-on gaps into one documented limitation.

**Architecture:** Every fix lands in the dormant zero-downtime code on `main` (anchored on origin/main `5da1cc9b`). The failure book (`internal/rollout/failures.go`) gains a persisted sequence, write-ahead intents and schema 2. The planner and scheduler honour hold-ran markers everywhere a good build could run. Declined boxes get an exec-time gate and process signed retries. Host retries become console-signed root envelopes. Claims-on boxes decline to arm.

**Tech Stack:** Go 1.x, stdlib only. Tests: `go test`, the rollout simulation (`internal/rollout/sim_test.go`) and its mutation script.

**Spec:** `/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/docs/superpowers/specs/2026-10-07-zero-downtime-deploys-design.md` (rev 17, binding). Inputs: `docs/superpowers/plans/CODEX-FINDINGS-ZDD-IMPL-5*.md`, `CODEX-FINDINGS-ZDD-IMPL-6*.md`, amadan discussion #272.

## Global Constraints

- **Flag off is unchanged.** With `CARLOS_SWAP` unset, behaviour stays as on origin/main, with exactly four authorized exceptions: Task 13's bucket retention also pins `GoodVersion` (it can only keep more, never delete more); Task 15's F6 change (flag-off by nature); Task 15's `/run` sweep (flag-independent cleanup); Task 19's recreation of a cgroup that took `cgroup.kill` (dev-box kernel defect). Every new hook defaults to nil and is set only on an armed or declined box. A changed shared helper (for example `configAckFunc`) keeps its flag-off behaviour; new behaviour goes on the armed path only.
- **Wire shapes are additive** (AGENTS.md). Restart-scope entries gain an optional `signed` field. The status rollout report gains per-host `held_epoch` (Task 8) and `transient` (Task 17), both `omitempty`; the console's instance-list rollout view passes them through.
- **The failure book file** (`<dataDir>/swap-failures.json`) is written as `"schema": 2` from Task 5 on. Readers accept 1 and 2. A schema-1 file must read unchanged.
- **Lock order** (`internal/rollout/manager.go:98`): goodMu, then activator.Service.mu, then Manager.mu, then scheduler.mu, then FailureBook.mu. No disk write under activator.Service.mu or scheduler.mu.
- **Copy.** No new member- or developer-facing text without a `// TODO-COPY(<id>)` comment on its line and a row in `copy-review/strings.json` (Task 17). Reuse approved text first: `internal/rolloutwords/words.go`, `rollout.HeldDownRefusal`, `host.refuseNoSwaps`, `host.refuseTexts`, the approved record `docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-copy.json`. No em dashes in any new string. Agent log lines are operator text and need no review.
- **Commands.** Sandboxed packages (`internal/rollout`, `internal/release`, `internal/provision`, `internal/rolloutwords`, `internal/status`):
  `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; nice -n 19 go test ./internal/rollout/ -run 'Name' -count=1`.
  Every other package (activator, host, console, cgroup, instancectl, root package) runs **unsandboxed** (`dangerouslyDisableSandbox: true`) with a short temp root:
  `mkdir -p /tmp/k && cd /home/paulca/amadan.net/carlos/platform/.claude/worktrees/zdd-flag-readiness && TMPDIR=/tmp/k GOFLAGS='-mod=mod -buildvcs=false' GOPROXY=off nice -n 19 go test ./internal/host/ -run 'Name' -count=1`.
  Run tests serially. Never `go test ./...` inside a task. `gofmt -l` on touched files must be empty.
- **Commits:** plain `git commit -m` in the worktree (identity is configured there). End messages with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- **Simulation gate.** Tasks 3, 4, 5 and 7 touch scheduler or gate logic: run `nice -n 19 internal/rollout/testdata/rolloutsim/mutate.sh 300` (sandboxed exports as above) after committing, and paste its verdict lines. Task 16 adds mutants and runs the one 3000-seed sweep for the revision.
- **Integrity:** reports paste real command output only.

## Review Focus

1. **A failed write of the failure book followed by an agent restart** (disk full, then a crash). Expected: no build that failed, and no hold build that ran, is eligible again after the restart. Task 5 pins it with `TestAFailedSaveThenRestartKeepsTheFailure`.
2. **An older agent binary started over a schema-2 book.** Expected: it refuses managed starts loudly rather than erase markers. Task 5 pins the reader side with `TestASchema2BookIsUnreadableToASchema1Reader` (an old-reader stub).
3. **A box with `CARLOS_HOST_CLAIM` set and `CARLOS_SWAP=overlap`.** Expected: it declines with `host-claims-on`, runs today's paths, and the deploy watch URL-watches it like any declined box. Task 1 pins it.
4. **A retry entry replayed from an old log, forged unsigned by a box, or naming another build.** Expected: ignored with a log line. Task 8 pins all three.
5. **A late outcome of an older wake or attempt arriving after a newer retry, marker or good write.** Expected: it never undoes the newer state. Task 4 pins the two sequences Astra gave (N6, retry ordering).

## Rulings recorded by this plan

- Ruling (Paul's, 2026-10-11): claims-on boxes do not get swaps. Arming declines on any box with host claims enabled (`CARLOS_HOST_CLAIM` non-empty), reason code `host-claims-on`. The four claims-on gaps from `CODEX-FINDINGS-ZDD-IMPL-5-MERGE.md` stay open as the future feature "claims-on swaps" (#272).
- Ruling: "refuse to arm" means **decline** (today's paths, failure records honoured), not `refuseManaged` (every managed wake refused). Why: refusing would make the flag an outage switch on claims boxes; declining runs nothing new. Cost if wrong: a claims box with the flag set serves on today's stop-then-start paths instead of refusing, which is the flag-off behaviour.
- Ruling: host retries are signed in the console (`release.SignRootEnvelope` with the console's root key), verified on the box (`release.VerifyRootEnvelope`), and an unsigned or invalid retry is ignored on every box. The grant names account, app, host, build and the **restriction epoch** the box published for that host (Task 8): any change to the host's restriction since the console read it (a new failure, a re-mark) voids the grant, and the epoch is persisted, so a replay after a restart clears nothing new. A 24-hour issue window is a backstop. Cost if wrong: a member whose retry raced a second failure has to retry again.
- Ruling: an unresolved write-ahead intent found at startup is a failure of its build on that instance (held down in hold mode). Cost if wrong: an agent crash in the middle of a swap halts that build's cohort on the box until `carlos promote --retry` or a signed host retry.
- Ruling: the failure book is always written as schema 2. Cost if wrong: downgrading an agent with the flag on leaves that box refusing managed starts until the file is upgraded or removed.
- Ruling (replaces the plan-time pointer read, plan review #7): explicit rollback evidence clears the marker. The pointer pass (armed `applyPointers`, declined Task 10) clears a host's hold-ran marker when the route's channel pointer, signature-verified, names the route's installed good build with `PromotedAt` after the marker's `At`. `carlos rollback` re-signs the pointer, so it qualifies; root's automatic sibling repoint does not. While a marker stands, the good build never boots in place of another build, at plan or at exec, whatever is installed. Cost if wrong: a wake in the seconds between `carlos rollback` and the next pointer pass is refused once with the approved "roll back or retry" text.
- Ruling: deferred (not code defects on the flag-on path): round-3 §5 coverage-only items (config delivery order/removal coverage, shutdown join tests, blocked-publication tests, admitting `CoalesceRecycle` coverage, concurrency-one/two-cohort coverage), reproducible simulation replay, and every real-box check (D1, kernel suite, real-unit checks, flagship probe). The status waking/stopping race is NOT deferred: Task 17.

---

### Task 1: Claims-on boxes decline to arm

**Corrections (plan review round 1; these override the text below):**
- `overlapEnv` takes a string (`internal/host/rollout_test.go:58`): call `overlapEnv("")`.

**Files:**
- Modify: `internal/host/rollout.go:25-37` (code), `:142-153` (`swapArming`)
- Modify: `internal/rolloutwords/words.go:83-121` (`unavailablePhrases`, `UnavailableDeclines`)
- Modify: `internal/status/snapshot.go:261-262` (comment lists the decline codes)
- Test: `internal/host/rollout_test.go`, `internal/rolloutwords/words_test.go`

**Interfaces:**
- Produces: `const rolloutHostClaims = "host-claims-on"` in package host. `rolloutwords.Unavailable("host-claims-on")` returns the approved `unavailableDeclined`, and `UnavailableDeclines("host-claims-on")` is true, so the deploy watch URL-watches a claims box exactly as a declined one.

- [ ] **Step 1: Write the failing tests**

```go
// internal/host/rollout_test.go
func TestSwapArmingDeclinesWhenHostClaimsAreOn(t *testing.T) {
	resetRollout(t)
	var buf bytes.Buffer
	log.SetOutput(&buf)
	t.Cleanup(func() { log.SetOutput(os.Stderr) })
	// No cgroups either: the claims check must come first, so the code
	// says claims, not delegation.
	eb := &activator.ExecBackend{Cfg: activator.Config{HostWrites: hostwrite.Config{Enabled: true}}}
	if swapArming(overlapEnv(nil), eb) {
		t.Fatal("armed a box with host claims on")
	}
	if got := rolloutUnarmed(); got != rolloutHostClaims {
		t.Fatalf("decline code %q, want %q", got, rolloutHostClaims)
	}
	if !strings.Contains(buf.String(), "REFUSING CARLOS_SWAP=overlap (host-claims-on)") {
		t.Fatalf("no operator line naming the claims refusal: %q", buf.String())
	}
	r := rolloutStatusProbe()
	if r == nil || r.Unavailable != rolloutHostClaims {
		t.Fatalf("status %+v: want Unavailable %q", r, rolloutHostClaims)
	}
}

func TestSwapArmingIgnoresClaimsWithTheFlagOff(t *testing.T) {
	resetRollout(t)
	eb := &activator.ExecBackend{Cfg: activator.Config{HostWrites: hostwrite.Config{Enabled: true}}}
	if swapArming(noEnv, eb) || rolloutUnarmed() != "" {
		t.Fatal("flag off: a claims box must say nothing")
	}
}
```

```go
// internal/rolloutwords/words_test.go
func TestHostClaimsDeclineReadsAsADecline(t *testing.T) {
	if Unavailable("host-claims-on") != unavailableDeclined || !UnavailableDeclines("host-claims-on") || UnavailableRefuses("host-claims-on") {
		t.Fatal("host-claims-on must read and act as a decline")
	}
}
```

`overlapEnv` takes the public key the existing tests pass; pass whatever its signature needs (nil or a test key). Use the imports the file already has plus `hostwrite`.

- [ ] **Step 2: Run to verify they fail**

Run (unsandboxed): `TMPDIR=/tmp/k GOFLAGS='-mod=mod -buildvcs=false' GOPROXY=off nice -n 19 go test ./internal/host/ -run 'TestSwapArming' -count=1`
Run (sandboxed): `nice -n 19 go test ./internal/rolloutwords/ -run TestHostClaims -count=1`
Expected: FAIL (`rolloutHostClaims` undefined; the words test fails).

- [ ] **Step 3: Implement**

```go
// internal/host/rollout.go, in the const block beside rolloutNoDelegation
	// The flag is on but host claims are on: claims-backed launches cannot
	// yet carry a swap's recovery choice, its execution evidence, the
	// generations file or the good build (#272, "claims-on swaps"), so the
	// box declines and stays on today's paths.
	rolloutHostClaims = "host-claims-on"
```

```go
// internal/host/rollout.go, swapArming, first check after rollout.Enabled
	if eb != nil && eb.Cfg.HostWrites.Enabled {
		log.Printf("agent: REFUSING %s=overlap (%s): host claims are on, and claims-backed launches cannot yet keep a swap's "+
			"recovery choice, execution evidence, generations file or good build, so this box stays on today's stop-then-wake paths",
			rollout.SwapEnv, rolloutHostClaims)
		declineRollout(rolloutHostClaims)
		return false
	}
```

```go
// internal/rolloutwords/words.go
	"host-claims-on":          unavailableDeclined,
...
	case "no-cgroup-delegation", "no-generation-control", "generations-off", "host-claims-on":
```

Update the comment at `internal/status/snapshot.go:261-262` to list `"host-claims-on"`.

- [ ] **Step 4: Run to verify they pass**, plus the packages' existing rollout tests: `-run 'Rollout|Swap|Declin'` in host and the whole rolloutwords package.

- [ ] **Step 5: Commit** `zdd-readiness: claims-on boxes decline CARLOS_SWAP=overlap (host-claims-on)`

---

### Task 2: The deploy watch never drops a fleet or polls an unread host

**Corrections (plan review round 1; these override the text below):**
- Seed the provision test the way `TestListRecords` does (`CreateRecord`, `internal/provision/provision_test.go:413`); `DirStore.Put` returns two values (`internal/release/store.go:402`). There is no `putTestRecord`.
- An absent host must also stop being polled if it was already in `modeURL`: the poll loop (`deploywatch.go:317`) checks only `mode` and `urlLive`. Give `watchedHost` an `absent bool`, set it in the new `case !ok:` arm (cleared whenever the host is listed again), and make the poll loop skip absent hosts. Add `TestWatchRolloutStopsPollingAURLHostThatDropsOffTheList`: a host first listed with no report on a complete join (so it is URL-watched), then missing from every later list; after the transition, zero further origin requests.

**Files:**
- Modify: `internal/provision/provision.go:2610-2657` (`ListRecords`), and the census pin `internal/provision/plan9_b8_census_test.go:849` if `TestPlan9B8RecordWriteCensus` fails on the moved body
- Modify: `internal/console/apiinstances.go:630-636` (`handleAPIInstanceList`)
- Modify: `deploywatch.go:474-475` (`rolloutWatch.apply`), and `pendingDetail` (~593) only if the absent-host line needs it
- Test: `internal/provision/provision_test.go`, `internal/console/rolloutfleet_test.go`, `deploywatch_test.go`

**Interfaces:**
- Produces: `provision.ListRecordsComplete(ctx, s release.Store, acct, app string, max int) (recs []Record, truncated bool, skipped []string, err error)`. `ListRecords` becomes a wrapper that drops `skipped`; no other caller changes.

- [ ] **Step 1: Write the failing tests**

```go
// internal/provision/provision_test.go
func TestListRecordsCompleteNamesTheSkippedHost(t *testing.T) {
	ctx := context.Background()
	s := release.NewDirStore(t.TempDir()) // the store TestListRecords uses; match it
	putTestRecord(t, s, "acct", "app", "a.example.com") // use TestListRecords' own seeding helper
	if err := s.Put(ctx, RecordKey("acct", "app", "b.example.com"), []byte("not json"), ""); err != nil {
		t.Fatal(err)
	}
	recs, truncated, skipped, err := ListRecordsComplete(ctx, s, "acct", "app", 10)
	if err != nil || truncated || len(recs) != 1 || len(skipped) != 1 || skipped[0] != "b.example.com" {
		t.Fatalf("recs %d truncated %v skipped %v err %v", len(recs), truncated, skipped, err)
	}
}
```

```go
// internal/console/rolloutfleet_test.go (helpers: placedRecord, writeFleetHeldBox, listRollout)
// Astra plan 6 R3: an unreadable record must never drop its fleet from the
// completeness check. The record's host sits on fleet/pi, whose only
// snapshot is unreadable too.
func TestAnUnreadableRecordKeepsEveryFleetInTheCompletenessCheck(t *testing.T) {
	// 1. placedRecord a readable host on the platform and a second host on fleet/pi.
	// 2. register fleet pi (writeFleetHeldBox or UpsertFleet, as the neighbouring tests do).
	// 3. overwrite the pi host's record with "{not json" at provision.RecordKey(acct, app, host).
	// 4. put "{not json" at fleets/pi/status/boxes/broken.json.
	// 5. GET the instance list: want "rollout_incomplete": true.
}
```

Write the test body with the file's own helpers, following `TestFleetBoxHoldShowsOnTheListPageAndRetryDoor`; the five numbered steps are the arrangement and the assertion.

```go
// deploywatch_test.go (helpers: scripted, sweep, noURL, testWatch)
// A target missing from a complete, untruncated list is not evidence that it
// uses the old path: it is never polled, and the timeout names it.
func TestWatchRolloutNeverPollsATargetMissingFromACompleteList(t *testing.T) {
	// targets: a (on a reporting box) and b; every sweep lists only a, with a
	// followed report still settling, incomplete=false, truncated=false.
	// noURL(t) fails the test on any origin request.
	// Want: a timeout error naming b, and zero origin requests.
}
```

Model the deploywatch test on `TestWatchRolloutIncompleteJoinIsNotNoReports` (`deploywatch_test.go:708`).

- [ ] **Step 2: Run to verify they fail** (provision sandboxed; console and root package unsandboxed with `TMPDIR=/tmp/k`).

- [ ] **Step 3: Implement**

```go
// internal/provision/provision.go
func ListRecords(ctx context.Context, s release.Store, acct, app string, max int) (recs []Record, truncated bool, err error) {
	recs, truncated, _, err = ListRecordsComplete(ctx, s, acct, app, max)
	return recs, truncated, err
}

// ListRecordsComplete is ListRecords that also names each host whose record
// could not be read (a store error, a malformed body, a body that disagrees
// with its path). A caller that must know whether the list is the whole app
// (the deploy watch's completeness, Astra plan 6 R3) reads skipped.
func ListRecordsComplete(ctx context.Context, s release.Store, acct, app string, max int) (recs []Record, truncated bool, skipped []string, err error) {
	// ListRecords' body moved here unchanged, with the skip recorded:
	//	rec, err := GetRecord(ctx, s, acct, app, host)
	//	if err != nil {
	//		skipped = append(skipped, host)
	//		continue
	//	}
}
```

```go
// internal/console/apiinstances.go, handleAPIInstanceList
	recs, truncated, skipped, err := provision.ListRecordsComplete(ctx, s.cfg.Releases, acct.Sqid, app, instanceListMax)
	...
	relevant := recordFleets(recs)
	if len(skipped) > 0 {
		// A skipped record's placement is unknown, so every fleet the account
		// has, and the platform, counts toward completeness (nil means all).
		log.Printf("console: api instances list %s/%s: %d record(s) unreadable %v; every fleet counts toward the rollout join",
			acct.Sqid, app, len(skipped), skipped)
		relevant = nil
	}
	join, joinComplete := s.loadRolloutJoin(ctx, acct.Sqid, time.Now(), relevant)
```

```go
// deploywatch.go, rolloutWatch.apply: replace `case !ok && l.truncated:`
		case !ok:
			// Absent from this list: cut short, or a record the console could
			// not read. Neither says the host uses the old path, so it keeps
			// its mode and state and is never moved to URL watching here
			// (Astra plan 6 R3).
			h.unread = !l.truncated
```

Check `pendingDetail`: an absent host on an untruncated list now has `unread` true. If `watchPendingUnread`'s approved text does not fit "missing from the list", reuse `watchPendingUnlisted` by leaving `unread` false instead, and say which in the report; do not add new text.

Update `apply`'s doc comment bullets (`deploywatch.go:430-446`) to match.

- [ ] **Step 4: Run to verify they pass**, plus `-run 'TestListRecords|TestPlan9B8'` (provision), `-run 'Rollout|Fleet|InstanceList'` (console), `-run 'TestWatch|TestDeployWatch'` (root).

- [ ] **Step 5: Commit** `zdd-readiness: an unreadable record keeps every fleet in the watch's completeness check, and is never URL-polled`

---

### Task 3: Hold-ran markers honour records, workers and rollback provenance (N5, N7, ruling 205)

**Corrections (plan review round 1; these override the text below):**
- **Rollback provenance moves out of the planner** (plan review #7). Drop `wakeInputs.RolledBack` and the plan-time pointer read. Instead:
  - `decideHoldRan`'s `installed == rt.GoodVersion` arm refuses with `HeldDownRefusal(mark.Build)` whenever a marker stands.
  - `planCheck`'s good-build arm refuses the good build whenever a marker stands, whatever `rt.Version` is: delete `&& rt.Version != rt.GoodVersion` (`wakeplan.go:317`). No I/O in the check.
  - `applyPointers` (`manager.go:418`) also runs for an app with a marker on any of its hosts (not only one with records). For each such host whose route installs its good build (`rt.Version == rt.GoodVersion`), a pointer for the route's channel naming `rt.GoodVersion` with `PromotedAt` after `mark.At` clears the marker (`ClearHoldRan`) and logs it. An installed-build change seen by `observe` on a marked host makes that app's pointer read due at once (reset `m.pointerRead[k]`).
  - Test: `TestASignedRollbackClearsTheMarkerAndTheGoodBuildBoots` replaces `TestAnInstalledGoodBuildBootsOverAMarkerOnlyAfterASignedRollback`: before the pointer, the plan is refused and `p.Check("v1")` (from any earlier plan) refuses; after `h.pointer("stable", "v1", later)` and `h.pass()`, the marker is gone and the plan boots v1. An older pointer clears nothing. The harness pointer is an unsigned fixture (`fake_test.go:352`); signature verification is `release.Channel`'s, already tested in `internal/release`.
- The book's clock: give `FailureBook` a `now func() time.Time` (nil means `time.Now`), and set `h.book.now = h.clock` in `newHarness` so `At` follows the harness clock.

**Files:**
- Modify: `internal/rollout/failures.go:62-66` (`HoldRan` gains `At`), `:197-221` (`markLocked`/`MarkHoldRan` stamp `At`)
- Modify: `internal/rollout/wakeplan.go:31-40` (`wakeInputs`), `:127-152` (`decideHoldRan`), `:254-283` (`planRow`), `:300-329` (`planCheck`)
- Modify: `internal/rollout/worker.go:21-30` (`executeWorker`)
- Modify: `internal/rollout/holdran_seq_test.go:58-71`, `internal/rollout/sim_test.go:~826-840` (remove the N7 exemption)
- Test: `internal/rollout/holdran_test.go`, `internal/rollout/worker_test.go`

**Interfaces:**
- Produces: `HoldRan{Account, App, Build string; At time.Time}` (`At` is `json:"at,omitempty"`, set by the book from its clock, `time.Now` unless a test injects one). `wakeInputs.RolledBack func(good string, since time.Time) bool`.

Rules (spec §6.4, §8.3, §8.4; Astra R3 N5, N7, ruling 205):
1. A marked build is planned only when nothing holds it for the instance: held down refuses (as today), and **a record that `Blocks` it while the instance has a good build refuses too**, with `HeldDownRefusal(mark.Build)` (approved text; no new copy). Without a good build the §8.5 rule stands: a record alone refuses nothing.
2. The installed good build boots over a marker only on explicit rollback evidence: the route's channel pointer, signature-verified (`Deps.Pointer`), names `rt.GoodVersion` with `PromotedAt` after `mark.At`. `carlos rollback` re-signs the pointer (`internal/release/release.go:1252`, `signPointer` stamps a fresh `PromotedAt`), so a member's rollback passes. Root's automatic sibling repoint (`internal/release/rollback.go:96`) does not. No pointer, an unreadable one, or an older one refuses with `HeldDownRefusal(mark.Build)`.
3. A scheduled worker replacement carries no fallback while a marker of the same app stands on the host.

- [ ] **Step 1: Write the failing tests**

```go
// internal/rollout/holdran_test.go
// N7: a marker never turns into permission to run a build a sibling's
// failure record holds. The instance has a good build, so it stays down.
func TestAMarkedBuildStillHonoursASiblingsFailureRecord(t *testing.T) {
	ctx := context.Background()
	h := newHarness(t, "h", "s")
	h.stage("v2")
	h.install("v2", "h", "s")
	h.setGood("h", "v1") // the good-build helper the file's other tests use
	h.markHoldRan("h", "v2")
	h.recordFailure("v2", "s") // a sibling's plain failure record of v2
	h.sleepHost("h")
	h.wakeHost("h")
	p := h.m.Plan(ctx, h.rows.get("h"))
	if p.Refuse != HeldDownRefusal("v2") {
		t.Fatalf("plan %+v: want the hold-down refusal for v2", p)
	}
}

// Ruling 205: the installed good build boots over a marker only after a
// signed rollback newer than the marker.
func TestAnInstalledGoodBuildBootsOverAMarkerOnlyAfterASignedRollback(t *testing.T) {
	ctx := context.Background()
	h := newHarness(t, "h")
	h.setGood("h", "v1")
	h.markHoldRan("h", "v2") // stamped at the harness clock
	h.install("v1", "h")     // root repointed h to its good build
	h.sleepHost("h")
	h.wakeHost("h")
	if p := h.m.Plan(ctx, h.rows.get("h")); p.Refuse != HeldDownRefusal("v2") {
		t.Fatalf("no rollback pointer: plan %+v, want refused", p)
	}
	h.advance(time.Minute)
	h.pointer("stable", "v1", h.clock())
	if p := h.m.Plan(ctx, h.rows.get("h")); p.Refuse != "" || p.Boot.Version != "v1" {
		t.Fatalf("after a signed rollback: plan %+v, want v1", p)
	}
}
```

```go
// internal/rollout/worker_test.go
// N5: a worker replacement on a host with a hold-ran marker carries no
// fallback onto the good build.
func TestAWorkerReplacementCarriesNoFallbackWhileAMarkerStands(t *testing.T) {
	// Arrange as the file's existing fallback test does (a worker host with
	// good v1, an overlap adoption of v3), then mark the host hold-ran on v2
	// before the pass. Want: h.act.replacements()[0].fallback == nil.
}
```

If the harness lacks `setGood`, `markHoldRan` or `recordFailure`, add them to `fake_test.go` as thin wrappers (`h.rows.put` with `GoodVersion`/`GoodDigest`; `h.book.MarkHoldRan`; `h.book.Record`).

Change `TestASupersededHoldThatRanKeepsTheInstanceOffItsGoodBuild` (`holdran_seq_test.go:58-71`): with s's record of v2 standing and h holding good v1, h's plan must now be `Refuse == HeldDownRefusal("v2")`, and its comment must say the instance stays down until a retry or a newer build (N7).

- [ ] **Step 2: Run to verify they fail**: `nice -n 19 go test ./internal/rollout/ -run 'Marker|HoldRan|Superseded|Worker' -count=1`

- [ ] **Step 3: Implement**

```go
// wakeplan.go, decideHoldRan
	switch {
	case installed == mark.Build:
		if hasGood && in.Book.Blocks(acct, app, installed, host, false) {
			// N7: the marker stops a fallback; it never lifts a record.
			return activator.WakePlan{Refuse: HeldDownRefusal(mark.Build)}, nil
		}
	case hasGood && installed == rt.GoodVersion:
		if in.RolledBack == nil || !in.RolledBack(rt.GoodVersion, mark.At) {
			// Ruling 205: only a signed rollback newer than the marker is an
			// explicit recovery; root's sibling repoint is not.
			return activator.WakePlan{Refuse: HeldDownRefusal(mark.Build)}, nil
		}
		return recovery, nil
	case hasGood && in.Book.Blocks(acct, app, installed, host, false):
		return activator.WakePlan{Refuse: HeldDownRefusal(mark.Build)}, nil
	}
```

```go
// wakeplan.go, planRow: wakeInputs gains
		RolledBack: func(good string, since time.Time) bool {
			if m.d.Pointer == nil {
				return false
			}
			ptr, err := m.d.Pointer(ctx, acct, app, routeChannel(rt))
			return err == nil && ptr != nil && ptr.Version == good && ptr.PromotedAt.After(since)
		},
```

```go
// wakeplan.go, planCheck, the marker arm
			case build == mark.Build:
				if m.d.Book.HeldDown(acct, app, build, host) || (hasGood && m.d.Book.Blocks(acct, app, build, host, false)) {
					return held(build)
				}
				return nil
```

```go
// worker.go, executeWorker
	acct := release.RouteAccount(rt)
	if _, marked := holdRanFor(m.d.Book, acct, rt.App, rt.Host); !marked {
		if g := rt.Good(); g.Version != "" && g.Digest != "" && g.Version != a.Build {
			fallback = &release.StagedIdentity{...as today...}
		}
	}
```

Rewrite `decideHoldRan`'s and `planCheck`'s doc comments to state the three rules above.

In `sim_test.go`, delete the N7 exemption (`&& build != s.holdRan[h.name]` in `checkBoot`, ~840) and make `checkWakeRefused` (~826-832) expect a refusal when the marked build is held for a host with a good build. Model the rollback rule if the simulation installs the good build over a marker; otherwise say so in the report.

- [ ] **Step 4: Run** the rollout package (`nice -n 19 go test ./internal/rollout/ -count=1`) and `nice -n 19 internal/rollout/testdata/rolloutsim/mutate.sh 300`; paste both.

- [ ] **Step 5: Commit** `zdd-readiness: hold-ran markers keep records, block worker fallback, and need a signed rollback (N5, N7, ruling 205)`

---

### Task 4: A book sequence orders marker clears and retries (N6, retry ordering)

**Corrections (plan review round 1; these override the text below):**
- **A stale outcome suppresses the whole failure transition** (plan review #3). `Record` returns a disposition, `type RecordResult int` with `Recorded`, `Merged`, `Unchanged`, `PreRetry`. `failCohort` (`queue.go:~480-500`) halts and supersedes only for `Recorded`/`Merged`; for `PreRetry` it neither halts the cohort nor supersedes attempts. Add Manager-level tests through `done()` and `OnGen`: after `RetryHost` (and, separately, after a pointer clear), a delayed failure of a pre-retry attempt and of a pre-retry wake leave the cohort unhalted, the retried host's queued adoption admitted, and no running attempt's `SchedRev` changed.
- **Markers are per execution, not per build** (plan review #4). `HoldRan` gains `Exec string` (`json:"exec,omitempty"`): the execution's identity, `"a<SchedRev>"` for an attempt and `"w<token>"` for a planned wake (the same IDs Task 5's intents use). `markLocked` keeps `Seq`/`At` only when `Exec` matches the standing marker (a repeated report of one execution); a different execution of the same build gets a fresh `Seq = ++seq` and `At`. Extend the N6 regression: marker for B (exec a1), snapshot C, B runs again (exec a2, re-marked), C's good write clears nothing.

**Files:**
- Modify: `internal/rollout/failures.go` (sequence, `HoldRan.Seq`, clears, `Record`)
- Modify: `internal/rollout/manager.go:892-1003` (`served`, `writeGood`, `writeGoodLocked`, `goodRecorded`), `:515-553` (`RetryHost`), `:418-466` (`applyPointers`), `:581-587` (`newWakeToken`), `:732-741` (`Bootstrap`)
- Modify: `internal/rollout/queue.go:34-50` (`dispatch.BookSeq`), `internal/rollout/scheduler.go:96-287` (`execute`, `done`), `internal/rollout/wakeplan.go:186-246, 362-477` (`Plan`, `OnGen`, `buildFailed`)
- Test: `internal/rollout/failures_test.go`, new `internal/rollout/bookseq_test.go`

**Interfaces:**
- Produces:
  - `func (b *FailureBook) Snapshot() uint64`: the book's sequence plus one. Taken before a generation can execute. Zero means "no snapshot" and clears nothing.
  - `HoldRan.Seq uint64` (`json:"seq,omitempty"`), set from the book's sequence when the marker is written.
  - `func (b *FailureBook) ClearHoldRanBefore(host string, snap uint64) (bool, error)`: drops the marker only if `snap != 0 && marker.Seq < snap`.
  - `FailureRecord.Seq uint64` (`json:"-"`): the snapshot the failing execution started under; zero when unknown.
  - The book's sequence is persisted (`failureFile.Seq`, `json:"seq,omitempty"`), so snapshots after a restart exceed every marker before it.
  - `Manager.served(host string, g activator.Gen, snap uint64)` and `writeGood(host string, g activator.Gen, snap uint64)`.

Rules:
1. **N6.** A good write clears only markers set before its generation's execution began. Attempts take `d.BookSeq = Book.Snapshot()` in `execute` immediately before `Act.Swap` or `executeWorker`. Planned wakes take a snapshot in `Plan` right after `newWakeToken`, kept per token (`Manager.wakeSnap map[uint64]uint64`, dropped when that token's terminal event arrives, when `forget` drops the host, and when a newer plan for the host replaces an entry with no event). `Bootstrap` passes zero.
2. **Retry ordering.** `RetryHost` records a host clear at `++seq`, and `ClearOnPointer` records an app-wide clear of the build at `++seq` (in memory, `map[[4]string]uint64` keyed by account, app, build and host, `""` for app-wide; `Prune` drops entries for builds no route installs). `Record(r)` with `r.Seq != 0 && r.Seq <= clear` for its host or app-wide key began before that retry: it creates no record, leaves `Retried` alone, adds no `HeldDown`. It **still sets** a requested hold-ran marker, because the marker is a fact about the database, not a failure.
3. A marker written by `markLocked` gets `Seq = ++b.seq` and `At = b.now()`.

- [ ] **Step 1: Write the failing tests**

```go
// internal/rollout/bookseq_test.go
// N6, Astra's sequence: A's good write was decided before hold B ran on the
// host; B's marker is newer, and A's write must not clear it.
func TestAnOlderGoodWriteNeverClearsANewerMarker(t *testing.T) {
	b := &FailureBook{}
	snapA := b.Snapshot()
	if err := b.MarkHoldRan("h", HoldRan{Account: "bab", App: "blog", Build: "v2"}); err != nil {
		t.Fatal(err)
	}
	if cleared, _ := b.ClearHoldRanBefore("h", snapA); cleared {
		t.Fatal("a write decided before the marker cleared it")
	}
	if cleared, _ := b.ClearHoldRanBefore("h", b.Snapshot()); !cleared {
		t.Fatal("a write decided after the marker must clear it")
	}
}

func TestAZeroSnapshotClearsNothing(t *testing.T) {
	b := &FailureBook{}
	_ = b.MarkHoldRan("h", HoldRan{Account: "bab", App: "blog", Build: "v2"})
	if cleared, _ := b.ClearHoldRanBefore("h", 0); cleared {
		t.Fatal("no snapshot must clear nothing")
	}
}

// Retry ordering: a failure of an attempt that began before the host's retry
// never takes the retry back.
func TestAPreRetryFailureLeavesTheRetryStanding(t *testing.T) {
	b := &FailureBook{}
	before := b.Snapshot()
	b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "s", Seq: before})
	if ok, _ := b.RetryHost("bab", "blog", "v2", "h"); !ok {
		t.Fatal("retry")
	}
	b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h", Seq: before, HeldDown: []string{"h"}})
	if b.Blocks("bab", "blog", "v2", "h", false) || b.HeldDown("bab", "blog", "v2", "h") {
		t.Fatal("a pre-retry failure undid the retry")
	}
	b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h", Seq: b.Snapshot()})
	if !b.Blocks("bab", "blog", "v2", "h", false) {
		t.Fatal("a failure after the retry must stand")
	}
}

func TestAPreRetryFailureNeverRecreatesAClearedRecord(t *testing.T) {
	b := &FailureBook{}
	before := b.Snapshot()
	b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "s", Seq: before})
	if ok, _ := b.ClearOnPointer("bab", "blog", "v2", time.Now().Add(time.Hour)); !ok {
		t.Fatal("clear")
	}
	b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "t", Seq: before, MarkHoldRan: true, Mode: "hold"})
	if _, held := b.Lookup("bab", "blog", "v2"); held {
		t.Fatal("a pre-retry failure recreated the record")
	}
	if _, marked := b.HoldRanOn("t"); !marked {
		t.Fatal("the marker is a fact about t's database and must still be set")
	}
}

func TestTheSequenceSurvivesAReopen(t *testing.T) {
	dd := t.TempDir()
	b, _ := OpenFailureBook(dd)
	_ = b.MarkHoldRan("h", HoldRan{Account: "bab", App: "blog", Build: "v2"})
	b2, err := OpenFailureBook(dd)
	if err != nil {
		t.Fatal(err)
	}
	if cleared, _ := b2.ClearHoldRanBefore("h", b2.Snapshot()); !cleared {
		t.Fatal("after a reopen a fresh snapshot must exceed the old marker")
	}
}
```

Add one Manager-level regression in `bookseq_test.go` for N6 through `OnGen`: plan a first wake of v1 on h (snapshot taken), mark h hold-ran on v2 through a scheduled hold attempt that is superseded (as `holdran_seq_test.go` does), then deliver v1's `GenSettled` with the first plan's token; assert `h.book.HoldRanOn("h")` still names v2.

- [ ] **Step 2: Run to verify they fail**: `-run 'Snapshot|Marker|PreRetry|Sequence|OlderGood'`

- [ ] **Step 3: Implement** the rules above. Key code:

```go
// failures.go
type FailureBook struct {
	// ...existing fields...
	seq     uint64               // persisted; every marker and clear takes ++seq
	cleared map[[4]string]uint64 // in memory: retry clears, host "" for app-wide
	now     func() time.Time     // nil: time.Now
}

func (b *FailureBook) Snapshot() uint64 {
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.seq + 1
}

func (b *FailureBook) preRetryLocked(r FailureRecord) bool {
	if r.Seq == 0 {
		return false
	}
	return r.Seq <= b.cleared[[4]string{r.Account, r.App, r.Build, r.Host}] ||
		r.Seq <= b.cleared[[4]string{r.Account, r.App, r.Build, ""}]
}
```

In `Record`: after the marker step, `if b.preRetryLocked(r) { return false, save-or-retry }`. `markLocked` sets `h.Seq = b.bumpLocked()` and `h.At = b.nowLocked()`; compare old and new markers on `Account/App/Build` only, so re-marking the same build keeps its first `Seq` and `At`. `goodRecorded(host, ver, snap)` calls `ClearHoldRanBefore`. `RetryHost` keeps its unconditional `ClearHoldRan` (the retry is the explicit recovery).

- [ ] **Step 4: Run** the rollout package and `mutate.sh 300`; paste both.

- [ ] **Step 5: Commit** `zdd-readiness: a persisted book sequence orders marker clears and pre-retry outcomes (N6, retry ordering)`

---

### Task 5: Failure state survives a failed write and a restart (F5, schema 2)

**Corrections (plan review round 1; these override the text below):**
- **Never end an intent on `!Owns`** (plan review #1). A failed wake deletes its activator entry before its `OnGen` is delivered (`activator.go:1453`, `:1549`), so `dropUnownedWakes` must not call `End`. Instead, **every planned first wake ends with exactly one token-carrying terminal event**: in `internal/activator`, the planned-wake exits that today send no event (refused at the lease, abandoned at the exec, refused by `planCheck`) emit `GenCancelled{Executed: false, Token: plan.Token}`; only for a plan with a `Token` and only when `OnGen` is set, so flag-off is untouched. `End` runs on the token's first event that settles the planned build's fate: `GenSettled`, `GenFailed`, `GenFellBack`, `GenCancelled`, and `GenHealthy` with `FirstWake == false` (the verdict floor booted the good build and the planned build never ran, `activator/wakeplan.go:667`). `Begin`'s replacement of an older wake intent for the same host stays (the outcome fence keeps a newer plan from starting before the older wake's outcome is delivered); say so in its comment, with the fence's anchor.
- **Persist the effective mode** (plan review #2). `Swap` turns an overlap attempt on a foreign-layout row into a hold (`activator/swap.go:264`, `a.foreignRow`). Add `EffectiveMode(host string, m activator.SwapMode) activator.SwapMode` to the rollout `Activator` interface (`*activator.Service` implements it with `foreignRow`; the fake returns `m` unless a test sets it), and write the intent's `Mode` from it. Check the planned-wake path for the same override and do likewise. Add `TestAForeignLayoutHoldCrashReopensHeldDown`: an overlap attempt on a foreign row writes a `hold` intent; reopening the book with that intent unresolved holds the host down and marks it.
- The marker a reconciled hold intent sets carries `Exec` = the intent's ID (Task 4).

**Files:**
- Modify: `internal/rollout/failures.go` (intents, schema 2, reconciliation)
- Modify: `internal/rollout/scheduler.go:96-129` (`execute`), `:133-287` (`done`)
- Modify: `internal/rollout/wakeplan.go:186-246` (`Plan`), `:362-426` (`OnGen`), `internal/rollout/manager.go:638-661` (`dropUnownedWakes`)
- Modify: `internal/host/rollout.go:315-320` (`armRollout`), `internal/host/rolloutdeclined.go:68-84` (`honourDeclinedFailures`)
- Test: `internal/rollout/failures_test.go`, new `internal/rollout/intent_test.go`, `internal/host/rollout_test.go`

**Interfaces:**
- Produces:
  - `type Intent struct { Account, App, Build, Mode, ID string }` (JSON keys lower-case), stored in `failureFile.Intents map[string]Intent` by host.
  - `func (b *FailureBook) Begin(host string, in Intent) error`: writes the intent and saves; on a save error the intent is removed from memory and the error returned, and the caller must not execute.
  - `func (b *FailureBook) End(host, id string) error`: removes the host's intent if its ID matches, and saves.
  - `func (b *FailureBook) Reconciled() int`: how many intents `OpenFailureBook` turned into records.
  - `func (b *FailureBook) Flush() error`: saves now (used after reconciliation).

Rules (Astra R1 #5, R3 §5 item 2):
1. **Write-ahead.** An adoption attempt (`d.Class == classAdopt`) calls `Begin(host, Intent{..., ID: "a" + strconv.FormatUint(d.SchedRev, 10)})` in `execute` after `resolve` and before `Swap`/`executeWorker`. A planned first wake (`plan.FirstWake && plan.Boot.Version != "" && plan.Refuse == ""`) calls `Begin(host, Intent{..., ID: "w" + strconv.FormatUint(tok, 10)})` at the end of `Plan` (no activator lock is held there). A failed `Begin` refuses: the attempt finishes `SwapAbandoned` with `Executed: false` and reason `"failure records could not be written"`; the wake is refused with the approved text `"this box cannot start instances right now: its record of failed builds cannot be read"` (the same string as `host.refuseTexts[rolloutBookUnreadable]`; share it, do not copy-edit it).
2. **End** runs after the outcome's own book write (`Record`/`MarkHoldRan`), never before: attempts in `done()`, wakes on the token's terminal `OnGen` event (`GenSettled`, `GenFailed`, `GenFellBack`, `GenCancelled`), on `dropUnownedWakes` for the dropped entry's token, and in `Begin` itself when a newer wake intent replaces an older wake intent for the host. A `Begin` that finds an attempt intent (`"a..."`) for the host returns an error.
3. **Reconcile at open.** Each intent left in the file becomes a failure record for its build on that host (`Cause` `release`, `Mode` the intent's, `HeldDown` and a hold-ran marker when `Mode == "hold"`, `PromotedAt` and `At` the open time, `Reason` a string `reasonWord` maps to an existing word; check `internal/rollout/events.go`). Intents are then deleted in memory and the book is marked dirty.
4. **Schema 2.** `saveLocked` writes `Schema: 2`; `OpenFailureBook` accepts 1 and 2 and errors on anything else.
5. `armRollout`: after `OpenFailureBook`, if `book.Reconciled() > 0`, log the count and `Flush()`; a flush error is `refuseManaged(svc, rolloutBookUnreadable)`. `honourDeclinedFailures`: the reconciled records are honoured; a flush error is logged.

- [ ] **Step 1: Write the failing tests**

```go
// internal/rollout/intent_test.go
// A save fails by making the data dir read-only (WriteFileAtomic writes its
// temp file beside the target). Running as root defeats it: the tests skip.

// Review Focus 1: B's failure is held only in memory because the save
// failed; the agent restarts. The intent written before B ran is still on
// disk and comes back as B's failure.
func TestAFailedSaveThenRestartKeepsTheFailure(t *testing.T) {
	dd := t.TempDir()
	b, _ := OpenFailureBook(dd)
	if err := b.Begin("h", Intent{Account: "bab", App: "blog", Build: "v2", Mode: "hold", ID: "a7"}); err != nil {
		t.Fatal(err)
	}
	if err := os.Chmod(dd, 0o500); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { os.Chmod(dd, 0o700) })
	if _, err := b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h", Mode: "hold", HeldDown: []string{"h"}}); err == nil {
		t.Skip("the save did not fail (running as root?)")
	}
	_ = b.End("h", "a7") // fails too: the intent stays on disk
	os.Chmod(dd, 0o700)
	b2, err := OpenFailureBook(dd)
	if err != nil {
		t.Fatal(err)
	}
	if b2.Reconciled() != 1 || !b2.HeldDown("bab", "blog", "v2", "h") {
		t.Fatalf("after restart: reconciled %d, held down %v", b2.Reconciled(), b2.HeldDown("bab", "blog", "v2", "h"))
	}
	if m, ok := b2.HoldRanOn("h"); !ok || m.Build != "v2" {
		t.Fatal("a hold intent must leave its marker")
	}
}

func TestABeginThatCannotSaveRefusesAndLeavesNothing(t *testing.T) {
	dd := t.TempDir()
	b, _ := OpenFailureBook(dd)
	os.Chmod(dd, 0o500)
	t.Cleanup(func() { os.Chmod(dd, 0o700) })
	if err := b.Begin("h", Intent{Account: "bab", App: "blog", Build: "v2", Mode: "overlap", ID: "a1"}); err == nil {
		t.Skip("the save did not fail")
	}
	os.Chmod(dd, 0o700)
	b2, _ := OpenFailureBook(dd)
	if b2.Reconciled() != 0 {
		t.Fatal("a refused begin left an intent")
	}
}

func TestASchema1BookReadsAndIsWrittenAsSchema2(t *testing.T) {
	dd := t.TempDir()
	raw := `{"schema":1,"records":[{"account":"bab","app":"blog","build":"v2","host":"h","cause":"release","mode":"overlap","reason":"x","promoted_at":"2026-10-01T00:00:00Z","at":"2026-10-01T00:00:00Z"}]}`
	os.WriteFile(filepath.Join(dd, FailureFile), []byte(raw), 0o644)
	b, err := OpenFailureBook(dd)
	if err != nil || !b.Blocks("bab", "blog", "v2", "x", false) {
		t.Fatalf("schema 1: %v", err)
	}
	if err := b.Flush(); err != nil {
		t.Fatal(err)
	}
	out, _ := os.ReadFile(filepath.Join(dd, FailureFile))
	if !strings.Contains(string(out), `"schema": 2`) {
		t.Fatalf("written as %s", out)
	}
}

// Review Focus 2: what an older agent sees. Its reader refused schema 2.
func TestASchema2BookIsUnreadableToASchema1Reader(t *testing.T) {
	var f struct{ Schema int `json:"schema"` }
	dd := t.TempDir()
	b, _ := OpenFailureBook(dd)
	_ = b.Flush()
	raw, _ := os.ReadFile(filepath.Join(dd, FailureFile))
	json.Unmarshal(raw, &f)
	if f.Schema == 1 {
		t.Fatal("an old reader would accept the file and drop markers and intents")
	}
}
```

Also add, in `intent_test.go`, Manager-level tests: (a) an adoption whose `Begin` fails never reaches `Act.Swap` (`h.act.snapshot()` shows no call) and is retried after the abandon deferral; (b) a first-wake plan whose `Begin` fails is refused with the shared text; (c) a planned wake's intent is ended by its `GenSettled` and by `dropUnownedWakes`. In `internal/host/rollout_test.go`: an armed box whose book holds an intent reconciles it, and a flush failure refuses managed starts (`rolloutUnavailable() == rolloutBookUnreadable`).

- [ ] **Step 2: Run to verify they fail.**

- [ ] **Step 3: Implement** the rules. `Begin`/`End` never run under `scheduler.mu` or `activator.Service.mu`. Reword the `dirty` comment (`failures.go:89-92`) and `Record`'s comment (`:155-163`) to say what now protects a failed save: the intent on disk.

- [ ] **Step 4: Run** the rollout package, the host tests `-run 'Rollout|Declin|Book'` (unsandboxed), and `mutate.sh 300`; paste all three.

- [ ] **Step 5: Commit** `zdd-readiness: write-ahead intents and schema 2 keep failures across a failed save and a restart (F5)`

---

### Task 6: An uncertain start in hold mode is a failure (ruling 206)

**Corrections (plan review round 1; these override the text below):**
- **Fallback needs termination evidence** (plan review #9). A nested start error can wrap both `errStartedThenExited` and a failed termination proof (`backend_exec.go:1709`), and `spawnPlanned` refuses a fallback when `proc == nil` (`activator/wakeplan.go:393`). So in overlap, `errStartedThenExited` is a failure of the boot build (`w.failed`), and the fallback runs **only when the generation's termination is proven** (no `genStartUnproven` in the error chain, or the activator's own proof succeeded). Otherwise the wake records the failure (`GenFailed`), serves nothing, and keeps the lease/containment as an unproven wake does today. Test both cleanup outcomes.

**Files:**
- Modify: `internal/activator/backend_exec.go:~1702-1725` (the `markStartUncertain` sites; give the `inv == ""` case its own sentinel `errStartedThenExited`)
- Modify: `internal/activator/swaphold.go:~190-205` (`holdResult`)
- Modify: `internal/activator/wakeplan.go:~355-390` (`spawnPlanned`)
- Test: `internal/activator/swaphold_test.go` or the file with `holdResult` tests; `internal/activator/wakeplan_test.go`

**Interfaces:**
- Produces: `var errStartedThenExited = errors.New("generation exited before it was observed")` wrapped by `markStartUncertain` for the `inv == ""` case, so `errors.Is` finds both.

Rules (Astra R3 ruling 206): a nested start that root accepted and whose outcome is uncertain may have run its build.
1. **Hold swap:** `holdResult` returns `SwapDown` (held down by `done()`, cohort halted) when `werr != nil`, the proc is nil and `startMayHaveRun(nil, werr)` is true. It never returns abandoned with `Executed: true` for such a start, so the abandon deferral cannot re-run the build.
2. **Planned wake:** `spawnPlanned` treats an uncertain start as a failure of the boot build (`w.failed`) in hold mode always, and in overlap mode only for `errStartedThenExited`. Other uncertain overlap starts keep today's `GenCancelled{Executed: true}`.

- [ ] **Step 1: Write the failing tests** against the nested fakes the existing uncertain-start tests use (search `markStartUncertain` in `internal/activator/*_test.go`): a hold swap whose nested start returns `markStartUncertain(errStartedThenExited)` yields `SwapDown` with `Executed` true; one returning another uncertain error also yields `SwapDown`; a hold planned wake with an uncertain start emits `GenFailed` (not `GenCancelled`); an overlap planned wake with `errStartedThenExited` emits `GenFellBack` when it has a fallback, and with another uncertain error still emits `GenCancelled{Executed: true}`.

- [ ] **Step 2: Run to verify they fail** (unsandboxed, `TMPDIR=/tmp/k`, `-run 'Uncertain'`).

- [ ] **Step 3: Implement.**

- [ ] **Step 4: Run** `-run 'Uncertain|Hold|Planned|Swap'` in activator; paste.

- [ ] **Step 5: Commit** `zdd-readiness: an uncertain nested start is a hold failure, never a re-runnable abandon (ruling 206)`

---

### Task 7: Execution admission: worker gate, managed revalidation, uncaptured recycle

**Files:**
- Modify: `internal/activator/backend_exec.go:~2113-2160` (`StartWorkerStaged`), `internal/activator/workerbuild.go:~51-118` (`startWorker`, `runBeforeExec`), `:~160-186` (`recycleWorkerLocked`)
- Modify: `internal/rollout/scheduler.go:96-103` (`execute`)
- Test: `internal/activator/exec_gate_test.go`, `internal/rollout/manager_test.go`

Rules (Astra R3 §5 item 4, ruling 206 "park worker launch hook"):
1. **Worker gate after preparation.** `runBeforeExec` keeps only the generations-file publication. The caller's ownership gate rides the context (`withExecGate(ctx, gate)`), and `StartWorkerStaged` spawns with `b.spawnInCgroupGated(rt, build, b.launchGate(ctx, rt.Host))`, so the gate runs after cgroup preparation and before every start attempt, as the instance path does (`cgroup.go:~354/367`).
2. **Managed revalidation.** `execute`: after reading the row, `if !Managed(*rt) || release.RouteAccount(*rt) != s.key.acct || rt.App != s.key.app` the attempt finishes `SwapAbandoned` with `Executed: false` and reason `"route no longer managed"`, before `resolve`.
3. **Uncaptured recycle.** In `recycleWorkerLocked`, when `a.CoalesceRecycle != nil` (an armed or refusing box) and the worker has no captured build (`e.workerServing.StagedPath == ""`), it never respawns from the live link: with a live process (`stopOld != nil`) the recycle is skipped and the process keeps running; with none, the entry stays down and the agent logs once per host. Today's behaviour stays when `CoalesceRecycle` is nil.

- [ ] **Step 1: Write the failing tests**: (a) in `exec_gate_test.go`, modelled on the instance tests at `:419`, a worker start whose gate starts refusing during cgroup preparation never execs (the fake cgroup's prepare hook flips the gate); (b) a worker start retry after a failed PID move re-asks the gate; (c) in `manager_test.go`, a queued host whose row becomes a unit route before dispatch gets no `Swap` call and its outcome is abandoned; (d) an armed box's recycle of a worker with no captured build makes no live-link spawn.

- [ ] **Step 2: Run to verify they fail.**

- [ ] **Step 3: Implement.**

- [ ] **Step 4: Run** activator `-run 'Gate|Worker|Recycle'` (unsandboxed), rollout package, `mutate.sh 300`; paste.

- [ ] **Step 5: Commit** `zdd-readiness: worker gates after cgroup preparation, managed revalidation at dispatch, no uncaptured live-link recycle`

---

### Task 8: Host retries are signed by the console

**Corrections (plan review round 1; these override the text below):**
- **Bind the grant to the restriction's epoch, not to a watermark** (plan review #13). Drop `restartMemory.retryAt`. `HostRetryGrant` gains `Epoch uint64 \`json:"epoch"\``. The epoch of a host's restriction is a persisted book sequence number: `FailureRecord` gains `Epochs map[string]uint64 \`json:"epochs,omitempty"\`` (per host: `++seq` each time `Record` changes that host's standing in the record, and the record-wide entry under key `""` each time the record is created or merged), and a marker's epoch is its `Seq` (Task 4). `func (b *FailureBook) RestrictionEpoch(acct, app, build, host string) uint64` returns the larger of the record's `""` and host entries, or the marker's `Seq` when the marker names `build`.
- **Status publishes it.** `status.RolloutHost` gains `HeldEpoch uint64 \`json:"held_epoch,omitempty"\``, set beside `HeldBy` (`internal/rollout/status.go:74-93`). The console's rollout join and `retryHostBuild` (`internal/console/apiretry.go:41-58`) return `(build string, epoch uint64)`, and the door signs both. An absent epoch (an older box) is 0; the console still signs it, and the box refuses a 0 epoch.
- **Which build a grant clears** (plan review #5). `Manager.RetryHost(host, build string, epoch uint64) bool` clears (a) the record of `build` for the host, when one holds the host, and (b) the host's marker, when it names `build`; each only if `RestrictionEpoch(..., build, host) == epoch`. A grant naming the marker's build B while the slot wants A clears the marker and nothing else. Drop the `wantBuild` equality rule in the body below.
- The box's verification keeps the 24-hour issue window (`HostRetryGrantTTL = 24 * time.Hour`) as a backstop.
- Tests replace the replay test: a grant whose epoch predates a second failure of the same build on the same host clears nothing; reopening the book and replaying a used grant clears nothing new.

**Files:**
- Create: `internal/release/hostretry.go`, `internal/release/hostretry_test.go`
- Modify: `internal/release/restartscope.go:107-110` (`RestartScope.Signed`), `:161-175` (`ValidRestartScope`), `AppendRestartScope` if it compares scopes
- Modify: `internal/console/apiretry.go:61-71` (`requestHostRetry`), `:76-122`, and the browser twin `internal/console/browserretry.go`
- Modify: `internal/host/restartscope.go:~179-200` (`retryRequests` returns the signed field), `internal/host/restart.go:~838-863` (verify before `retryHost`), `internal/host/agent.go:~2296-2306` (`restartMem.pub`, `retryHost` signature)
- Modify: `internal/rollout/manager.go:515-553` (`RetryHost` takes the build)
- Test: `internal/console/apiretry_test.go` (or the existing retry test file), `internal/host/restart_test.go` (or the retry tests' file), `internal/rollout/manager_test.go`

**Interfaces:**
- Produces:

```go
// internal/release/hostretry.go
package release

// HostRetryGrant is the console's signed `carlos instances retry <host>`
// (spec §8.4, §11): a box clears a host's failure hold only on a grant the
// deployment root signed, naming that account, app, host and build.
type HostRetryGrant struct {
	V        int    `json:"v"`
	Account  string `json:"account"`
	App      string `json:"app"`
	Host     string `json:"host"`
	Build    string `json:"build"`
	IssuedAt string `json:"issued_at"` // RFC 3339, UTC, whole seconds
}

// HostRetryGrantTTL is how long a box accepts a grant after it was issued.
const HostRetryGrantTTL = time.Hour

var ErrHostRetryGrant = errors.New("host retry grant: not valid for this request")

func SignHostRetry(sg Signer, acct, app, host, build string, now time.Time) (string, error) {
	g := HostRetryGrant{V: 1, Account: acct, App: app, Host: host, Build: build,
		IssuedAt: now.UTC().Truncate(time.Second).Format(time.RFC3339)}
	wire, err := SignRootEnvelope(sg, g)
	return string(wire), err
}

// VerifyHostRetry checks wire against the root key and the entry it rode in:
// same account, app and host, issued no later than now plus a minute of skew
// and no earlier than now minus HostRetryGrantTTL.
func VerifyHostRetry(pub ed25519.PublicKey, wire, acct, app, host string, now time.Time) (HostRetryGrant, error) {
	var g HostRetryGrant
	if err := VerifyRootEnvelope(pub, []byte(wire), &g); err != nil {
		return HostRetryGrant{}, err
	}
	at, err := time.Parse(time.RFC3339, g.IssuedAt)
	if err != nil || g.V != 1 || g.Account != acct || g.App != app || g.Host != host || g.Build == "" ||
		at.After(now.Add(time.Minute)) || at.Before(now.Add(-HostRetryGrantTTL)) {
		return HostRetryGrant{}, ErrHostRetryGrant
	}
	return g, nil
}
```

  - `RestartScope.Signed string \`json:"signed,omitempty"\``: only a `retry` scope may carry it; `ValidRestartScope` refuses it on other kinds and above `MaxRootEnvelopeBytes`.
  - `Manager.RetryHost(host, build string) bool`: clears only when `build` is the build the host's queue slot wants (`h.wantBuild`) or, with no slot build, the build a record holds the host on; a mismatch clears nothing (and leaves the marker).
  - `restartMemory.pub ed25519.PublicKey`; `restartMemory.retryHost func(host, build string) bool`; `restartMemory.retryAt map[string]time.Time` (newest accepted `IssuedAt` per host).

Rules:
1. **Console.** `requestHostRetry(ctx, acct, app, host, build)` signs with `release.SignHostRetry(release.RootSigner(s.cfg.Key), ...)` and appends `RestartScope{Kind: retry, Value: host, Signed: wire}`. With `s.cfg.Key == nil` the door answers 503 and appends nothing. For its text, reuse the read-only console's existing 503 wording for promote (`internal/console/server.go:~4135`) if it reads right for a retry; otherwise add a draft with `// TODO-COPY(zdd-readiness.retry-no-key)`.
2. **Box.** Every retry entry is verified with `mem.pub` against the entry's account, app and host at the box's clock. Unsigned, unverifiable, expired, not newer than `retryAt[host]`, or a build that does not match: logged (`agent: retry requested for %s, dropped: %s`) and skipped. A box with no public key drops every retry. Verified: `retryAt[host] = IssuedAt`, then `retryHost(host, g.Build)`.
3. Old entries stay readable; a new box reading an old console's unsigned entry drops it.

- [ ] **Step 1: Write the failing tests**: release (sign/verify round trip; wrong host, wrong app, expired, future, tampered payload, scoped signer refused); console (the API and browser doors append an entry whose `signed` verifies for the held build; a console with no key answers 503 and appends nothing); host (Review Focus 4: an unsigned entry, a replay of the same grant under a new entry id, and a grant for another build each leave `RetryHost` uncalled; a valid grant calls it once with the grant's build); rollout (`RetryHost("h", "v9")` with the slot wanting v2 clears nothing).

- [ ] **Step 2: Run to verify they fail.**

- [ ] **Step 3: Implement.** Keep the restart-scope id/mark logic as is.

- [ ] **Step 4: Run** release, rollout (sandboxed), console and host `-run 'Retry|RestartScope'` (unsandboxed); paste.

- [ ] **Step 5: Commit** `zdd-readiness: host retries are root-signed in the console and verified on the box (Q4, spec §11)`

---

### Task 9: Declined boxes gate the actual launch and honour markers

**Corrections (plan review round 1; these override the text below):**
- **Every declined launch mechanism is gated against the identity it actually starts** (plan review #6): flat instance (`launchGate`), nested instance (`runExecGate`, `backend_exec.go:1682`), worker (`StartWorkerStaged`, `onto == nil`), and a claims-backed wake (after the claim snapshot resolves the live link, `claim_snapshot.go:87`, before the root request; Task 1 makes a claims box decline, so this is a declined box's path). Each passes the version it will start.
- **No flat read/exec race.** When `LiveExecGate` is set, the flat live-link spawn resolves the link once (`release.ResolveLive`), gates that version, and execs the resolved staged path instead of the link. With the gate nil (every other box) the spawn is byte-for-byte today's. Drop the "Residual" sentence in rule 1.
- **Markers on a declined box**: the installed good build is refused while a marker stands (as in the body); Task 10's pointer pass clears the marker on a signed rollback, as the armed box does (Task 3), so `HeldDownRefusal`'s "roll back or retry" stays true on a declined box.

**Files:**
- Modify: `internal/activator/genbackend.go:~65-114` (`launchGate`), `internal/activator/backend_exec.go:~1465-1500` (live-link spawn), `:~2113-2150` (`StartWorkerStaged`, `onto == nil`), `internal/activator/activator.go:~2641` (`convergeWorkerLocked`), `internal/activator/workerbuild.go:~160-186` (`recycleWorkerLocked` converge branch)
- Modify: `internal/host/rolloutdeclined.go` (`failureHolds`: markers, the gate, activation on markers or records)
- Test: `internal/host/rolloutdeclined_test.go`, `internal/activator/exec_gate_test.go`

**Interfaces:**
- Produces: `ExecBackend.LiveExecGate func(rt registry.Route, build string) error` (nil everywhere except a declined box with records or markers). It is asked (a) in `launchGate` when the context carries no gate, with the version read from the live link immediately before the start; (b) in `StartWorkerStaged` with `onto == nil`, with the resolved `id.Version`, before the spawn; (c) in `convergeWorkerLocked` and the converge branch of `recycleWorkerLocked` with the live link's version, **before** the old process is stopped: a refusal leaves the worker running its current build (a recycle of a dead worker respawns its captured build when it has one).
- `failureHolds.heldBuild(rt)` also answers for markers (rule below), and `failureHolds.gate(rt, build) error` returns `errors.New(rollout.HeldDownRefusal(b))` or `errors.New(refuseNoSwaps)` when `build` is held for `rt`.

Rules (Astra R1 #6, R2/R3 declined residuals; spec §6.4, §8.4):
1. **Exec-time check.** The plan-time refusal stays; the gate repeats it at the real launch with the build actually being started. Residual, recorded in the ledger: a flat instance execs the live-link path, so a root repoint landing between the gate's read and `exec` is not caught.
2. **Markers on a declined box.** With a marker `m` for the route's account and app: the marked build may start unless held down, or blocked by a record while the route has a good build; any other build a record blocks is refused; **the installed good build is refused** (a declined box has no pointer read in this path; recovery is a signed retry, Task 10, or a newer build); any other build starts.
3. `honourDeclinedFailures` installs the planner and the gate when the book holds records **or** markers.

- [ ] **Step 1: Write the failing tests**: (a) `TestDeclinedBoxRefusesAHeldBuildAtLaunch`: plan with the live link on the good build, repoint to the held build before the start, assert no exec (use `TestDeclinedBoxKeepsAHeldBuildOffARealActivator`, `rolloutdeclined_test.go:314`, as the template); (b) a declined worker whose live link moves to a held build is not stopped by the sweep's converge and keeps its process; (c) a declined worker that crashes with the live link on a held build respawns its captured build, or stays down with none; (d) markers: a declined box with only a marker refuses the installed good build and the marked build when a record blocks it; (e) flag off: `LiveExecGate` is nil and no leg changes (`legsAreTodays`).

- [ ] **Step 2: Run to verify they fail** (unsandboxed).

- [ ] **Step 3: Implement.** `LiveExecGate` is consulted under the activator's lock in (c); it must only read memory and the registry row and the link, as `heldBuild` does today.

- [ ] **Step 4: Run** host `-run 'Declin|Rollout'` and activator `-run 'Gate|Worker|Converge'`; paste.

- [ ] **Step 5: Commit** `zdd-readiness: a declined box gates the actual launch, workers included, and honours hold-ran markers`

---

### Task 10: Declined boxes process retries

**Corrections (plan review round 1; these override the text below):**
- **A declined box publishes its holds, and the console lets them be retried** (plan review #5). `rolloutStatusProbe`'s declined report (`internal/host/rollout.go:481-490`) gains `Hosts` entries for each managed route `failureHolds` holds: `HeldBy` (the held build), `HeldEpoch` (`RestrictionEpoch`, Task 8), `Attempt` `status.AttemptDown` when held down or when a marker refuses the installed build, else `status.AttemptFailed`. `retryHostBuild` (`internal/console/apiretry.go:50`) accepts a report whose `Unavailable` is a decline code (`rolloutwords.UnavailableDeclines`) when the host has a `HeldBy`; it still refuses every other `Unavailable`. Add an API-to-box test: a declined box's held host gets a signed grant from the door, and the declined box's restart leg clears it.
- Retry clearing follows Task 8's epoch rule (`retryHost(host, build, epoch)`).
- The pointer pass also clears a marker on a signed rollback (Task 3's rule), and runs for apps with markers as well as records.

**Files:**
- Modify: `internal/host/rolloutdeclined.go` (`failureHolds.retryHost`, `failureHolds.applyPointers`; the comment that says the book is never written)
- Modify: `internal/host/agent.go:~2259-2306, ~2390-2396` (`fastConvergeLoop`: set `restartMem.retryHost` for a declined box; run the pointer pass)
- Test: `internal/host/rolloutdeclined_test.go`

**Interfaces:**
- Consumes: Task 8's `restartMemory.retryHost func(host, build string) bool` and signed-entry verification; Task 4's `FailureBook.RetryHost`, `ClearOnPointer`, `ClearHoldRan`.
- Produces: `func (h *failureHolds) retryHost(host, build string) bool` and `func (h *failureHolds) applyPointers(ctx context.Context, read func(ctx context.Context, acct, app, channel string) (*release.ChannelPointer, error), routes []registry.Route)`.

Rules (Astra R2 ruling 194 "startup-only records"):
1. A verified signed retry for a host clears that host from the record for `build` (`RetryHost`) and its marker (`ClearHoldRan`), and saves.
2. At most every 30 s (the armed `pointerReadEvery`), for each app with a standing record, the declined box reads each followed channel's signed pointer (`release.Channel` with the agent's store and key, both already open in `fastConvergeLoop`) and clears a record whose build the pointer names with a newer `PromotedAt` (`ClearOnPointer`). A read error is logged and skipped. No store or no key: no pointer pass, logged once.
3. Records and markers now change in the running process, so `heldBuild` reads the live book (it does today); no restart is needed.

- [ ] **Step 1: Write the failing tests**: a declined box with a hold-down of v2 on h wakes h onto v2 after a verified signed retry for (h, v2) (planner returns today's plan); after a pointer naming v2 with a newer `PromotedAt` the record is gone; an older pointer clears nothing; a retry for another build clears nothing.

- [ ] **Step 2–4:** run to fail, implement, run `-run 'Declin|Retry'` (unsandboxed); paste.

- [ ] **Step 5: Commit** `zdd-readiness: declined boxes process signed retries and promote --retry pointers`

---

### Task 11: Bounded reads and acknowledgement writes

**Corrections (plan review round 1; these override the text below):**
- **Ack order is fixed at the transition** (plan review #10). `configTrack.activate` (called under `m.mu`) hands back the ack together with a per-host sequence taken under `m.mu`; the writer (`Manager.acks`) keeps the newest pending ack per host (latest sequence wins, older ones are dropped, never written after a newer one), submission never blocks, and one drain goroutine writes. When the manager's context ends, the drain finishes the write in progress and stops.
- **Flag off keeps `configAckFunc` as it is** (plan review #11): put the 10 s bound in the armed path only (the writer wraps each write in `context.WithTimeout`), not in `configAckFunc`.
- **A graceful stop joins the scheduler** (Task 5 review; spec §8.6 "Shutdown cancels and joins the whole operation set"). Add `func (m *Manager) Close(ctx context.Context)`: it stops admitting attempts, then waits (bounded by ctx) for every in-flight `execute` goroutine to finish its `done()` (including the book write and the intent `End`) and for every in-flight `OnGen` call to return, then stops the ack writer as above. `internal/host/agent.go` calls it right after `svc.Shutdown(ctx)` (~line 732), with the same 60 s context, when a manager is armed. Test: an attempt whose `Swap` is cancelled by shutdown has its outcome and `End` on disk when `Close` returns (no intent left in the file), so a planned agent restart mid-swap reconciles nothing.

**Files:**
- Modify: `internal/rollout/policy.go:~60-117` (`policyCache`)
- Modify: `internal/rollout/manager.go:418-466, 555-568, 831-887, 1017-1057` (`applyPointers`, `failedUnder`, `resolve`/`stagedDemand`, `activationDone`, `configSlept`), `internal/rollout/scheduler.go:46-50, 113-117`, `internal/rollout/wakeplan.go:254-283`
- Modify: `internal/host/agent.go:~1166-1173` (`configAckFunc`)
- Test: `internal/rollout/policy_test.go`, `internal/rollout/manager_test.go`

**Interfaces:**
- Produces: `const readBound = 5 * time.Second` (rollout); `failedUnder(ctx context.Context, rt registry.Route, build string) time.Time`; `Manager.acks` (an ordered asynchronous writer: one drain goroutine, FIFO, no lock held while writing).

Rules (Astra R1 Flag triage T3/T8/T10, R3 §5 items 6-7):
1. **Policy cache.** Each fill takes a per-key generation before `load`; the result is stored only if no newer fill has stored since. A failed read stamps `failedAt` and is not retried for `policyRetryAfter = 5 * time.Second`; meanwhile `get` answers the last value, or hold for an app never read. Missing (an empty manifest from `appconfig.Get`) is the defaults; an error is never the defaults.
2. **Bounds.** `policies.get`'s load, `Deps.Pointer` and `Deps.Demand` run under `context.WithTimeout(ctx, readBound)`. `failedUnder` takes the caller's context: the wake's in `planRow`, the attempt's in `execute`.
3. **Acks.** `activationDone` and `configSlept` hand `write` to `m.acks` instead of calling it, so neither a scheduler slot (`done()` calls `configActivated` before `finish`) nor the scheduler loop waits on the store. `configAckFunc` bounds its store write with a 10 s timeout.

- [ ] **Step 1: Write the failing tests**: (a) an older slow fill finishing after a newer one does not overwrite it (gate the loader on channels); (b) a failing loader is called at most once within `policyRetryAfter`; (c) a blocked ack writer does not stop `done()` from freeing the slot (the next attempt is admitted while the writer blocks); (d) a blocked pointer read in `failedUnder` returns zero once the wake's context is cancelled.

- [ ] **Step 2–4:** run to fail, implement, run the rollout package and host `-run 'ConfigAck|Rollout'`; paste.

- [ ] **Step 5: Commit** `zdd-readiness: versioned policy fills, bounded store reads, acks off the scheduler's path`

---

### Task 12: Workers record clean one-shots and wait out a fallback; halts are always announced

**Files:**
- Modify: `internal/activator/workerswap.go:~173-192` (`SwapResult.CleanExit`), `internal/activator/activator.go:~1209-1222, ~2484-2562` (`WakeWorker` budget)
- Modify: `internal/rollout/scheduler.go:181-194` (`done`), `internal/rollout/manager.go` (a good write for a clean exit), `internal/rollout/wakeplan.go:469-476` (`buildFailed` with no scheduler)
- Test: `internal/activator/workerswap_test.go` (or the worker test file), `internal/rollout/worker_test.go`, `internal/rollout/wakeplan_test.go`

**Interfaces:**
- Produces: `activator.SwapResult.CleanExit bool`: the replacement's build exited cleanly as a one-shot (spec §6.5 success).

Rules:
1. **One-shot good** (spec §6.5, §8.5): `done()` for `SwapDone` with `res.CleanExit` writes the good build from `res.Gen` without `acknowledged` (the attempt still holds the host's slot, so no newer generation can have completed). Snapshot rules from Task 4 apply (`d.BookSeq`).
2. **WakeWorker budget:** while the entry is a first wake, `WakeWorker`'s wait extends once to `arrival + firstWakeBudget`, as request waits do (`activator.go:~1209-1222`).
3. **Halt event:** `buildFailed` with no scheduler emits `EvCohortHalted` with the approved text when `Record` reports the record new, as `cohortFailed` does.

- [ ] **Step 1: Write the failing tests** for each rule (a parked one-shot replacement records good; a first-wake worker whose fallback takes 40 s returns success to `WakeWorker`; a first-wake failure on a host with no scheduler emits the halt event).

- [ ] **Step 2–4:** run to fail, implement, run activator `-run 'Worker'` and the rollout package; paste.

- [ ] **Step 5: Commit** `zdd-readiness: clean one-shot replacements record good, WakeWorker waits a fallback out, halts are announced`

---

### Task 13: Fallback artifacts stay, and a hold build is published before its incumbent stops

**Corrections (plan review round 1; these override the text below):**
- **Publish outside `a.mu`** (plan review #10). `persistGenerations` takes `genMu` then `a.mu` (`generations_file.go:238`), and `swapHold`'s admit body runs under `a.mu` (`swaphold.go:107`). So: register the pending generation under `a.mu`, release it, persist, re-acquire `a.mu`, and re-run the full admission checks (ownership, newest, the entry's state) before `beginStopLocked`. Any check that fails withdraws the pending entry and abandons with `Executed: false`.
- The retention change is an authorized flag-off exception (Global Constraints).

**Files:**
- Modify: `internal/host/retention.go:41-46`
- Modify: `internal/activator/swaphold.go:~110-135, ~245-255`, `internal/activator/generations_file.go:~151-181`
- Test: `internal/host/retention_test.go`, `internal/activator/swaphold_test.go` (or the hold tests' file)

Rules (Astra R1 T12, R3 §5 item 9):
1. Bucket retention pins `rt.GoodVersion` beside `Version` and `PrevVersion`.
2. `swapHold` registers the hold build's generation as pending (as `startWorker` does with `genPending`) and persists the generations file **before** `beginStopLocked` stops the incumbent; a failed persist abandons the swap (`Executed: false`) with the incumbent still serving. The pending entry is dropped when the hold withdraws.

- [ ] **Step 1: Write the failing tests**: retention keeps a good build no route installs; a hold swap whose generations write fails leaves the incumbent running and returns abandoned; the generations file names the hold build before the incumbent's stop begins (record the order in the fakes).

- [ ] **Step 2–4:** run to fail, implement, run host `-run Retention` and activator `-run 'Hold|Generations'`; paste.

- [ ] **Step 5: Commit** `zdd-readiness: retention pins the good build; a hold build is published before its incumbent stops`

---

### Task 14: The stage relay gives up, and a populated legacy parent is refused

**Flag blocker confirmed on the dev box (#272, 2026-10-11, arm64, kernel 6.12.105, systemd 257, real-unit check (d)):** a generation started under a legacy `carlos-exec@<host>` parent that still holds a process starts silently with NO memory limit, while the strict path refuses. The guard must fail closed and be reported: the generation start is refused while the host's parent holds processes directly, with an agent log line naming the host and the pid count. Unit-test it with a fake cgroupfs, and add a kernel-gated test (skipped unless the env the existing kernel tests use, e.g. `CARLOS_CLAIM_SPAWN_KERNEL=1` with `cgroup.Setup`, is present) that puts a process directly in a delegated parent and asserts the generation start is refused and creates no `g*` child.

**Corrections (plan review round 1; these override the text below):**
- **Refuse direct parent members whatever children exist** (plan review #12). `ensureParent` (`internal/cgroup/gen.go:127`) returns an error now (its signature changes; update every caller, including `StartChildGated` at `:168`). It reads the parent's own `cgroup.procs` (direct members only, not `cgroup.events`' descendant population) and returns `ErrLegacyParentPopulated` whenever that file lists a process, with or without `g*` children. Replace the "one with a `g1` child still passes" test with `TestAPopulatedParentWithAStaleEmptyGenerationIsRefused`; keep a test that an empty parent with live `g*` children passes.

**Files:**
- Modify: `internal/host/stagerelay.go` (`pendingHead` gains `since time.Time`; `relayGiveUp = time.Hour`)
- Modify: `internal/cgroup/gen.go:127-146` (`ensureParent`), and its caller's error path in `internal/activator` (abandon before exec)
- Test: `internal/host/stagerelay_test.go`, `internal/cgroup/gen_test.go`

Rules (Astra R1 T15 and T14 L146):
1. A relay head that root has neither installed nor refused for `relayGiveUp` is dropped with a log line; the next blocked pass notes it again.
2. `ensureParent` reads the parent's own `cgroup.procs`; when it holds a process and no generation child exists, it returns `ErrLegacyParentPopulated` and creates no child. The generation start fails before exec (the swap abandons with `Executed: false`; a wake fails as a start failure before exec).

- [ ] **Step 1: Write the failing tests** (a relay head older than `relayGiveUp` stops touching the marker; a fake cgroupfs parent with a pid in `cgroup.procs` and no `g*` child makes `ensureParent` return `ErrLegacyParentPopulated`; one with a `g1` child still passes).

- [ ] **Step 2–4:** run to fail, implement, run host `-run Relay`, cgroup `-run Parent` (unsandboxed); paste.

- [ ] **Step 5: Commit** `zdd-readiness: the stage relay gives up after an hour; a populated legacy parent refuses generation placement`

---

### Task 15: F6 and leftover executable files in /run

**Files:**
- Modify: `internal/activator/opset.go:~244-285` (`genChildEmpty`)
- Modify: `internal/instancectl/gen.go:~259-300` (`sweepGenLeftovers`)
- Test: `internal/activator/opset_test.go`, `internal/instancectl/gen_test.go`

Rules (Astra IMPL-3 #F6 `CODEX-FINDINGS-ZDD-IMPL-3.md:44-49`; IMPL-2-R2 `:45`):
1. **F6** (flag off): when `KillGen` answers `errNoGenContainment` for a flat exec row's retained failed wake, `genChildEmpty` requires the host cgroup proven empty (`hostKiller.KillHost`) before the wake counts as stopped. **Before writing it, establish** what else can live in `carlos-exec@<host>` at that moment (co-spawned sidecar members, another live entry). If a live process of another entry can be there, use the non-killing `Cleanup.Populated()` check instead of `KillHost`, so the wake stays retained (and its lease kept) until the cgroup empties on its own. If neither is safe, stop and report BLOCKED with what you found; the item is then listed as owed.
2. **/run sweep:** `sweepGenLeftovers` also walks `ExeRoot` for generation exe directories (`<iid>@<n>`, parsed with the names helpers) that have no drop-in, are not starting (`genStarting`), whose unit systemd reports inactive or failed and whose cgroup is empty, and removes them under `genMu`. Host directories (no `@`) are never touched.

- [ ] **Step 1: Write the failing tests** (F6: a flag-off retained wake whose leader exited is not released while the host cgroup is populated, and is released once it is empty; sweep: an exe-only leftover is removed, a starting one and a host dir are kept).

- [ ] **Step 2–4:** run to fail, implement, run activator `-run 'Unproven|Retained|GenChild'` and instancectl `-run Sweep` (unsandboxed); paste.

- [ ] **Step 5: Commit** `zdd-readiness: F6 proves the host cgroup empty for a retained flag-off wake; /run exe leftovers are swept`

---

### Task 16: Simulation: new invariants, new mutants, one 3000-seed sweep

**Files:**
- Modify: `internal/rollout/sim_test.go`, `internal/rollout/testdata/rolloutsim/mutate.sh:58-63`
- Modify: the marked lines in `internal/rollout/wakeplan.go`, `failures.go`, `scheduler.go` (add `// rolloutsim:Mn` markers)

Rules:
1. Add mutants for this plan's invariants, each marked on exactly one line, each with the invariant that must catch it:
   - **M7** (N7): the marked-build `Blocks` check in `decideHoldRan` disabled (`if false && hasGood && in.Book.Blocks(`). Caught by I1 (no instance boots a build a record holds for it) now that the exemption is gone.
   - **M8** (N6): `ClearHoldRanBefore`'s sequence test disabled. Needs an invariant that a good write decided before a hold build ran never clears that build's marker; add it to the simulation as I5 if the simulation models good writes racing marks, otherwise make M8's kill a named unit test (rule 2).
   - **M9** (retry ordering): `preRetryLocked` returns false. Invariant: a retry is never undone by an outcome that began before it (I6).
   - **M10** (F5): a failed `Begin` does not refuse. Invariant: after an injected save failure and a simulated restart, no failed or held build boots on an instance it failed on (I7). Add a fault-injecting book path to the simulation if needed.
   - **M11** (plan review #3): `failCohort` halts on a `PreRetry` disposition. Invariant I6 (a retry is never undone) must cover the cohort halt, not only the book.
   - **M12** (plan review #4): `markLocked` keeps the old `Seq` for a different execution of the same build. Caught by I5 or by the extended N6 unit test (rule 2).
2. If a mutant's invariant cannot be modelled in the simulation, extend `mutate.sh` so a mutant line may name its own test (`-run`) instead of `TestSimInvariants`, keep the integrity rules, and say so in the script's header.
3. Run `uptime` first (back off above load 12), then once: `nice -n 19 internal/rollout/testdata/rolloutsim/mutate.sh 3000` with the sandboxed exports. Want a PASSED baseline and CAUGHT for M1 to M12. Paste the verdict lines.

- [ ] Steps: add invariants and mutants, run `mutate.sh 300` until clean, commit, run the 3000 sweep, commit any fixes and the evidence in the report.
- [ ] **Commit** `zdd-readiness: simulation invariants and mutants for markers, retries and intents`

---

### Task 17: A host that is starting or stopping is never counted done

**Files:**
- Modify: `internal/status/snapshot.go` (`RolloutHost.Transient`), `internal/rollout/status.go:~40-95` (`Manager.Status`)
- Modify: the console's rollout view struct and its join (`internal/console/apiinstances.go`, search `apiRollout` / the rollout view the list returns), the CLI's `apiRollout` and `classifyRollout` (`deploywatch.go:~182`)
- Test: `internal/rollout/status_test.go`, `internal/console/rolloutjoin_test.go`, `deploywatch_test.go`

**Interfaces:**
- Produces: `status.RolloutHost.Transient bool \`json:"transient,omitempty"\``: the activator owns the host (`Owns`) but has no current generation for it (`Current` false), so its process is starting or stopping and the cached serving build says nothing settled. The console view carries it as `transient`.

Rules (Astra R3 §5 item 10; plan review #8): spec §9.1 counts a **sleeping** instance as "installed, awaiting first wake"; a host caught mid-wake or mid-stop is neither asleep nor awake.
1. `Manager.Status` asks `Owns` and `Current` for each host with no manager or scheduler lock held (the lock order on `Manager`), and for a transient host reports `Transient: true`, `Awake: false`, and no `Serving` from the stale cache.
2. `classifyRollout` returns pending for a transient host, so the watch neither counts it done as asleep nor as swapped.

- [ ] **Step 1: Write the failing tests**: `Status` reports a host the fake activator owns with no current generation as transient with no serving build; the console list carries `"transient": true`; the watch keeps a transient host pending through a sweep and completes once it reports asleep or swapped.
- [ ] **Step 2–4:** run to fail, implement, run rollout `-run Status`, console `-run Rollout`, root `-run TestWatch`; paste.
- [ ] **Step 5: Commit** `zdd-readiness: a host starting or stopping is reported transient and never counted done`

---

### Task 18: Roll notes, the owed list, and the copy index

**Files:**
- Modify: `docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-roll-notes.md` ("Before Gate E: owed", the claims reply, a new "Claims-on boxes" note)
- Create: `copy-review/strings.json` (only if any `TODO-COPY` marker exists on the branch)

- [ ] **Step 1:** `git grep -n 'TODO-COPY'` and build `copy-review/strings.json` in the copy-review skill's format (`id`, `section`, `label`, `text`, `context`, `notes`), one row per marker, the `id` matching the marker. If there are none, write none and say so.
- [ ] **Step 2:** Update the roll notes: which #272 items this branch closes (task commit refs filled in by the controller), the claims-on limitation and its reason code, the retry-signing roll order (boxes before consoles; unsigned retries are ignored), the schema-2 downgrade note, and the real-box checks unchanged (D1, the kernel suite, real-unit checks, the flagship probe).
- [ ] **Step 3:** Roll note from #272: `platform-infrastructure/scripts/update-platform-binary.sh` does not restart `carlos-instance-control`; use `carlos system update` (main's system apply restarts it, 0bb57a31), or restart it by hand after the old script.
- [ ] **Step 4: Commit** `zdd-readiness: roll notes and copy index`

---

### Task 19: A killed cgroup is recreated before anything respawns into it

(Added 2026-10-11 from the dev-box run on #272. Execute it before Task 15, which uses `KillHost`.)

**Flag blocker confirmed on the dev box** (arm64, kernel 6.12.105, systemd 257): once `cgroup.kill` has been written to a cgroup, every later `clone3(CLONE_INTO_CGROUP)` into that same cgroup is SIGKILLed at once. Recreating the cgroup fixes it. `TestClaimContainmentCleanupRespawn` (`internal/activator/claimspawn_kernel_linux_test.go:23`) reproduces it 3 of 3 on the box. Exposed: `ExecBackend.KillSidecar` (`internal/activator/genbackend.go:340`, the role-sidecar child) and `ExecBackend.KillHost` (`:431`, `carlos-exec@<host>`), which kill a cgroup and keep it. Generation kills remove their cgroup and are safe.

**Files:**
- Modify: `internal/cgroup/cleanup_linux.go:~282` (`Cleanup.KillAndEmpty`) and/or the cgroup `Manager`'s start path (`internal/cgroup/cgroup.go:~295-370`), `internal/cgroup/cleanup_other.go` if a signature changes
- Modify: `internal/activator/genbackend.go:340-370, 431-449` if the fix lives there
- Test: `internal/cgroup/*_test.go` (fake cgroupfs), the kernel-gated `internal/activator/claimspawn_kernel_linux_test.go`

Rules:
1. After a `cgroup.kill` on a cgroup we keep (the host cgroup, a role child), nothing is ever cloned into that same directory again. Before the next spawn into it, the emptied cgroup (and any empty child directories left under it) is removed and created fresh. Choose the place, removal right after the proven-empty kill or recreation at the next start, and say why. Either way, the recreated cgroup gets the same limits and controller settings it had (the ones the start path applies when it creates the cgroup), and it stays under the same parent, so the parent's accounting is unchanged.
2. Removal must not race a concurrent start into the same cgroup. Use whatever lock the start path already holds for that host, or add one, and document it.
3. A removal that fails (busy, EBUSY on a cgroup that is somehow repopulated) is an error: the kill is not proven, and the next start refuses rather than clone into a killed cgroup.
4. Flag off: `KillHost` is reached with the flag off only through Task 15's F6 path. The new behaviour applies there too; it is an authorized exception (it only replaces a cgroup that was just proven empty).

- [ ] **Step 1: Write the failing tests**: a fake-cgroupfs test that a kill of a kept cgroup followed by a start creates a fresh directory (new inode, or a recorded remove-then-create) with the limit files written again; a test that a failed removal makes the next start refuse; and make `TestClaimContainmentCleanupRespawn`'s kernel-gated path (unchanged gate, `CARLOS_CLAIM_SPAWN_KERNEL=1`) exercise the real `KillHost`/`KillSidecar` then respawn sequence if it does not already. The kernel test cannot run on this build box; say so, and leave it for the dev-box rerun.
- [ ] **Step 2–4:** run to fail, implement, run cgroup and activator `-run 'Kill|Respawn|Cleanup|Sidecar'` (unsandboxed); paste.
- [ ] **Step 5: Commit** `zdd-readiness: a cgroup that took cgroup.kill is recreated before anything respawns into it`
