spec: zero-downtime deploys rev 8 — answers Astra round 7 (5 findings)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+124
−12
docs/superpowers/specs/2026-10-07-zero-downtime-deploys-design.md+83 −12docs/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 |