carlos / platform Public

carlos deploy reports failure on a gated app, after a successful ship and promote Issue open

Claimed by claude~paul@keymail.dev 4 weeks ago

carlos deploy ends in an error on an app that has carlos gate set, even though the deploy itself succeeded completely.

Reproduce

carlos gate set --app report # password set carlos deploy --app report --kind static ./public

deploying https://report.bab.platform.tito.io shipped report v2 promoted report v2 -> edge a static route re-checks its channel about every 60s, so the URL can take that long to flip watching the X-Carlos-Version header: it reports the release ADOPTED, not the build running 2026/09/21 16:20:13 deploy: timed out waiting for report.bab.platform.tito.io to serve v2: report.bab.platform.tito.io: not serving (HTTP 401) — instance failing to wake, or route not live yet

The ship and the promote both landed. Fetching the URL with a gate cookie returns 200 and the new build, byte-identical to what was deployed. The only thing that failed is the watch, because the gate answers the unauthenticated poller with 401 — which is precisely what a gate is for.

Why it matters

The error text names two causes ("instance failing to wake, or route not live yet") and neither is true, so the operator's next move is to go and debug a deploy that worked. In a script or a CI step it is worse: a non-zero exit on a successful deploy.

Suggestions, in preference order

  1. The watch skips the gate. The deploy is already an authenticated, console-authorized action; the poller could carry the same credential, or the edge could let a deploy-scoped probe through.
  2. Treat 401-from-the-gate as "serving". A gate 401 proves the route is live and the edge is answering — it is a better liveness signal than a 200 would be from a stale build. It just cannot confirm the VERSION.
  3. At minimum, name the real cause and exit zero: "shipped and promoted; the watch cannot verify the version because a password gate is set on this host". Today's message sends people the wrong way.

Found deploying a gated internal report from Tito, 2026-09-21.

Still reproduces on platform main at 52f7114 (2026-09-25), built from source. Three gated static apps on console.carloku.com (account bdj), each deployed three times today. Every run ended the same way: shipped and promoted both printed, then

deploy: timed out waiting for carlos-plan.bdj.oncarlos.com to serve plan-b8600f9: carlos-plan.bdj.oncarlos.com: not serving (HTTP 401) — instance failing to wake, or route not live yet

carlos channels shows the new version on edge every time, and the site serves it behind the gate.

Where it happens:

  • internal/edge/edge.go:1516: e.gate.Admit returns before the static handler runs.
  • internal/edge/static.go:165: the only place a static route stamps X-Carlos-Version. So the gate's 401 carries no version header.
  • deploy.go:1469: the watch then falls into got == "" && resp.StatusCode/100 != 2 and reports "instance failing to wake, or route not live yet". For a gated host, neither is true.

One more option, beside the three above. The gate could mark its own response, for example X-Carlos-Gate: 1 on the 401. The watch could then say: "shipped and promoted; a password gate is set on this host, so the version can't be checked from outside". It would exit 0 once channels shows the version on the channel. That reveals nothing the login page doesn't already, and it avoids stamping the release version on an unauthenticated response.

A side effect worth knowing: the only safe order for a gated static app today is instances create --kind static, then gate set, then deploy. That's so the site is never served ungated. So every careful first deploy of a gated site hits this error.

Taking this on branch gated-deploy-watch-164. Went with option 1: the watch carries a credential.

  • Console: new door POST /api/cli/apps/{account}/{app}/instances/{host}/gate-pass. It mints a real gate cookie (gate.Mint) from the record's hash, valid 15 minutes, and audits it. The edge needs no change; it verifies the pass like any visitor's cookie. Changing or clearing the password kills the pass.
  • Who gets one: people (members), Publish and Instance credentials. Each already has more than a 15-minute read: Publish can ship whatever the host serves, and Instance can clear the gate via PATCH. Operate is refused, and so is a service credential on a control-plane app.
  • CLI: deploy and canary fetch a pass for hosts the instance list marks gated and send it on every poll, so the version is actually verified. With no pass (older console, refusal), the host is dropped from the watch with a line saying why, and the exit is 0.
  • Timeout report: a 401 with no X-Carlos-Version is named as the gate, never "failing to wake". That covers the gate-set-mid-deploy case.

Rejected: the X-Carlos-Gate marker + exit 0 (post above). It needs an edge roll and still can't verify the version. Copy review and an Opus review are in flight.

Opus review changed two things.

  • Publish is off the door; it is Instance and people only. A Publish credential can't always change what a host serves: on a passkey or approval rung its promote becomes a proposal. So a pass would have been new read access past the password. No path needed it anyway, because deploy lists instances first and that door is Instance-only.
  • Pass errors are split three ways. A 404 or 403 (old console, refusal) drops the host with a line and exits 0. A 409 (gate cleared meanwhile) watches it as open. Anything else (429, 502, network) keeps the host in the watch without a pass, so a blip can't turn into a green deploy nobody checked.

Left as known limits: a --timeout longer than 15 minutes outlives the pass, and the report says "pass expired". A static --host custom domain with no record of its own gets no pass and fails with the "gate may be set" line.