carlos / platform Public

spec: zero-downtime deploys rev 8 — answers Astra round 7 (5 findings)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev fe31b350e540b7114705598a559bc87b06e69cb2 parent 1958541
2 files changed, +124 −12
  • docs/superpowers/specs/2026-10-07-zero-downtime-deploys-design.md +83 −12
  • docs/superpowers/specs/CODEX-FINDINGS-ZERO-DOWNTIME-R7.md +41 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-10-07-zero-downtime-deploys-design.md b/docs/superpowers/specs/2026-10-07-zero-downtime-deploys-design.md
index d88f8fa81..8746767e7 100644
--- a/docs/superpowers/specs/2026-10-07-zero-downtime-deploys-design.md
+++ b/docs/superpowers/specs/2026-10-07-zero-downtime-deploys-design.md
@@ -1,6 +1,6 @@
# Zero-downtime deploys: overlap swaps and paced rollout
-Status: draft rev 7, 2026-10-07. Discussions #231 (deploy pickup) and #230
+Status: draft rev 8, 2026-10-07. Discussions #231 (deploy pickup) and #230
(per-app restart pace, first-route health step) feed into this. Astra
rejected rev 1 with 17 findings, rev 2 with 12 new ones, rev 3 with 7, rev 4
with 3 and rev 5 with 4. The findings files are
@@ -110,6 +110,17 @@ sets it when the release is shipped. An attempt onto build B runs in
`hold` if either the app policy or B's manifest says `hold`. Otherwise it
runs in `overlap`.
+**B's demand is read from its canonical manifest** in the release store.
+That manifest is content-addressed, and its sha is named by the signed
+pointer. The box's staged copy isn't trusted for this, because staging
+re-marshals the parsed struct (`internal/release/adopt.go`), so a root
+binary older than this field writes copies without it. Staging also
+changes to keep the manifest's raw bytes, so copies staged from now on are
+lossless.
+
+**If the canonical manifest can't be read, the attempt runs in `hold`.**
+Missing policy data never permits an overlap.
+
## 5. Which build an instance installs
**Unchanged.** Root adopts every exec route from its channel exactly as it
@@ -228,10 +239,20 @@ numbers are used as:
| Canonical (registry) | Generation path |
|----------------------|-----------------|
-| `/run/carlos/<host>.sock` | `/run/carlos/<host>.g<n>.sock` |
-| `/run/carlos/instances/<id>/app.sock` | `/run/carlos/instances/<id>/app.g<n>.sock` |
+| `/run/carlos/<host>.sock` | `/run/carlos/g/<h12>.<n>.sock` |
+| `/run/carlos/instances/<id>/app.sock` | `/run/carlos/instances/<id>/app.<n>.sock` |
| `<socket>.control` | `<generation socket>.control` |
+`<h12>` is the first 12 hex characters of the host's sha256. Flat sockets
+today embed the whole hostname and can sit at the 107-byte limit
+(`internal/provision/provision.go`). With the generation suffix and
+`.control` added, some would exceed it. The new flat path is bounded:
+`/run/carlos/g/` (14 bytes), plus `<h12>` (12), plus `.` and up to 20
+digits, plus `.sock` (5), plus `.control` (8). That is 60 bytes at most,
+whatever the hostname. The nested `<id>` is already a bounded identifier.
+`prepare()` removes a stale file at its own generation's path, left there
+by an earlier agent process, before binding.
+
The registry keeps the canonical path as the route's identity. The
activator owns the generations, and there are two ways to address one:
@@ -398,9 +419,23 @@ held requests are served once it is healthy.
Worker processes and native sidecar members (`Kind=sidecar`) serve no
requests, and two copies could do the same work twice. They keep
-stop-then-start, on the respawn-on-same-entry path, and exec the attempt's
-staged build. The scheduler paces them with the readiness and failure rules
-in §6.5.
+stop-then-start, on the respawn-on-same-entry path
+(`respawnWorkerLocked`), with the readiness and failure rules in §6.5.
+
+**Native sidecars keep their own install path.** `Adoptable` admits only
+`Kind=instance`, so root doesn't adopt them. `ExecBackend.StartWorker`
+fetches their build from the signed channel itself, and the sweep's
+converge decision (`activator.go`) notices drift. That stays as it is. The
+only change is that the converge decision is **queued as a `release`
+attempt** with the scheduler instead of respawning at once. The pacing,
+the canary and the failure records then apply to sidecars like any other
+host.
+
+For a sidecar, the **desired build** is what `StartWorker` resolves from
+the channel, and the **serving build** is the version the running worker
+reports. These are kept apart: the worker-reported version no longer
+overwrites registry `Version` as the installed build, and is recorded as
+serving.
### 7.10 Scheduled work during an overlap
@@ -521,9 +556,17 @@ cohort halts, and the deploy watch says so, naming `carlos rollback`.
A failure record names an **(app, build, box) scope**. It is stored in the
agent's data dir, so it survives agent restarts, and it is reported in box
-status. While it stands, the scheduler makes no attempt onto that build for
-any instance of the app on that box, and wakes boot the good build instead
-(§8.2). Clearing:
+status. While it stands, **no instance that hasn't already served the
+build is moved onto it**: the scheduler makes no `release`, `install` or
+`retry` attempt onto it for such instances, and their wakes boot the good
+build instead (§8.2).
+
+Instances that **already serve** the build, because they passed before the
+failure, are unaffected. Their `restart` and `config` attempts onto the
+build they are serving go ahead as normal. So does a wake onto it, because
+the build is their good build too.
+
+Clearing:
- **A newer installed build** for an instance, from its channel, no longer
matches the record. The record stays for any
@@ -583,8 +626,16 @@ strings here describe meaning, not proposed copy.
- `carlos deploy`'s watch follows the schedulers and reports per box: how
many instances have swapped, which failed and why, and which stayed on
their good build. It ends when every box is done or halted, or at its
- existing deadline. A box counts as done when every instance's serving
- build is its desired build.
+ existing deadline.
+
+ **A box is done when its scheduler has no queued or running attempt and
+ no open canary or settle observation for the app.** Each sleeping
+ instance moved cold is counted as **installed, awaiting first wake**. It
+ doesn't hold the watch open, because no traffic may reach it for hours,
+ and the watch reports it as such. If that first wake later fails, it
+ shows up in `carlos instances list` and the events, not in a watch that
+ has already finished. An awake instance counts once its swap has passed
+ its health check and, for the canary, its settle.
- `carlos instances list` shows the installed and serving builds and, when
they differ, the reason: failed build or rolling out.
@@ -646,7 +697,17 @@ keep timer names stable across the two builds. Otherwise deploy with
that races a switch, the cache-generation guard, and the version header
from the serving generation.
- **Every `rt.Socket` reader** assigned in §7.1 has a test that dials the
- current generation.
+ current generation. A 120-byte hostname gets generation and control
+ sockets within the limit.
+- **Swap mode source:** a staged copy without `swap`, from an older root,
+ can't produce an overlap for a `hold` build. An unreadable canonical
+ manifest runs in `hold`.
+- **Sidecars:** a channel advance queues a paced `release` attempt. The
+ worker-reported version is recorded as serving, not installed.
+- **Failure-hold scope:** after a cohort failure, an instance already
+ serving the build still completes `config` and `restart` attempts.
+- **Deploy-watch completion:** sleeping instances are reported as awaiting
+ first wake and don't hold the watch open. An open canary settle does.
- **Scheduler:** per-instance desired builds across cohorts, canary gating,
`swap_concurrency`, halting on the first failure, repoint-back to the
good build, supersession and re-checks, attempt-identity dedupe, the
@@ -818,3 +879,13 @@ manual targets, leaving directed mode, superseded directives, bake
windows, and when to recompute. They moved with those settings to
`2026-10-07-instance-update-settings-design.md` §7, with a proposed
answer for each.
+
+### Round 7 (first review of the split spec)
+
+| # | Finding | Answer |
+|---|---------|--------|
+| 1 | Staging can erase a release's `hold` demand | §4.2: read from the canonical manifest; staging keeps raw bytes; unreadable → `hold` |
+| 2 | Native sidecars have no install path feeding the scheduler | §7.9: `StartWorker`'s existing channel install is kept; the converge decision becomes a paced attempt; desired and serving are separated |
+| 3 | A cohort failure blocks config on hosts already serving the build | §8.4: the hold applies only to hosts that haven't served the build |
+| 4 | Generation paths exceed the socket length limit | §7.1: bounded flat path, at most 60 bytes |
+| 5 | Deploy-watch completion | §9.1: done comes from scheduler state; cold installs are reported as awaiting first wake |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-ZERO-DOWNTIME-R7.md b/docs/superpowers/specs/CODEX-FINDINGS-ZERO-DOWNTIME-R7.md
new file mode 100644
index 000000000..15b1e6644
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-ZERO-DOWNTIME-R7.md
@@ -0,0 +1,41 @@
+REJECT
+
+1. **HIGH — §§4.2, 7.7, 15: Unchanged staging can erase a release’s `hold` demand.**
+
+ **Code:** [internal/release/adopt.go:1433](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/adopt.go:1433) writes the staged manifest by re-marshalling the parsed struct. Unknown fields disappear. At [adopt.go:1376](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/adopt.go:1376), an existing staged manifest is accepted without fetching the canonical copy.
+
+ **Failure:** An older root process stages B containing `swap:"hold"`, leaving a local manifest without that field. A swap-capable agent subsequently reads that valid-looking manifest and selects overlap, running B’s incompatible migration beside A. Upgrading root does not repair the existing copy. The spec leaves the policy’s authoritative read path unspecified.
+
+ **Smallest fix:** Require swap policy from the canonical manifest or a demonstrably lossless staged copy. Refetch older staged copies before trusting an absent `swap`; unavailable policy metadata must not authorize overlap.
+
+2. **HIGH — §§5, 6.1, 6.5, 7.9: Native sidecars have no installation path feeding the proposed scheduler.**
+
+ **Code:** [internal/release/adopt.go:830](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/adopt.go:830) admits only `Kind=="instance"`. Cold materialization uses that same filtered pass at [internal/release/materialize.go:49](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/materialize.go:49). Worker readiness writes the worker-reported build into registry `Version` at [internal/activator/activator.go:2035](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/activator/activator.go:2035).
+
+ **Failure:** A native sidecar serves A and its channel advances to B. Keeping root adoption unchanged never stages, repoints or stamps B for that route. The scheduler’s installed-versus-serving comparison therefore receives no B target, so the promised paced replacement never occurs. A newly provisioned sidecar without a live link cannot materialize either.
+
+ **Smallest fix:** Specify a sidecar installation leg using the existing signed-channel selection rules, and separate its installed build from its self-reported running version. Avoid widening every unrelated caller of `Adoptable`.
+
+3. **MEDIUM — §§8.1, 8.4: A cohort failure blocks config activation on instances already successfully running that build.**
+
+ **Code:** Config delivery replaces the file before requesting activation at [internal/appconfig/syncexec.go:1302](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/appconfig/syncexec.go:1302); subsequent delivery returns immediately when those bytes match at [syncexec.go:1280](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/appconfig/syncexec.go:1280).
+
+ **Failure:** H1 successfully switches to B; H2 subsequently rejects B. §8.1 leaves H1 serving B, but §8.4 forbids *every* scheduler attempt onto B. A config revision delivered to H1 therefore cannot become active: its same-build config attempts remain blocked indefinitely. The explicit statement that `carlos restart` restarts the serving build also conflicts with this blanket prohibition.
+
+ **Smallest fix:** Make the failure hold prevent adoption of B by unproven hosts, while permitting same-serving-build config and restart attempts on hosts already successfully running B. State that exception explicitly.
+
+4. **MEDIUM — §7.1: The prescribed flat generation paths exceed the supported Unix-socket length.**
+
+ **Code:** [internal/provision/provision.go:1219](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/provision/provision.go:1219) derives flat sockets from the full hostname; [provision.go:1230](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/provision/provision.go:1230) records the 107-byte limit. A failed control bind is tolerated at [internal/activator/backend_exec.go:890](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/activator/backend_exec.go:890).
+
+ **Failure:** An 82-byte hostname currently has a 99-byte app socket and a valid 107-byte control socket. Adding `.g1` makes the control path 110 bytes. The app passes health but loses its control channel, including timer registration. A 90-byte hostname’s previously usable app socket itself becomes unbindable.
+
+ **Smallest fix:** Use bounded identifiers for flat generation paths too, reserving space for the maximum generation representation and `.control`. Preserve the canonical registry identity separately.
+
+5. **MEDIUM — §§6.2, 6.4, 9.1: Deploy-watch completion does not represent rollout completion.**
+
+ **Code:** Hibernation removes the activator entry at [internal/activator/activator.go:2373](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/activator/activator.go:2373). Cold adoption deliberately installs without waking or health-checking at [internal/release/adopt.go:883](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/adopt.go:883).
+
+ **Failure:** A sleeping route has an installed/desired B but no serving generation, so §9.1’s “every instance’s serving build is its desired build” condition cannot become true without traffic. Successful deployments routinely reach the deadline. Conversely, a first wake serves B before its ten-second observation finishes; the same condition can report completion before B crashes and halts the cohort.
+
+ **Smallest fix:** Define completion from scheduler state: cold installations may finish as awaiting first wake, while active attempts and outstanding canary observations must finish before the watch reports success.
\ No newline at end of file