carlos / platform Public

zdd-readiness: implementation plan for the flag-readiness round (#272)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e61b858e7d3a503623ed389aec9643646b0eb7ba parent 5da1cc9
1 file changed, +978 −0
  • docs/superpowers/plans/2026-10-11-zero-downtime-flag-readiness.md +978 −0
diff --git a/docs/superpowers/plans/2026-10-11-zero-downtime-flag-readiness.md b/docs/superpowers/plans/2026-10-11-zero-downtime-flag-readiness.md
new file mode 100644
index 000000000..d1c30b875
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-11-zero-downtime-flag-readiness.md
@@ -0,0 +1,978 @@
+# 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, except Task 15's F6 change, which is flag-off by nature. Every new hook defaults to nil and is set only on an armed or declined box.
+- **Wire shapes are additive** (AGENTS.md). Restart-scope entries gain an optional `signed` field. The status rollout report gains nothing in this plan.
+- **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 and build, and is accepted for one hour after it was issued, newest-first per host. Cost if wrong: a box replays a captured grant inside its hour after a new failure of the same build on the same host; the residual needs bucket write access and an in-window capture.
+- 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: on a declined box, an installed good build never boots over a hold-ran marker; recovery is a signed host retry or a newer build. Cost if wrong: an explicit `carlos rollback` on a declined box needs a retry as well.
+- Ruling: deferred (not code defects, or not 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, the stale serving/awake status race (spec §9.1 already reports a cold instance as "installed, awaiting first wake"), and every real-box check (D1, kernel suite, real-unit checks, flagship probe).
+
+---
+
+### Task 1: Claims-on boxes decline to arm
+
+**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
+
+**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)
+
+**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)
+
+**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)
+
+**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)
+
+**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
+
+**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
+
+**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
+
+**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
+
+**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
+
+**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
+
+**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.
+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 M10. 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: 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: Commit** `zdd-readiness: roll notes and copy index`