| 1 | # `carlos canary` — filling the reserved verb |
| 2 | |
| 3 | **Date:** 2026-09-04 |
| 4 | **Issue:** the reserved `canary` case in `main.go:131` exits `not implemented`. |
| 5 | |
| 6 | ## Why |
| 7 | |
| 8 | CARLOS canaries instance apps today and has all year — `go.tito.io` follows |
| 9 | `canary/platform-adoption`, pinned as a live production channel in |
| 10 | `internal/release/laddercheck_test.go:12`. The machinery is all there: |
| 11 | |
| 12 | - a route's channel lives on its instance record, per host, so two instances |
| 13 | of one app on different channels stage different versions |
| 14 | (`internal/host/agent.go:1549`); |
| 15 | - `canary/*` routes converge on the 2s fast loop, not the 5-minute tick |
| 16 | (`internal/host/agent.go:1502`); |
| 17 | - promotion **into** a canary is unconditional on both the legacy ladder and |
| 18 | an app-defined pipeline (`internal/release/ladder.go:84`, |
| 19 | `internal/release/pipeline.go:328`); |
| 20 | - `VersionStage` only scans pipeline channels, so a build that has been on a |
| 21 | canary still enters the pipeline as fresh — canarying costs no rung. |
| 22 | |
| 23 | What is missing is *sugar*. Today a member types `instances create` + |
| 24 | `ship` + `promote` and works out the hostname themselves. A session in |
| 25 | September 2026 read `carlos canary`'s `not implemented` and README's |
| 26 | "canary hosts are minted for static apps only" and concluded the platform |
| 27 | could not canary an instance app at all. Both statements are true and |
| 28 | narrow; together they read as a blanket no. The verb closes that gap. |
| 29 | |
| 30 | ## Surface |
| 31 | |
| 32 | ``` |
| 33 | carlos canary [deploy] --app <a> [--name <n>] [--version <sha>] |
| 34 | [--kind binary|static] [--target <goos-goarch>] [--label <l>] |
| 35 | [--timeout 120s] <files-or-site-dir> |
| 36 | carlos canary ls --app <a> |
| 37 | carlos canary rm --app <a> --name <n> |
| 38 | ``` |
| 39 | |
| 40 | The bare form is `deploy`, the same way `carlos instances` has a bare form. |
| 41 | Sub-verbs are positional words that never reach `fs.Parse`, matching |
| 42 | `runInstances` (`cli.go:269`). |
| 43 | |
| 44 | API mode only, like `carlos deploy`: an instance record is a |
| 45 | console-authorized write, and this verb must complete the job with no box |
| 46 | access (AGENTS.md, "every `carlos` command is designed for a user with no |
| 47 | access to the underlying infra"). |
| 48 | |
| 49 | ## Name, channel and host |
| 50 | |
| 51 | - `--name`, else the current git branch, slugified to a single channel |
| 52 | segment (`internal/release`'s `channelSegRe`: `[a-zA-Z0-9._-]+`, ≤64). |
| 53 | - channel = `canary/<name>`. |
| 54 | - host = `<name>.<app>.<manifest domain>` — exactly the scheme README |
| 55 | already documents: `<canary>.<app>.<account-sqid>.<apps-domain>`. |
| 56 | |
| 57 | A branch that slugifies to nothing, or a `--name` that is not a valid |
| 58 | segment, is refused by name rather than silently repaired. |
| 59 | |
| 60 | ## `canary deploy` |
| 61 | |
| 62 | 1. Resolve session, account, app (deploy's rules; `requireDeployAPISession`). |
| 63 | 2. Resolve the name, build the channel, refuse an invalid one. |
| 64 | 3. `instancesEnable` — idempotent, and the only source of the domain suffix. |
| 65 | 4. `instanceList`; then exactly one of: |
| 66 | - no record on the canary host → `instanceCreate` with the canary channel; |
| 67 | - record exists on a different channel → `instanceSetChannel`; |
| 68 | - record already on the canary channel → no write at all. |
| 69 | 5. `runShipAPI` (banner suppressed; this verb prints its own). |
| 70 | 6. `promote` to `canary/<name>` — never refused, on any app, from any rung. |
| 71 | 7. `watchVersion` against the canary host only, never the app's stable host. |
| 72 | |
| 73 | Steps 3–4 are what make it sugar rather than a new subsystem: every call |
| 74 | already exists for `carlos instances` and `carlos deploy`. |
| 75 | |
| 76 | ## `canary ls` |
| 77 | |
| 78 | Instance records whose channel starts `canary/`, joined to the channel |
| 79 | listing for the lease. Columns: name, host, channel, staged version, and |
| 80 | what is left of the seven-day lease (`release.CanaryTTL`, refreshed on every |
| 81 | re-promote). An expired canary is marked, because a canary that outlives its |
| 82 | reason is the exact hazard the TTL exists to surface. |
| 83 | |
| 84 | ## `canary rm` |
| 85 | |
| 86 | Deletes the instance record for `canary/<name>`. It says plainly that the |
| 87 | channel pointer itself remains and expires on its own — there is no pointer |
| 88 | delete in the platform, and a command that implied otherwise would be |
| 89 | lying. |
| 90 | |
| 91 | ## Explicitly out of scope |
| 92 | |
| 93 | **Traffic splitting.** This is a URL per canary, not a percentage of |
| 94 | production traffic on one hostname. That would be real work in the edge — |
| 95 | weighted routing, not sugar over existing calls — and nothing here pretends |
| 96 | otherwise. |
| 97 | |
| 98 | ## Tests |
| 99 | |
| 100 | - name resolution: `--name` wins; git branch is slugified; an empty or |
| 101 | invalid result is refused by name. |
| 102 | - host construction against a manifest domain. |
| 103 | - the create / re-channel / no-write table for step 4. |
| 104 | - lease arithmetic in `ls`, including an expired canary and a pointer that |
| 105 | cannot be read. |
| 106 | - `rm` targets the record for the named canary and no other. |
| 107 | - the docsite gate: `### carlos canary` still exists, its fenced examples |
| 108 | run real verbs, and every `/docs` link resolves. |
| 109 | |