carlos / platform Public

WIP: plan 3 partly fixed for P1 findings; settings plan index started (both writers hit the usage limit)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev bcecbb3c2fd0f8f3cfb867b9451f097d10fdc181 parent 632daf0
2 files changed, +608 −21
  • docs/superpowers/plans/2026-10-07-instance-settings-0-index.md +412 −0
  • docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md +196 −21

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-07-instance-settings-0-index.md b/docs/superpowers/plans/2026-10-07-instance-settings-0-index.md
new file mode 100644
index 000000000..88a07e7ad
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-07-instance-settings-0-index.md
@@ -0,0 +1,412 @@
+# Instance Update Settings: Plan Index
+
+> **For agentic workers:** this index holds no tasks. It fixes the order of
+> the five plans, what each needs from the zero-downtime plans, and the
+> names they share. Read it before any plan, and read the spec before both.
+
+**Spec:** `docs/superpowers/specs/2026-10-07-instance-update-settings-design.md`
+(rev 10). Rev 7 was approved by Paul and accepted by Astra (S5); §4.6 (#253)
+was accepted by Astra in S7. The spec is the authority: where a plan and the
+spec disagree, the spec wins and the plan is the bug. Its §7 and §9–§13
+record every review finding. None may be reopened.
+
+**Zero-downtime set:** `docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md`
+and its six plans (written "zdd-N" below). Their "Cross-plan interface
+ledger" is the source of every zdd name used here.
+
+**Design branch:** `design-zero-downtime-deploys`, in the worktree
+`/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys`.
+It holds docs only. Implementation happens on per-plan branches cut from
+amadan `main`; each plan names its branch and worktree.
+
+**Line numbers** in every plan are from `origin/main` at `cfdb4e12`
+(2026-10-07). `main` moves quickly: each implementer re-reads line numbers
+against the branch point, and finds each edit by the function name the plan
+gives beside the number.
+
+## Order
+
+| # | Plan | Branch | Lands | Depends on |
+|---|------|--------|-------|------------|
+| 1 | `…-1-pointer-digests.md`: #253. `ChannelPointer.Digest` and `HistoryDigests`; the provenance rule in promote, rollback, `FastTrack`, `RebindPointer` and (through them) the `cli.go` direct-store paths; the signed emission marker; root's digest check before staging; the static-site reader on the edge; per-box ratchets for root and the edge; `CARLOS_REQUIRE_POINTER_DIGEST`; `carlos channels resign`; the console's straggler report; its own copy review; roll notes for "writers before readers" | `settings-1-pointer-digests` | **ships first.** Inert until the marker is written: no writer emits a digest, and a ratchet only arms on a pointer that carries one | none (independent of every zdd plan) |
+| 2 | `…-2-promotion-records.md`: §4.1. The signed promotion record (account, app, version, manifest digest) written by every promotion; its verifier; `release.AuthorizedManifest`; `carlos releases authorize` and its console door; `DeleteVersion` removes a pruned build's record; the `internal/updatewords` package | `settings-2-promotion-records` | additive: records are written from merge on, and nothing reads them until plan 4 | 1 |
+| 3 | `…-3-console-settings.md`: §3, §5.1. The settings and `install_request` on the provision record; the app policy on the instances manifest; reconcile resolving defaults into one registry column; `GET`/`PUT /updates`, `POST /updates/install` and the app-policy door, for app members only (the instance-bound half is gated on #155 step 6); the console's refusal of `pin`, `manual` and `major` until every box reports the protecting binary; the control socket's read-only `GET /updates` | `settings-3-console-settings` | dormant on boxes: nothing reads the column until plan 4, and the refusal holds every non-default value until plan 4's boxes report | 2 |
+| 4 | `…-4-root-applies.md`: §4. `release.Target`; the consumed file; the build-state object and its registry copy; the `BeforeExec` write under the lease before every spawn path execs; `install_request` resolution, completion and the merged watermark; held-instance recovery and the wake check; failure records outranking every rule; evidence (pointer, promotion record, staged copy) per install; retention protection; the box report that lifts plan 3's refusal | `settings-4-root-applies` | the behaviour change. A record with no settings resolves to `auto` and rule 4, today's adoption, byte for byte | 1, 2, 3; zdd-1 to zdd-5 merged |
+| 5 | `…-5-surfaces.md`: §5. `carlos apps set` gains the policy flags; `carlos instances set`, `carlos instances update`; `carlos instances list` reasons; the console instance page's Updates section (Eleven-style); *settings not enforced*; docs; roll notes; and the copy review of every string plans 2–5 drafted, which stops for Paul | `settings-5-surfaces` | additive | 3, 4; zdd-6 merged (it adds `carlos apps set` and the instance list's INSTALLED/SERVING/REASON columns) |
+
+Plans 1 and 2 can merge and roll before any zdd plan. Plan 3 can merge
+before plan 4: its console refusal keeps every non-default setting off until
+plan 4's boxes say they enforce and protect them. Plan 4 is the only plan that
+changes what a box installs.
+
+**Split adjusted from the brief:** the console's refusal (§4.4 "refuses to
+enable … until every box … reports a binary that protects them") is in
+plan 3, with the door it guards, so plan 3 can never merge without it. Plan 4
+adds the box-side report that lifts it. Plan 1 carries its own copy review,
+because it ships first and alone.
+
+## What the settings plans take from the zero-downtime plans
+
+Plan 4 is the only consumer. Its Task 0 greps every row against merged
+`main` before any code is written.
+
+| zdd name | Defined | Settings use |
+|---|---|---|
+| `release.GetManifestRaw(ctx, s, acct, app, version) (*Manifest, []byte, error)` | zdd-4 T2 | **shared with settings-1 T1**: whichever lands first defines it, byte for byte as zdd-4 T2 Step 3 spells it; the second skips that step (`git grep -n 'func GetManifestRaw' internal/release` decides) |
+| `release.StagedIdentity{Version, StagedPath, Digest, Demand}`, `release.ResolveLive`, `release.VerifyStaged`, `release.FileSHA256` | zdd-4 T3; `FileSHA256` on main (`proof.go:169`) | the retained identity: a build already staged and verified on the box is recovered from that copy with no promotion record (§4.1) |
+| `release.BoxPins`, `release.PinSource`, `WithPinSource` | zdd-4 T5, T6 | pinned builds and build-state builds join the pin set so a reused-name restage never removes them |
+| `rollout.FailureBook.Blocks(acct, app, build, host string, served bool) bool`, `rollout.OpenFailureBook(dataDir)`, `rollout.FailureFile` | zdd-5 T4 | "failure records outrank all rules" (§4.3), and "if T fails, R completes failed" |
+| `activator.Attempt{…, Cause}`, `CauseInstall` | zdd-3 T1 | an install request's move is an attempt with `Cause: CauseInstall` |
+| `Service.Swap`, `swapOverlap`'s `a.spawnSuccessor(` call, `swapHold`, `startPinned`, `StartPolicy.Fallback`, `startAdmitted` | zdd-3 T4, T6, T10 | spawn paths that must call `BeforeExec` first |
+| `startWorker`, `respawnWorkerLocked`, `recycleWorkerLocked` | zdd-4 T9 | worker spawn and recycle paths that must call `BeforeExec` first |
+| `spawnPlanned`, `spawnPlannedWorker`, `doClaimWake`'s planning block, `ReplaceWorker` | zdd-5 T9, T11 | planned-wake and socketless spawn paths that must call `BeforeExec` first |
+| `persistGenerations` and its call points | zdd-5 T12 | `BeforeExec` sits **after** each `persistGenerations()` call that precedes a spawn, so the generations file names the build before its build state does |
+| registry `GoodVersion`, `ServingVersion` | zdd-5 T2 | `GET /updates` reports serving; recovery never moves onto a build the box failed |
+| `status.HostRollout{Installed, Serving, …}` | zdd-5 T13 | plan 5's list and console join, beside plan 4's `HostUpdates` |
+| `carlos apps set` (`apps_swap.go`), `instances_cli.go` INSTALLED/SERVING/REASON, console instance page rows | zdd-6 T5, T9, T12 | plan 5 extends them |
+
+"Before any generation runs a build against the database" (§4.2) is
+`BeforeExec`. zdd-3 and zdd-5 have no hook of that name. Plan 4 adds the
+field and the calls into their code. With `CARLOS_SWAP` unset, today's wake
+(`doWake`, `doClaimWake`) and worker spawns call it too, because §4.2 applies
+whether or not generations are on.
+
+## Shared interfaces
+
+These names and types are fixed here so plans written in parallel agree. A
+plan may add fields. It may not rename these.
+
+### Plan 1 defines (internal/release unless stated)
+
+```go
+// ChannelPointer gains (additive, omitempty; signed with the payload):
+Digest string `json:"digest,omitempty"` // ManifestDigest of Version's manifest
+HistoryDigests []string `json:"history_digests,omitempty"` // one per History entry, "" where unknown
+
+func ManifestDigest(raw []byte) string // hex sha256 of the canonical manifest bytes (etagOf)
+func (p *ChannelPointer) DigestOf(version string) string // the signed digest for Version or a History entry; "" when none
+func (p *ChannelPointer) CarriesDigests() bool // p.Digest != "": what the ratchet and the flag test
+func (p *ChannelPointer) ChannelHasDigests() bool // head or any history entry carries one: the rollback rule
+var ErrManifestDigest = errors.New("release: manifest does not hash to the digest its signer recorded")
+func CheckManifestDigest(ctx context.Context, s Store, acct, app, version, want string) (*Manifest, []byte, error) // want "": no check
+
+// The emission marker (root-signed, SignRootEnvelope / VerifyRootEnvelope):
+func PointerDigestsKey(acct string) string // "releases/<acct>/pointer-digests"
+type DigestMarker struct {
+ Schema int `json:"schema"` // 1
+ Account string `json:"account"`
+ At time.Time `json:"at"`
+ By string `json:"by,omitempty"`
+}
+func WriteDigestMarker(ctx context.Context, s Store, sg Signer, acct, by string) error
+func RemoveDigestMarker(ctx context.Context, s Store, acct string) error
+func ReadDigestMarker(ctx context.Context, s Store, pub ed25519.PublicKey, acct string) (*DigestMarker, error) // (nil, nil) when absent
+
+// The per-box ratchet:
+const RequirePointerDigestEnv = "CARLOS_REQUIRE_POINTER_DIGEST"
+var ErrPointerWithoutDigests = errors.New("release: pointer carries no digests")
+type DigestRatchet struct{ /* unexported */ }
+func NewDigestRatchet(own string, also []string, require bool) *DigestRatchet // own: the dir this process writes; also: dirs it only reads
+func RootRatchetDir(optDir string) string // <optDir>/pointer-digests (/opt/carlos/pointer-digests)
+func EdgeRatchetDir(dataDir string) string // <dataDir>/pointer-digests (/data/pointer-digests)
+func (r *DigestRatchet) Admit(acct string, p *ChannelPointer) error // nil-safe: a nil ratchet admits everything
+func WithDigestRatchet(r *DigestRatchet) PassOption
+
+// carlos channels resign:
+type ResignPlan struct {
+ Account, App, Channel string
+ Version string
+ Digest string // computed now from the bucket, shown to the operator
+ History []string
+ HistoryDigests []string // after resign: every entry the bucket can answer for
+ Computed []bool // per entry (index 0 = head): true when resign computed it rather than copying it
+ etag string
+}
+func (sg Signer) PlanResign(ctx context.Context, s Store, acct, app, channel string) (*ResignPlan, error)
+func (sg Signer) Resign(ctx context.Context, s Store, plan *ResignPlan, actor string) error // CAS on the plan's etag
+
+// The straggler report:
+type ChannelDigestState struct {
+ Channel string `json:"channel"`
+ Version string `json:"version"`
+ PromotedAt time.Time `json:"promoted_at"`
+ HeadDigest bool `json:"head_digest"` // the head carries a digest
+ HistoryMissing int `json:"history_missing"` // history entries with none
+ Straggler bool `json:"straggler"` // no head digest, marker on, PromotedAt after the marker
+}
+func DigestReport(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string) ([]ChannelDigestState, *DigestMarker, error)
+```
+
+### Plan 2 defines (internal/release)
+
+```go
+func PromotionRecordKey(acct, app, version string) string // "releases/<acct>/<app>/promoted/<version>"
+type PromotionRecord struct {
+ Schema int `json:"schema"` // 1
+ Account string `json:"account"`
+ App string `json:"app"`
+ Version string `json:"version"`
+ ManifestDigest string `json:"manifest_digest"`
+ Channel string `json:"channel,omitempty"` // the channel promoted to; "" for an authorize
+ Authorized bool `json:"authorized,omitempty"` // written by `carlos releases authorize`, not a promotion
+ Actor string `json:"actor,omitempty"`
+ At time.Time `json:"at"`
+}
+var ErrNoPromotionRecord // wraps os.ErrNotExist
+func (sg Signer) WritePromotionRecord(ctx context.Context, s Store, rec PromotionRecord) error // signedWrapper, grant attached for a scoped signer
+func ReadPromotionRecord(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, version string) (*PromotionRecord, error)
+func AuthorizedManifest(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, version string) (*Manifest, error) // record + CheckManifestDigest
+type AuthorizePlan struct { Account, App, Version, ManifestDigest string; Artifacts map[string]Artifact }
+func PlanAuthorize(ctx context.Context, s Store, acct, app, version string) (*AuthorizePlan, error)
+func (sg Signer) Authorize(ctx context.Context, s Store, plan *AuthorizePlan, actor string) error // root only; re-reads, refuses if the digest moved
+```
+
+### Plan 3 defines
+
+```go
+// internal/release/updatesettings.go
+const (
+ UpdatesAuto = "auto"
+ UpdatesManual = "manual"
+ ControlUpdates, ControlChannel, ControlMajor, ControlPin = "updates", "channel", "major", "pin"
+)
+type UpdateSettings struct { // the EFFECTIVE settings: defaults already resolved by reconcile
+ Updates string `json:"updates"` // UpdatesAuto | UpdatesManual
+ Channel string `json:"channel"`
+ Major *int `json:"major,omitempty"`
+ Pin string `json:"pin,omitempty"`
+ InstallRequest uint64 `json:"install_request,omitempty"`
+ InstallFastTrack bool `json:"install_fast_track,omitempty"`
+ Control []string `json:"control,omitempty"` // the app's instance_control
+}
+func ParseUpdateSettings(col string) (UpdateSettings, error) // "" parses to {Updates: auto}: today's behaviour
+func (u UpdateSettings) Column() string // canonical JSON for the registry column
+func (u UpdateSettings) Default() bool // auto, no major, no pin: what every row is before this feature
+
+// internal/provision
+// Record gains: Updates string `json:"updates,omitempty"`; Major *int `json:"major,omitempty"`;
+// Pin string `json:"pin,omitempty"`; InstallRequest *InstallRequest `json:"install_request,omitempty"`
+type InstallRequest struct { ID uint64 `json:"id"`; FastTrack bool `json:"fast_track,omitempty"`; At time.Time `json:"at"`; By string `json:"by,omitempty"` }
+// Manifest gains: Updates *UpdatePolicy `json:"updates,omitempty"`. The default channel stays Manifest.Channel.
+type UpdatePolicy struct {
+ InstanceControl []string `json:"instance_control,omitempty"`
+ DefaultUpdates string `json:"default_updates,omitempty"`
+ DefaultMajor *int `json:"default_major,omitempty"`
+}
+func EffectiveUpdates(rec Record, m *Manifest) release.UpdateSettings
+
+// internal/registry
+// Route gains: UpdateSettings string (column update_settings, "" on every pre-existing row)
+func (r *Registry) SetUpdateSettings(host, col string) error
+
+// internal/status
+const UpdateSettingsProtocol = 1
+// BoxMetrics gains: UpdateSettings int `json:"update_settings,omitempty"` (absent: a binary that ignores settings)
+// console: func (s *Server) updatesEnforced(ctx context.Context) (ok bool, blocking []string, err error)
+
+// internal/instanceupdates (control socket, read-only)
+type Report struct {
+ Host string `json:"host"`
+ Settings release.UpdateSettings `json:"settings"`
+ MayChange []string `json:"may_change"`
+ Installed string `json:"installed,omitempty"`
+ Serving string `json:"serving,omitempty"`
+ Desired string `json:"desired,omitempty"`
+ Reason string `json:"reason,omitempty"`
+}
+func Mount(mux *http.ServeMux, host string, token func() (string, error), read func(host string) (Report, error))
+```
+
+Console doors (all `svcrole.Set{}`, person-only, until #155 step 6 adds the
+instance-bound credential; spec §3.3, §5.1):
+
+```
+GET /api/cli/apps/{account}/{app}/instances/{host}/updates
+PUT /api/cli/apps/{account}/{app}/instances/{host}/updates
+POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install
+PUT /api/cli/apps/{account}/{app}/updates (app policy)
+```
+
+### Plan 4 defines
+
+```go
+// internal/release
+type ResolvedRequest struct { ID uint64 `json:"id"`; Target string `json:"target"` }
+type BuildState struct {
+ Schema int `json:"schema"` // 1
+ Host string `json:"host"`
+ LastExecuted string `json:"last_executed,omitempty"`
+ Resolved *ResolvedRequest `json:"resolved,omitempty"`
+ Completed uint64 `json:"completed,omitempty"`
+ CompletedFailed bool `json:"completed_failed,omitempty"` // the request at Completed completed failed
+ At time.Time `json:"at"`
+}
+func BuildStateKey(replicaPrefix string) string // replicaPrefix + "/build-state.json", beside lock.json
+type Consumed struct { // /opt/carlos/consumed/<host>, root-owned
+ Schema int `json:"schema"`
+ Host string `json:"host"`
+ Resolved *ResolvedRequest `json:"resolved,omitempty"`
+ Completed uint64 `json:"completed,omitempty"`
+ CompletedFailed bool `json:"completed_failed,omitempty"`
+ Desired string `json:"desired,omitempty"` // root's last decision, for reports
+ Reason string `json:"reason,omitempty"` // a TargetReason
+ At time.Time `json:"at"`
+}
+func ConsumedPath(optDir, host string) string
+func ReadConsumed(optDir, host string) (*Consumed, error) // (nil, nil) absent
+func WriteConsumed(optDir string, c Consumed) error
+func Watermark(c *Consumed, b *BuildState) uint64 // max of both: never one in place of the other
+
+type TargetReason string // "adopt" "pin" "request" "manual" "major" "unknown" "new" "baking" "failed" "unauthorized"
+type Evidence int // EvidencePointer, EvidenceRecord, EvidenceStaged
+type TargetInput struct {
+ Route registry.Route
+ Pointer *ChannelPointer // the verified head; nil when unresolved
+ Settings UpdateSettings
+ Build *BuildState
+ Consumed *Consumed
+ Installed string
+ HasReplica bool
+ HeadHeld string // Hold(...) for the head on this box: "" = eligible
+ Failed func(version string) bool // zdd-5 FailureBook.Blocks for this host
+}
+type TargetDecision struct {
+ Version string // "" installs nothing
+ Reason TargetReason
+ Evidence Evidence
+ Bake bool
+ Resolve *ResolvedRequest // this pass resolves a pending request
+ Fail uint64 // this pass completes request Fail as failed
+}
+func Target(in TargetInput) TargetDecision
+
+// internal/registry
+// Route gains: BuildState string (column build_state): the agent's copy, JSON of release.BuildState
+func (r *Registry) SetBuildState(host, col string) error
+
+// internal/activator
+// Service gains: BeforeExec func(ctx context.Context, host, build string) error
+// Called under the instance's lease before every spawn that execs build. A non-nil
+// error refuses the spawn: a wake fails as a wake fails today; a swap is abandoned.
+
+// internal/status
+type HostUpdates struct {
+ Desired string `json:"desired,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ Pending uint64 `json:"pending,omitempty"` // an install request above the watermark
+ Completed uint64 `json:"completed,omitempty"`
+ CompletedFailed bool `json:"completed_failed,omitempty"`
+}
+// BoxMetrics gains: Updates map[string]HostUpdates `json:"updates,omitempty"` (host-keyed, private)
+```
+
+### Words (plans 2–5)
+
+Every member-facing string plans 2–5 add is a draft with an ID
+(`settings.*`) in one leaf package, `internal/updatewords`, which **plan 2
+creates** (it merges first). Each plan adds its own file of drafts, so
+parallel branches never edit the same lines:
+
+```go
+// internal/updatewords/words.go (plan 2)
+type Draft struct{ ID, Text, Context string } // Context: where it appears and what it does, for the reviewer
+func add(ds ...Draft) // called from each file's init; panics on a duplicate ID
+func Text(id string) string // the text for id; panics on an unknown id
+func All() []Draft // sorted by ID: what plan 5's copy review exports
+// files: words_authorize.go (plan 2), words_console.go (plan 3), words_box.go (plan 4), words_surfaces.go (plan 5)
+```
+
+Plan 5's Task 1 exports `All()`, reviews every draft with Paul, and replaces
+each `Text` with the approved wording; a test then pins every draft to
+`docs/superpowers/plans/2026-10-07-instance-settings-5-copy.json`. Operator
+journal lines on a box are not member-facing, and stay where they are
+printed. Plan 1's strings are reviewed in plan 1.
+
+## Global constraints (every plan)
+
+- `go build ./...`, `go vet ./...`, `gofmt -l .` and the touched packages'
+ tests are clean before every commit. The full `make test` gate runs in each
+ plan's last task.
+- Use the default Go build cache. Never set `GOCACHE`, `GOMODCACHE` or
+ `GOTMPDIR` under `/tmp`. Run `go` as `GOFLAGS=-mod=mod GOPROXY=off go …`; if
+ the sandbox answers "read-only file system", rerun that one command with
+ the sandbox off. Socket-binding packages (`internal/activator`,
+ `internal/host`) run with `TMPDIR=/tmp/k` (`mkdir -p /tmp/k`).
+- Scratch goes under `$TMPDIR` or `~/.cache/<plan-branch>/`, and the last
+ task deletes it.
+- CI cross-compiles darwin, windows and linux-arm64. Any Linux-only syscall
+ lives in a `_linux.go` file with a `!linux` stub.
+- **Wire shapes are additive** (AGENTS.md). Every new pointer, record,
+ manifest and status field is `omitempty`, and a document written before it
+ still parses. Old binaries verify a pointer with digests (spec §8).
+- **The default is today.** A record with no settings, an app with no policy,
+ and a box with no marker behave exactly as before. Each plan proves it.
+- No user-facing string ships without copy review. No em dashes in any
+ user-facing string.
+- Commits: `git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit`,
+ one per task, message `settings-N task M: <what>`, ending with
+ `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. Never push to
+ `main`. Land with `amadan branch merge carlos/platform <branch> -expect <tip>
+ -message "..."` after CI is green and Paul says so.
+- Plan 4 is concurrency-heavy (the request lifecycle across boxes): it ends
+ with an invariant simulation run serially at nice 19, one 3000-seed sweep,
+ per the CPU budget note.
+
+## Review focus (cross-plan)
+
+1. **A box upgraded mid-rollout of #253.** A box whose ratchet has armed
+ meets a pointer from a writer that was not upgraded. Expected: that
+ channel stops moving on that box, it keeps serving what it has, and the
+ console's straggler report names the channel. Plan 1 owns it.
+2. **An instance with a replica and no build state lands on a fresh box with
+ `manual`.** Expected: nothing is installed, the reason is *build unknown*,
+ and the instance stays down rather than booting the head. Plan 4 owns it.
+3. **"Update now" pressed on two consoles within a second.** Expected: two
+ increasing request ids, the higher one wins, and each completes at most
+ once. Plan 3 owns the id, plan 4 the completion.
+4. **A pin on a build promoted before this feature, on a box that has it
+ staged.** Expected: recovered from the staged copy; anywhere else, refused
+ with the authorize hint. Plan 4 owns it.
+5. **Retention while a box still runs a binary from before plan 4.**
+ Expected: the console refuses `pin`, `manual` and `major`; the housekeeper
+ prunes nothing it cannot prove unneeded. Plans 3 and 4 own it.
+
+## Spec questions found while planning (not resolved here; listed for Paul)
+
+- **Q1. Build-state objects live in regional data buckets.** §4.2 puts the
+ build state "beside the instance's replica" (`CARLOS_HIBERNATE_BUCKET`, per
+ region). §4.4 says the housekeeper prunes nothing for an app unless it can
+ read every build-state object of the app. The deployment-wide housekeeper's
+ role cannot read another region's data bucket (`infra/modules/region-host`
+ grants sibling regions `moves/*` push only, never `GetObject`). Read
+ literally, an app with instances in a second region is never pruned again.
+ Plan 4 implements the literal, fail-closed reading and logs why.
+- **Q2. `release.Target(route, pointer, settings)`** takes more than those
+ three: the build state, root's consumed file, the installed build, whether
+ a replica exists, this box's bake verdict and the failure records. Plan 4
+ passes one `TargetInput` struct. Root is still the only caller that
+ installs.
+- **Q3. The console API path.** §5.1 names `/api/v1/instances/<host>/updates`.
+ The console has no `/api/v1`; every instance door is
+ `/api/cli/apps/{account}/{app}/instances/{host}/…`. Plan 3 uses the house
+ path.
+- **Q4. Default channel and default pin.** `Record.Channel` is required and
+ set at create, so a "default channel" only ever reaches new instances; plan
+ 3 maps `--default-channel` to the existing `instances.json` `channel`. §3.2's
+ table implies a default pin, which §5 gives no flag; plan 3 offers none.
+- **Q5. No command writes the emission marker.** §4.6 says the marker "is
+ written". Plan 1 adds `carlos channels digests on|off` (root key,
+ direct-store only, like other deployment-control writes).
+- **Q6. The moves held stage installs a version with no pointer.**
+ `release.StageHeld` and `PublishHeld` (`materializeheld.go`) stage a
+ version named by a move, checked only by the D3 proof gate. §4.5 says
+ "nothing can make root install a build that §4.1 doesn't authorize". It is
+ not an install this spec adds, so no plan changes it; it is listed.
+- **Q7. `install_request` with FastTrack.** §4.3 lets a request ask for the
+ current head past bake. Today only the root key waives bake (`FastTrack`).
+ Plan 3 lets only a person who may fast-track the channel today set
+ `fast_track` on a request.
+- **Q8. Who may authorize and resign.** Both sign with the deployment's
+ key. Plan 2 and plan 1 make them root-key operations (the console signs
+ with its key for an app member, as it does for promote; a direct-store CLI
+ with a scoped key is refused).
diff --git a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md
index 4ef66f0bb..a18fe9ad2 100644
--- a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md
+++ b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md
@@ -65,7 +65,7 @@ The index fixes `Gen`, `GenSocketPath` and `AppSocket`. Plans 1 and 2 were draft
| C4 | plan 1 T3 | `func (a *Service) noteCurrent(host string, n uint64)`, `func (a *Service) genRetired(host string, g Gen)` | `noteCurrent` is the cache-generation bump (a `sync.Map`, safe under `a.mu`). `genRetired` closes the edge's idle connections to `g`, and must be called with no lock held. |
| C5 | plan 1 T2, plan 2 T10–T11 | `ExecBackend.flatGenSocket(host, n)`, `nestedGenSocketIn(runDir, n)`, `clearOwnGenSocket(sock)`, `startChildAt(ctx, rt, sock string, n uint64)`, `startUnitAt(ctx, rt, sock string, n uint64)`, `prepareAt(rt, role, sock string, gen uint64)`, `normalizeSocketMode(rt, sock, proc)`, `listenControl(rt, control, envPath)` | Plan 1's own `GenBackend.StartGen(ctx, rt, n, perGen)` is the wake's spawn, and it execs the **live link**. Plan 2 adds the trailing generation number to plan 1's `startChildAt`, `startUnitAt` and `prepareAt` (`Start` passes 0). Plan 3 adds a staged-path spawn beside it. |
| C6 | plan 1 (spec rev 15) | `swapCanonicalLink(canonical, target string) error` | Replaces the canonical path with a symlink to `target`, atomically: a temp link, then rename. |
-| C7 | plan 2 T10 | `ExecBackend.GenContainment`, `ParentLimit(ctx, rt, cgroup.Limit) error`, `cgroup.OverlapLimit(inc, succ)`, `ExecBackend.tier(rt)` (on `main`) | `ParentLimit` answers `errNoGenContainment` when `GenContainment` is off. Plan 2's `KillGen(ctx, rt, n uint64)` is not used here: a failed successor is stopped through the operation set. Plan 5 calls `KillGen` for a failed first wake. |
+| C7 | plan 2 T10 | `ExecBackend.GenContainment`, `ParentLimit(ctx, rt, cgroup.Limit) error`, `KillGen(ctx, rt, n uint64) error` (and `SplitBackend.KillGen`), `errNoGenContainment`, `cgroup.OverlapLimit(inc, succ)`, `ExecBackend.tier(rt)` (on `main`) | `ParentLimit` answers `errNoGenContainment` when `GenContainment` is off. `KillGen` kills what is left of generation n's own child and returns once it is empty, or wraps `errNoGenContainment` when there are no generation children. Task 2's `stopMember` calls it after every member's graceful drain, because a leader's exit does not prove its child empty. Plan 5 also calls it for a failed first wake, through Task 2's `genKiller`. |
| C8 | plan 2 T9, T11 | `instancectl.Client.StartGen(ctx, host, n, memoryMax)`, `activator.GenControl`, `Server.startGen`, `Server.ensureGenExe(host, n)`, `Server.refuseStart(w, host, unit, exe func() error)`, `genValues(host, n)`, the server's `request` struct, `ExecBackend.startRole(ctx, rt, role, gen)` | The nested generation start. Its executable mount targets the live link until Task 9. |
| C9 | plan 2 T12 | `(*cgroup.Manager).PrepareStrictParent(host, tier)`, `(*cgroup.Prepared).SetParentLimit(l)`, `(*cgroup.Prepared).CloseRemove()`, `spawnClaimedGen(ctx, parent, n, tier, work, identity, build, before)` | Plan 2 leaves the entry's strict parent and its life to plan 3 (Task 10). |
@@ -703,7 +703,7 @@ git commit -m "zdd plan 3 task 1: swap types, the SwapBackend seam, flat staged
- Test: `internal/activator/opset_test.go`
**Interfaces:**
-- Consumes: plan 1's `a.gens` (C1); `Gen` (C1); `SwapBackend.RestoreGenParent` (Task 1).
+- Consumes: plan 1's `a.gens` (C1); `Gen` (C1); `SwapBackend.RestoreGenParent` (Task 1); plan 2's `KillGen(ctx, rt, n)` on `ExecBackend` and `SplitBackend`, and its `errNoGenContainment` (C7).
- Produces:
```go
type genRole int; const roleSpawning, roleSuccessor, roleRetiring genRole
@@ -711,14 +711,20 @@ git commit -m "zdd plan 3 task 1: swap types, the SwapBackend seam, flat staged
type opSet struct{ members map[uint64]*genMember; raised bool }
// actEntry gains: ops opSet
var opJoinBound time.Duration
+ // genKiller is plan 2's generation kill, as a capability. Plan 3 owns the
+ // name; plan 5 uses it and does not declare its own.
+ type genKiller interface{ KillGen(ctx context.Context, rt registry.Route, n uint64) error }
func (a *Service) addMemberLocked(e *actEntry, m *genMember)
func (a *Service) isMember(e *actEntry, m *genMember) bool
func (a *Service) joinOps(e *actEntry) bool
- func (a *Service) stopMember(m *genMember) bool
+ func (a *Service) stopMember(e *actEntry, m *genMember) bool // leader exit AND an empty generation child
+ func (a *Service) genChildEmpty(e *actEntry, m *genMember) bool
func (a *Service) dropMember(e *actEntry, m *genMember) bool
func (a *Service) leaveSet(e *actEntry, m *genMember)
func (a *Service) reapMembersLocked(e *actEntry)
+ func (a *Service) reapExited(e *actEntry, m *genMember)
```
+ A generation is terminated when its leader has exited **and** its generation child cgroup is proven empty (spec §7.3, §7.4). The flat `childProc.Stop` signals only the leader (`backend_exec.go`, `func (p *childProc) Stop`), and plan 2's exit goroutine only tries `RemoveChild`, which the kernel refuses while a forked descendant lives. So after the graceful drain, `stopMember` calls plan 2's `KillGen` for the member's generation, which kills what is left of that child and returns once it is empty. A failed kill keeps the member in the set, unproven, exactly as `ErrStillRunning` does.
- [ ] **Step 1: Write the failing tests**
@@ -730,9 +736,13 @@ package activator
import (
"context"
"errors"
+ "fmt"
"slices"
+ "sync"
"testing"
"time"
+
+ "github.com/carlosframework/platform/internal/registry"
)
// plantMember puts a generation into host's operation set the way Swap does
@@ -877,6 +887,109 @@ func TestBarrierKeepsTheLeaseWhileAGenerationIsUnproven(t *testing.T) {
}
}
+// killGenBackend is fakeBackend plus plan 2's KillGen. err, while set, is
+// what every kill answers: a generation child that still holds a process.
+type killGenBackend struct {
+ *fakeBackend
+ kmu sync.Mutex
+ err error
+ kills []uint64
+}
+
+func (k *killGenBackend) KillGen(_ context.Context, _ registry.Route, n uint64) error {
+ k.kmu.Lock()
+ defer k.kmu.Unlock()
+ k.kills = append(k.kills, n)
+ return k.err
+}
+
+func (k *killGenBackend) setErr(err error) {
+ k.kmu.Lock()
+ k.err = err
+ k.kmu.Unlock()
+}
+
+func (k *killGenBackend) killed() []uint64 {
+ k.kmu.Lock()
+ defer k.kmu.Unlock()
+ return slices.Clone(k.kills)
+}
+
+// A generation's leader can exit while a process it forked lives on in its
+// child cgroup. The leader's exit is not termination (spec §7.3, §7.4).
+// After the graceful drain the member's own child is killed and proven
+// empty through plan 2's KillGen. While that proof fails, the member stays
+// in the set and the lease stays held, exactly as for ErrStillRunning. The
+// reaper retries the kill and finishes the stop once the child is empty.
+//
+// Without the KillGen step, stopMember accepts Exited() alone: the member
+// leaves the set at once, "marked unproven" never happens, and the Bounce
+// releases the lease over the surviving descendant.
+func TestSwapWitnessMemberStopProvesTheGenerationChildEmpty(t *testing.T) {
+ x := awakeForBarrier(t, true)
+ old := opJoinBound
+ opJoinBound = 100 * time.Millisecond
+ t.Cleanup(func() { opJoinBound = old })
+ kb := &killGenBackend{fakeBackend: x.b, err: errors.New("g9 still holds a process")}
+ x.a.backend = kb
+ member := &fakeProc{label: "gen:9", rec: x.b} // its leader exits on SIGTERM
+ plantMember(t, x.a, x.host, roleRetiring, member, 9)
+ waitFor(t, "the member marked unproven", func() bool {
+ x.a.mu.Lock()
+ defer x.a.mu.Unlock()
+ m := x.a.entries[x.host].ops.members[9]
+ return m != nil && m.unproven
+ })
+ if !member.isStopped() || !member.Exited() {
+ t.Fatal("the graceful stop never ran, or the leader did not exit")
+ }
+ if got := kb.killed(); !slices.Equal(got, []uint64{9}) {
+ t.Fatalf("KillGen calls = %v, want [9] (the member's own generation)", got)
+ }
+ rep := x.b.lastRep()
+ x.a.Bounce(x.host)
+ waitFor(t, "the stop to pend", func() bool {
+ x.a.mu.Lock()
+ defer x.a.mu.Unlock()
+ e := x.a.entries[x.host]
+ return e != nil && e.pendingStop
+ })
+ if rep.isClosed() {
+ t.Fatal("the lease was released while generation 9's child still held a process")
+ }
+ x.a.sweepOnce() // the reaper retries the kill, which still fails
+ time.Sleep(50 * time.Millisecond)
+ if rep.isClosed() {
+ t.Fatal("the lease was released on a failed retry")
+ }
+ kb.setErr(nil)
+ x.a.sweepOnce() // the kill proves the child empty now
+ waitFor(t, "the member reaped", func() bool {
+ x.a.mu.Lock()
+ defer x.a.mu.Unlock()
+ e := x.a.entries[x.host]
+ return e == nil || len(e.ops.members) == 0
+ })
+ x.a.sweepOnce() // finishes the stop
+ waitFor(t, "the lease release", rep.isClosed)
+}
+
+// A backend with no generation children (containment off answers
+// errNoGenContainment) has nothing more to prove than the leader's exit:
+// the generation shares the host cgroup, whose emptiness the terminal
+// stop's own evidence proves.
+func TestMemberStopWithoutContainmentAcceptsTheLeaderExit(t *testing.T) {
+ x := awakeForBarrier(t, true)
+ kb := &killGenBackend{fakeBackend: x.b, err: fmt.Errorf("wrapped: %w", errNoGenContainment)}
+ x.a.backend = kb
+ m := plantMember(t, x.a, x.host, roleRetiring, &fakeProc{label: "gen:9", rec: x.b}, 9)
+ select {
+ case <-m.done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("the member never left the set")
+ }
+}
+
// With swaps on, Shutdown covers a waking entry (spec §7.4): it cancels the
// wake and waits for it. Its child is stopped and its lease given back
// before Shutdown returns.
@@ -946,7 +1059,7 @@ func TestShutdownLeavesAWakingEntryWithSwapOff(t *testing.T) {
- [ ] **Step 2: Run the tests to verify they fail**
-Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapWitnessBarrier|TestBarrierKeeps|TestSwapWitnessShutdownJoins|TestShutdownLeaves' -count=1`
+Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapWitnessBarrier|TestBarrierKeeps|TestMemberStop|TestSwapWitnessShutdownJoins|TestShutdownLeaves' -count=1`
Expected: build failure: `undefined: genRole`, `undefined: genMember`, `x.a.entries[x.host].ops undefined`.
- [ ] **Step 3: Write `opset.go`**
@@ -969,6 +1082,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/registry"
)
type genRole int
@@ -1066,11 +1180,21 @@ func (a *Service) joinOps(e *actEntry) bool {
return true
}
+// genKiller is plan 2's generation kill (ExecBackend.KillGen and its
+// SplitBackend forward): it kills what is left of generation n's own child
+// cgroup and returns once that child is empty. Plan 5 uses this name too.
+type genKiller interface {
+ KillGen(ctx context.Context, rt registry.Route, n uint64) error
+}
+
// stopMember stops m's process, bounded by actStopTimeout: SIGTERM, the
// generation's own graceful drain, then SIGKILL (spec §7.5 step 6). It
-// reports whether termination is proven. A member that never spawned is
+// reports whether termination is proven, which takes two things: the
+// leader has exited, and the generation's child cgroup is empty (spec
+// §7.3, §7.4). The flat childProc signals only its leader, so a process
+// the app forked can outlive it in g<n>. A member that never spawned is
// trivially proven.
-func (a *Service) stopMember(m *genMember) bool {
+func (a *Service) stopMember(e *actEntry, m *genMember) bool {
if m.proc == nil {
return true
}
@@ -1083,14 +1207,46 @@ func (a *Service) stopMember(m *genMember) bool {
if err != nil {
log.Printf("activator: stop generation %d: %v", m.gen.N, err)
}
- return m.proc.Exited()
+ if !m.proc.Exited() {
+ return false
+ }
+ return a.genChildEmpty(e, m) // swapsim:M7
+}
+
+// genChildEmpty proves m's generation child empty, after its leader has
+// exited, through plan 2's KillGen: whatever is left in g<n> (or the
+// nested carlos-instance-gen@<id>:<n> unit) is killed and the child is
+// waited empty. That kill is the only one that can reach a descendant the
+// leader left behind, and it touches no other generation.
+//
+// A backend with no generation kill (a test double), or one that answers
+// errNoGenContainment, has no generation children: the generation ran in
+// the host cgroup, and the terminal stop's own stop evidence on that cgroup
+// is the proof (plan 2, "stop evidence on the parent"). Any other error is
+// an unproven termination.
+func (a *Service) genChildEmpty(e *actEntry, m *genMember) bool {
+ k, ok := a.backend.(genKiller)
+ if !ok || m.gen.N == 0 {
+ return true
+ }
+ a.mu.Lock()
+ rt := e.route
+ a.mu.Unlock()
+ ctx, cancel := context.WithTimeout(context.Background(), instancectl.DeadlineControl)
+ defer cancel()
+ err := k.KillGen(ctx, rt, m.gen.N)
+ if err == nil || errors.Is(err, errNoGenContainment) {
+ return true
+ }
+ log.Printf("activator: %s: generation %d's leader exited but its child is not proven empty: %v", rt.Host, m.gen.N, err)
+ return false
}
// dropMember stops m and takes it out of e's set once its termination is
// proven. It reports whether termination was proven. An unproven member
// stays in the set, marked, and the sweep's reaper finishes it.
func (a *Service) dropMember(e *actEntry, m *genMember) bool {
- proven := a.stopMember(m) // swapsim:M4
+ proven := a.stopMember(e, m) // swapsim:M4
if !proven {
a.mu.Lock()
m.unproven, m.cancel, m.retriedAt = true, nil, a.now()
@@ -1133,8 +1289,9 @@ func (a *Service) leaveSet(e *actEntry, m *genMember) {
}
// reapMembersLocked finishes members whose owner could not prove their stop.
-// Once a member's process has exited, the reaper takes it out of the set.
-// Until then it re-issues Stop every actStopTimeout, the cadence
+// Once a member's process has exited, the reaper proves its generation
+// child empty (reapExited) and only then takes it out of the set. Until the
+// leader exits it re-issues Stop every actStopTimeout, the cadence
// finishPendingStops keeps for the main process. The caller holds a.mu.
func (a *Service) reapMembersLocked(e *actEntry) {
for _, m := range e.ops.members {
@@ -1143,7 +1300,7 @@ func (a *Service) reapMembersLocked(e *actEntry) {
}
if m.proc.Exited() {
m.reaping = true
- go a.leaveSet(e, m)
+ go a.reapExited(e, m)
continue
}
if a.now().Sub(m.retriedAt) < actStopTimeout {
@@ -1160,6 +1317,19 @@ func (a *Service) reapMembersLocked(e *actEntry) {
}(m)
}
}
+
+// reapExited finishes an unproven member whose leader has exited: it leaves
+// the set only once its generation child is proven empty. A failed proof
+// leaves it marked, and the next sweep tries the kill again.
+func (a *Service) reapExited(e *actEntry, m *genMember) {
+ if a.genChildEmpty(e, m) {
+ a.leaveSet(e, m)
+ return
+ }
+ a.mu.Lock()
+ m.reaping, m.retriedAt = false, a.now()
+ a.mu.Unlock()
+}
```
- [ ] **Step 4: Wire the set into `activator.go`**
@@ -1297,7 +1467,7 @@ In `Shutdown`, replace lines 2921–2936 (from `var waits []chan struct{}` throu
- [ ] **Step 5: Run the tests to verify they pass**
-Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapWitnessBarrier|TestBarrierKeeps|TestSwapWitnessShutdownJoins|TestShutdownLeaves' -count=1 -v 2>&1 | tail -20`
+Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapWitnessBarrier|TestBarrierKeeps|TestMemberStop|TestSwapWitnessShutdownJoins|TestShutdownLeaves' -count=1 -v 2>&1 | tail -20`
Expected: every subtest `--- PASS`, then `ok`.
- [ ] **Step 6: Run the whole package (the flag-off paths are unchanged)**
@@ -1604,7 +1774,7 @@ git commit -m "zdd plan 3 task 3: swap admission through hostwrite, claims off a
func (a *Service) genHealthy(ctx context.Context, g Gen) error
// Service gains: genHealth func(ctx context.Context, g Gen) error (test seam)
var errSwapOff, errSwapBusy, errSwapKind, errSwapStopped, errSuccessorExited, errSwapMode error
- // test fixture: fakeSwapBackend, servingProc, swapFixture, newSwapFixture, setCurrentGen, (*Service).genStateForTest, indexPrefix
+ // test fixture: fakeSwapBackend, genServingProc (named apart from plan 1 Task 4's servingProc test type, which is in the same package), swapFixture, newSwapFixture, setCurrentGen, (*Service).genStateForTest, indexPrefix
```
- [ ] **Step 1: Write the test fixture**
@@ -1677,7 +1847,7 @@ func (g *fakeSwapBackend) StartStaged(ctx context.Context, _ registry.Route, gen
p := &fakeProc{label: fmt.Sprintf("gen:%d", gen.N), rec: g.fakeBackend}
var proc Proc = p
if g.genServe != nil {
- proc = &servingProc{fakeProc: p, stop: g.genServe(gen)}
+ proc = &genServingProc{fakeProc: p, stop: g.genServe(gen)}
}
g.gmu.Lock()
g.gens = append(g.gens, gen)
@@ -1722,13 +1892,16 @@ func (g *fakeSwapBackend) lastGen() (Gen, *fakeProc) {
return g.gens[len(g.gens)-1], g.genProcs[len(g.genProcs)-1]
}
-// servingProc is a generation child with a real socket server behind it.
-type servingProc struct {
+// genServingProc is a generation child with a real socket server behind it.
+// Plan 1 Task 4 already declares a servingProc test type in this package
+// (canonlink_test.go, wrapping a Proc and an *http.Server), so this one has
+// its own name.
+type genServingProc struct {
*fakeProc
stop func()
}
-func (p *servingProc) Stop(ctx context.Context) error {
+func (p *genServingProc) Stop(ctx context.Context) error {
p.stop()
return p.fakeProc.Stop(ctx)
}
@@ -5581,7 +5754,7 @@ git commit -m "zdd plan 3 task 12: agent restart mid-swap and the never-healthy
- Create: `internal/activator/swap_sim_test.go`, `internal/activator/testdata/swapsim/mutate.sh`
**Interfaces:**
-- Consumes: everything above, plus the `// swapsim:M1`…`M6` markers placed in Tasks 2, 3, 4 and 6.
+- Consumes: everything above, plus the `// swapsim:M1`…`M7` markers placed in Tasks 2, 3, 4 and 6 (M7 is in Task 2's `stopMember`).
- Produces: `TestSwapInvariants` (seeds: `SWAP_SIM_SEEDS`, default 100; replay one with `SWAP_SIM_SEED=<n>`); `mutate.sh`.
The invariants (spec §12), checked on every event:
@@ -6139,9 +6312,10 @@ cp -a "$ROOT/go.mod" "$ROOT/go.sum" "$ROOT/internal" "$ROOT/launchctx" "$SCRATCH
MUTANTS='M1|activator.go|swapsim:M1|s/a\.joinOps\(e\)/true/
M2|swap.go|swapsim:M2|s/a\.addMemberLocked\(e, retiring\)/_ = retiring/
M3|activator.go|swapsim:M3|s/e\.pin != nil/false/
-M4|opset.go|swapsim:M4|s/proven := a\.stopMember\(m\)/proven := true; go a.stopMember(m)/
+M4|opset.go|swapsim:M4|s/proven := a\.stopMember\(e, m\)/proven := true; go a.stopMember(e, m)/
M5|swapadmit.go|swapsim:M5|s/ \|\| e\.state != actAwake//
-M6|activator.go|swapsim:M6|s/if a\.cfg\.Swap \{/if false {/'
+M6|activator.go|swapsim:M6|s/if a\.gens \{/if false {/
+M7|opset.go|swapsim:M7|s/return a\.genChildEmpty\(e, m\)/return true/'
fail=0
while IFS='|' read -r name file marker sub; do
src="$SCRATCH/tree/$A/$file"
@@ -6177,13 +6351,14 @@ Then run `chmod +x internal/activator/testdata/swapsim/mutate.sh`.
- [ ] **Step 6: Run the mutation checks**
Run: `uptime && mkdir -p /tmp/k && nice -n 19 internal/activator/testdata/swapsim/mutate.sh 300`
-Expected: six `CAUGHT` lines and exit status 0. Each mutant has a deterministic witness:
+Expected: seven `CAUGHT` lines and exit status 0. Each mutant has a deterministic witness:
- M1: `TestSwapWitnessBarrierJoinsBeforeTheLease`, `TestSwapWitnessAgentRestartMidSwap`
- M2: `TestSwapWitnessAgentRestartMidSwap`
- M3: `TestSwapWitnessHoldExecsTheStagedBuild`
- M4: `TestSwapWitnessBarrierJoinsBeforeTheLease`
- M5: `TestSwapWitnessNoAdmissionOntoAStoppingEntry`
- M6: `TestSwapWitnessShutdownJoinsAWakingEntry`
+- M7: `TestSwapWitnessMemberStopProvesTheGenerationChildEmpty`
The sweep count is reported separately. Record both in the branch description.