carlos / platform Public

spec: carlos deploy — seconds-to-live for every non-baking channel

Design for the deploy signal (control/serial), the generalized fast
converge set (#101), Materialize on cold wake (#77), the carlos deploy
verb, and the X-Carlos-Version edge header.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a02ebc61608f10a9bde1ec2811a8bb72d67f1f7a parent dc6a6c3
1 file changed, +257 −0
  • docs/superpowers/specs/2026-08-08-carlos-deploy-design.md +257 −0
diff --git a/docs/superpowers/specs/2026-08-08-carlos-deploy-design.md b/docs/superpowers/specs/2026-08-08-carlos-deploy-design.md
new file mode 100644
index 000000000..6122f2550
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-08-carlos-deploy-design.md
@@ -0,0 +1,257 @@
+# carlos deploy — seconds-to-live for every non-baking channel
+
+2026-08-08. Extends the pipelines spec (2026-08-02-pipelines-design.md), the
+instance-provisioning spec (2026-08-04, "Known gap (Task 8 review)"), and the
+bake-controls spec (2026-08-04). Implements the principle of issue #101
+("Nothing should wait minutes to wire up once the information to do it
+exists") and the preferred fix of issue #77 (Materialize). Touches
+`internal/host` (agent.go), `internal/activator` (backend_exec.go),
+`internal/release` (adopt.go, a new serial object), the edge proxy
+(response header), and the CLI (`carlos deploy`).
+
+## 1. Problem
+
+`carlos ship` + `carlos promote` complete in seconds, and then the feature is
+live... eventually. The latency between "the bucket has everything needed"
+and "the URL serves it" is three loops that each chose a cadence in
+isolation (issue #101's framing):
+
+| Step | Owner | Cadence today |
+|---|---|---|
+| Route mint for a new instance | `instancesLoop` (`internal/host/agent.go`) | 5 min + jitter |
+| Adoption (stage + symlink + try-restart) | `adoptLoop` | 1 h + 0–10 min jitter |
+| Adoption, `canary/*` routes only | `canaryAdoptLoop` | 2 s + jitter |
+| Cold wake with nothing staged | `ExecBackend.Materialize` | **never** (nil in production → 503) |
+
+The per-app `production` flag (bake-controls spec) is NOT the missing piece:
+it gates only the supply-chain bake window, which non-production apps already
+skip. The wait is pure poll cadence.
+
+Routes on `canary/*` channels already have the target experience: promote →
+staged and restarted in ~3–5 s. The problem is that the fast path is scoped
+by *channel name* when the useful scope is *outstanding work* — plus one gap
+the name-scope hides: a box has no way to notice a fresh promote to an
+already-converged non-canary route without polling that channel's pointer,
+and polling every pointer at 2 s is the poll storm #101 warns against.
+
+## 2. Shape of the fix
+
+Four pieces, all pull-based. The console never gains an inbound path to a
+box (#101's hard constraint); boxes keep converging with the console down.
+
+1. **A deploy signal** — one bucket object the console bumps on writes that
+ create immediate box work; boxes poll that single object every 2 s and run
+ the normal (fully verified, hold-respecting) pass set on a change.
+2. **A fast set** — routes with outstanding work are swept every 2 s until
+ converged, regardless of channel name; the set drains to empty.
+ Subsumes `canaryAdoptLoop`.
+3. **Materialize on cold wake** — an unstaged exec route stages its channel
+ head on demand at wake instead of 503ing until the hourly pass.
+4. **`carlos deploy`** — ship → promote → watch until the URL provably
+ serves the shipped version, via a new `X-Carlos-Version` edge response
+ header.
+
+## 3. The deploy signal: `control/serial`
+
+A new unsigned bucket object at `control/serial` (top level, NOT under
+`channels/` or any per-app prefix — nothing that lists or parses app shapes
+sees it):
+
+```json
+{"schema": 1, "serial": 41, "bumped_at": "2026-08-08T10:15:00Z"}
+```
+
+**Console bumps it** (read-increment-write, last-writer-wins — see below)
+after any bucket write a box loop consumes: channel promote, instance record
+create/enable/update, app config (env) write, production-flag flip. One bump
+may coalesce many writes; that is the point.
+
+**Boxes poll it every 2 s** in the fast loop (§4). One GET per box per 2 s —
+O(1) in fleet size, ~43k requests/day/box, cents/month. The box remembers
+the last serial it acted on (in-memory is enough; a restart just triggers
+one extra pass) and reacts to **any change**, not only increments — a reset
+or races between two console instances must never wedge the box into
+ignoring bumps. On change it runs the standard pass set: instances
+reconcile (`provision.Reconcile`), adopt pass (`release.Pass`), exec config
+sync — each with every signature check, bake hold, and ratchet exactly as
+today. Full pass-set runs are clamped to at most one per 5 s so a bump storm
+degrades to steady-state polling, not a stampede.
+
+**Security posture.** The object is unsigned because it carries no
+authority: it can only trigger *verified* work sooner. An attacker with
+bucket write access could bump it forever — and could already write garbage
+pointers that fail verification; either way the damage is bounded extra
+GETs, capped by the clamp. Verification of what gets adopted is unchanged.
+
+**Wire shape (additive, fail-safe).** New object; nothing existing parses
+it. Consumer list (everything that PARSES the shape, per the repo rule): the
+box fast loop, and its tests. Old boxes ignore it and keep the hourly
+cadence — the safe direction. Operator-side scripts that write pointers
+directly (pinfra `ship-app.sh`/`promote-app.sh`) do not bump; those flows
+stay hourly until the scripts learn to (non-goal here).
+
+**Console concurrency.** Two console processes bumping concurrently may
+lose an increment (S3 has no CAS on this path). Acceptable: the value's
+magnitude is meaningless, only *change* is; a lost increment still changed
+the value once, and the writes it announced are found by the pass the other
+bump triggers. `bumped_at` uses the console clock and is informational.
+
+## 4. The fast set: cadence as a function of outstanding work
+
+`canaryAdoptLoop` generalizes into a **fast converge loop**:
+
+- **Membership** (a route has outstanding work) — any of:
+ - no live symlink yet (newly minted, or staging previously failed);
+ - the last adopt pass computed an unstaged, hold-eligible head for it
+ (the pass already computes this "as its ordinary business" — #101);
+ - its instance record exists but the route is not yet minted
+ (`provision.Reconcile`'s outstanding case).
+- **Cadence**: 2 s + jitter while the set is non-empty; the loop goes
+ entirely quiet (serial polling only) when it drains. Per-route failure
+ backoff doubles 2 s → 5 min cap, so a permanently broken route (missing
+ artifact, spurious 403s under load — #101's cost note) cannot hammer the
+ store forever; the hourly pass remains the floor for everything.
+- **Bake interaction**: a route whose head is inside a bake window is NOT
+ outstanding work — it is *held*, drops out of the fast set, and re-enters
+ when the window expires (the hourly pass or a serial bump re-notices it).
+ Fast convergence never polls a held route at 2 s for 72 h.
+- `canary/*` routes lose their special case: a canary promote bumps the
+ serial like any other, the pass notices the unstaged head, the route joins
+ the fast set, converges, drains. Behavior is equal or better; the
+ channel-name check (`isCanaryRoute`) is deleted. Production channel names
+ pinned in `internal/release/laddercheck_test.go` are untouched — this
+ changes when pointers are *polled*, never which channels are *legal*.
+- The sandbox rules move with the code: the fast loop stays inside
+ `carlos-edge.service` (ProtectSystem=strict), so the standing DO-NOTs on
+ `canaryAdoptLoop` — no `NagExpiredCanaries`, no `WithCapabilities` —
+ apply verbatim to its successor and must be carried over in comments.
+ The capability-converge consequence recorded there (new binary runs
+ against the previous unit shape until the hourly root pass) is unchanged.
+
+Restart semantics are already correct and untouched: the adopt pass
+try-restarts awake units onto the new symlink; hibernating instances get the
+pure repoint and pick the new binary up on wake. Once adoption is fast,
+"new version on next refresh" follows for both warm and cold instances.
+
+## 5. Materialize: cold wake stages on demand
+
+`ExecBackend.Materialize` (`internal/activator/backend_exec.go`, nil in
+production today) gets wired: on wake of a host whose live symlink is
+missing, resolve the route's channel pointer (full signature verification),
+check the bake hold (a held head is NOT materialized — cold wake must not
+become a bake bypass; the 503 is then correct and the message says "held"),
+stage the artifact, point the symlink, and let the wake proceed. First
+visitor pays a slow request instead of receiving a 503 for up to an hour.
+
+Implementation is a refactor-to-share of `adoptHost`'s staging half, not a
+second staging path. Per-host singleflight (the activator's existing wake
+serialization) bounds concurrent materializations; this design does not
+depend on fixing the #76 beginRequest race, and doesn't worsen it.
+
+This retires "no live binary" as a wake-failure class (issue #77's preferred
+fix, and the instance-provisioning design's own recorded preference).
+
+## 6. The verb: `carlos deploy`
+
+Member CLI, console-API surface — no AWS access, per the operate-via-API
+rule. One command:
+
+```
+carlos deploy [-target linux-arm64] [-channel <ch>] [-host <host>] [-timeout 120s]
+```
+
+1. **Resolve** app from `.carlos/config` / `-app` like every other verb.
+2. **Resolve channel**: the channel the app's instances follow (instances
+ API). Exactly one distinct channel → use it. Several → error listing
+ them, demanding `-channel` or `-host`. None (no instances) → error with
+ the `instances create` hint; deploy does not create instances (YAGNI).
+3. **Ship** — the existing ship path, flags passed through.
+4. **Promote** — the existing promote path, to the resolved channel. The
+ console bumps `control/serial` as part of the promote write.
+5. **Watch** — poll the instance URL(s) until `X-Carlos-Version` equals the
+ shipped version, then print the URL(s) and exit 0. Default timeout 120 s.
+ On timeout: exit 1 with diagnostics — the version the header currently
+ reports, or "no header" (edge predates this feature → say so and suggest
+ the hourly window), or connection state (route not minted yet / wake
+ failing).
+6. **Bake-held channel** (production app promoting to beta/stable inside a
+ window): ship and promote complete, then deploy prints the hold ("held
+ until ~T, use the console fast-track if this is an emergency") and exits
+ 0 **without watching** — the requested writes succeeded and the hold is
+ policy, not failure. Deploy never bypasses or waives bake; fast-track
+ stays a console/passkey action (bake-controls spec §5).
+
+Step-by-step verbs remain first-class; deploy is composition, not
+replacement.
+
+## 7. The version tell: `X-Carlos-Version`
+
+The edge sets `X-Carlos-Version: <version>` on every proxied response for
+versioned routes, read from the route's live symlink target — the edge
+already knows it; this is a `Server:`-header-grade disclosure, approved as
+public. It is the deploy watch's proof and doubles as the human
+"verify with the binary you built" tell (`curl -sI | grep -i carlos`).
+
+Consumer list for the header (everything that PARSES it): the deploy watch,
+and tests. Absent header = old edge or unversioned route; the watch treats
+it as "cannot confirm", never as a match.
+
+Additionally, the instances API response gains an additive
+`staged_version` field where the console can know it, chipping at issue #78
+(no member-facing wake visibility). Freshness is best-effort (the console's
+view may lag box-local truth); the header is the authoritative watch and
+the API field is explicitly NOT used by deploy's exit criterion.
+
+## 8. What this deliberately does not change
+
+- **No push channel.** Boxes stay unreachable by design; everything here is
+ the box pulling one extra tiny object.
+- **No bake/ladder/passkey changes.** Holds, sign-off gates, the stable
+ step-up, fast-track, and the production flag's 72 h ratchet are all
+ untouched; the fast set explicitly excludes held routes.
+- **No new dev-mode flag.** The unconverged-work predicate makes a cadence
+ knob unnecessary; the existing production flag keeps meaning exactly
+ "bake applies".
+- **No operator-script integration.** pinfra ship/promote scripts keep the
+ hourly cadence until separately taught to bump the serial.
+- **Existing wire shapes.** Channel pointers, manifests, flag objects,
+ bake-clock files: unchanged. The two new shapes (serial object, response
+ header) are additive and ignored by everything that predates them.
+
+## 9. Testing
+
+- Serial: parse/shape test; change-not-increment reaction; clamp behavior.
+- Fast set: membership predicate unit tests (unstaged, unreconciled, held →
+ excluded, drained → quiet); backoff schedule; `canaryAdoptLoop` parity
+ test showing canary routes converge at least as fast as before.
+- Materialize: dir-store staging on wake; held head refuses with the held
+ message; missing artifact fails the wake with the existing error class.
+- Header: set for versioned routes, absent for unversioned; value tracks a
+ symlink swap.
+- Deploy verb: e2e against a test console + dir store — every test that
+ shells the carlos binary builds env via `carlosTestEnv` and sets
+ `cmd.Dir` to a scratch dir (repo rule; the 40-account incident).
+- `laddercheck` untouched and green: production channel names still legal.
+- Backward-parse: pre-change pointers and manifests still adopt (repo rule:
+ wire-shape changes are additive).
+
+## 10. Rollout
+
+Deploy verb is useful immediately (canary/* routes are already fast); full
+seconds-to-live needs the box halves live. Order per the standing rule
+(promote → adopt → restart): ship the new binary, adopt on both boxes, then
+restart `carlos-edge.service` (long-running unit — serial loop and header
+don't exist until restart). Flagship and tito both; verify with the header
+itself from the edge (`--resolve` rule), not a cached DNS answer.
+
+## Relationship to open work
+
+- #101 — implements its two suggested shapes plus the serial signal for the
+ notice-a-promote gap; comment left linking this spec.
+- #77 — closed by §5 (Materialize, its preferred fix).
+- #78 — advanced by §7's `staged_version` field (not fully closed).
+- #95 — env-set first-sighting absorption: the serial bump on config writes
+ plus the pass-set run covers the reported symptom; verify during
+ implementation and close if so.
+- #76 — activator wake singleflight race: untouched, explicitly not
+ worsened (§5).