carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– CARLOS platform
2
3> πŸ€– This document was written by an LLM (Claude), on the ideas,
4> instruction, and editing of humans, per
5> [factor X](https://11factor.org/#x).
6
7A black-box deployment platform for CARLOS apps: `carlos ship` publishes an
8immutable, content-addressed release; the platform places it on instances,
9routes to it through a shared edge, and surrounds it with platform-level
10glue. One deployment is **one object-store bucket + N hosts running the
11`carlos` binary + DNS** β€” the whole self-hosting story in one sentence. See
12the `2026-08-01-carlos-platform-design.md` design spec (private `platform-infrastructure` repo)
13for the full design.
14
15## Build & test
16
17 make build # CGO_ENABLED=0 build of the carlos binary
18 make test # go vet, gofmt -l, go test ./...
19
20One static binary plays every role, selected by subcommand: `carlos edge`,
21`carlos agent` (edge + activator + release adoption + fleet status, one
22process per host), `carlos ship`/`promote`/`rollback`, `carlos vet`. Run
23`carlos` with no arguments for the full command list.
24
25### Platforms
26
27Linux and macOS run every role β€” box and workstation alike β€” and install
28from apt (`sudo apt install carlos`) or Homebrew
29(`brew install carlosframework/tap/carlos`).
30
31**Windows is a client-only target.** A Windows workstation can `carlos
32login`, `carlos ship`, `carlos deploy`, `carlos logs` β€” everything that
33talks to a console or a bucket over the network β€” against a Linux fleet.
34It cannot BE a box: `carlos agent` and the root-side passes need unix
35sockets, `flock` and systemd, and the packages that provide them compile
36on Windows (the CLI is the same binary) but refuse at their per-OS
37boundary rather than pretending. See `internal/edge/purge_listen_windows.go`
38and `internal/host/{statfs,flock}_windows.go` for what that refusal means
39in each case.
40
41Two consequences worth knowing before you install it:
42
43- **`carlos update` does not self-replace on Windows.** It still checks the
44 release feed and tells you when you are behind, then hands you the URL of
45 the zip to unpack over your existing binary. Windows cannot rename a new
46 binary over a running `.exe`, and a self-replace nobody here can test is
47 worse than an honest manual step.
48- **Your credentials file is not permission-protected on Windows.** On Linux
49 and macOS `~/.carlos/credentials` is written `0600`; the equivalent mode
50 on `%USERPROFILE%\.carlos\credentials` means nothing, so any other user
51 of that machine can read your session. Do not `carlos login` on a shared
52 Windows machine.
53
54Install by downloading `carlos-windows-amd64.zip` (or `-arm64`) from
55[carlosframework/releases](https://github.com/carlosframework/releases/releases),
56unpacking `carlos.exe` somewhere on your `PATH`, and verifying its checksum
57against the release's signed `SHA256SUMS.txt`.
58
59## Accounts
60
61An **account** is the tenancy primitive: a project or team that owns apps and
62has members. Kass, Keymail and Woodstar each get their own rather than sharing
63one flat list, and a self-hosted single-app deployment can give team A and
64team B separate accounts that deploy independently.
65
66An account is `{id, sqid, name}`. The **id** is the next free integer,
67allocated at creation by a create-only write of `accounts/<sqid>/account.json`
68in the deployment bucket; the registry only ever *materializes* what the
69bucket says, never assigns. That is what makes account identity survive a
70registry rebuild and be identical on every box: minted once, recorded in the
71bucket, never reassigned. Sequential ids keep sqids short enough to live in a
72hostname you can read out (`kass.bac.oncarlos.com`); they are not secret, and
73never were β€” every platform hostname's certificate is published in
74Certificate Transparency. The **sqid** is the frozen encoder over that id and
75is the account's public identity, in every platform domain and every bucket
76key. The **name** is free text and deliberately not unique: two accounts may
77both be called "Blog", because identity is the sqid and there is nothing to
78squat.
79
80`ops` is the built-in operator account β€” id 1, sqid `bab` β€” created by
81migration on every deployment. CLI-added apps live there by default, so a
82console-less self-hoster is unaffected by any of this.
83
84**Roles.** Exactly two:
85
86| Role | Can |
87|---|---|
88| **owner** | everything a member can, plus invite, remove members, rename |
89| **member** | deploy, promote, rollback, create/retire canaries, view everything |
90
91Every account has at least one owner; the last one can be neither removed nor
92demoted (transfer first).
93
94**In the console.** The account switcher sits top left on every account page,
95where the wordmark used to be: a dropdown whose trigger names the account you
96are in, listing the accounts you are a member of and linking to the
97**New account** screen at `/accounts/new`. It is a `<details>` disclosure, so
98it is keyboard-operable and works with scripting off.
99URLs are explicit and shareable β€” `/a/<sqid>/apps/<app>` β€” so a link a
100teammate opens lands where you meant; `/` redirects to the account you were
101last in (or your first membership, or one minted on the spot). A signed-in
102non-member asking for an account URL gets the **same 404 a nonexistent page
103gets**: account existence is not probeable.
104
105**Invites** go into the addressee's Keymail Apps inbox β€” the platform never
106emails anyone β€” as a single-use link that expires in 7 days and is revocable.
107Clicking it while signed in as that address creates the membership.
108
109**From the CLI.** Every app verb takes `--account`, a sqid or a name,
110defaulting to `ops`:
111
112 carlos auth login --console https://platform.carlosframework.com
113 carlos accounts create --name kass # you own it
114 carlos accounts create --name kass --owner paul@keymail.dev # headless
115 carlos accounts list # sqid, name, app count
116 carlos apps create --account kass --app blog # claim the name (rate limited;
117 # the console's New app form is not)
118 carlos ship --account kass --app blog ...
119 carlos add --account kass --app blog blog.example.com
120
121**Colour.** The CLI colours its output when β€” and only when β€” it is writing
122to a terminal: a write that happened reads green, a dry run or a bypassed
123rail yellow, a refusal red, and context you did not ask for (the `using
124<console> as <you>` banner, "nothing to do" verdicts, follow-up hints) is
125dimmed. Redirect it, pipe it, or run it in CI and every byte is what it
126always was, so parsing scripts and test assertions are unaffected. Override
127with `CARLOS_COLOR=always|never`; `NO_COLOR=1` and `TERM=dumb` are honoured.
128
129**Who the CLI is.** `carlos auth login` gives the command line an identity,
130and that identity owns the accounts it creates. It authenticates against the
131**deployment's own console** β€” already a registered Keymail OAuth client and
132already the authority on who belongs to what β€” rather than minting a second
133OAuth client, so there is one consent screen, one place to revoke, and a
134self-hoster's CLI trusts their own box.
135
136It prints a code rather than opening a browser or listening on a local port,
137because the machine that needs the credential is usually an SSH session where
138neither works. You open `/auth/cli` in whatever browser you are already signed
139in on and **type the code** the terminal printed.
140
141Typing it is the point, not a nicety: a link that arrives one click from an
142Approve button is the device-code phishing attack (the one run at scale against
143Microsoft tenants) β€” an attacker starts the flow, sends you their link, and one
144click hands them a credential that acts as you. So the console never approves
145from a prefilled code, and the CLI never prints the ready-made link. When a
146token is issued, a note goes to your Keymail inbox naming the terminal, where
147it connected from, and how to revoke it.
148
149The token lands in `~/.carlos/credentials` (mode 0600, one entry per console
150origin), expires in 90 days, and is listed with a revoke button at
151`/auth/cli/tokens`.
152
153 carlos auth whoami # who this terminal is, per the console
154 carlos auth logout # revokes the token, then forgets it
155
156**Shipping from a logged-in laptop.** Once you're logged in, `ship`,
157`promote`, `rollback`, `channels`, and `env`/`secrets` (issue #43) speak
158through the console instead of the bucket directly β€” no
159`CARLOS_DEPLOYMENT_BUCKET`/`_DIR` on a developer's machine at all:
160
161 carlos ship --app blog --version $(git rev-parse --short HEAD) --target linux-arm64 blog-linux-arm64
162 carlos promote --app blog abc1234 canary/my-branch
163 carlos channels --app blog
164
165Canary and edge promote exactly as before; **promoting onto `stable` is
166refused** β€” the console names itself in the refusal, because moving `stable`
167needs a passkey step-up only a browser can do. Finish it at the URL the
168refusal prints.
169
170**CI uses the console too.** Mint a deploy credential for the app, limited
171to the rungs CI may move, and give CI the secret and the console's address:
172
173 carlos services create --role deploy --app blog --rungs canary,edge ci
174 # in CI:
175 export CARLOS_SERVICE_TOKEN=... CARLOS_CONSOLE=https://console.example.com
176 carlos deploy --app blog --version "$GITHUB_SHA" --channel edge blog-linux-arm64
177
178`carlos deploy` ships, promotes and watches the app's hosts, the same as
179from a laptop. The first deploy of a new app still needs a person: a deploy
180credential cannot create the app or set up its first host. Every promote is
181audited in the console and reaches the boxes within seconds. A deploy
182credential can ship, and promote or roll back only its rungs. It cannot
183reach stable, hotfix, delete the app, or change retention or domains.
184
185**Direct bucket mode is for operators.** With `CARLOS_DEPLOYMENT_BUCKET`
186(or `_DIR`) set, every verb goes straight to the bucket and the console
187never sees it. Use it to set up a deployment before its console exists, to
188run one that has no console, to recover when the console is down, and for
189the platform's own releases. A scoped `CARLOS_RELEASE_KEY` limits that path
190only. The CLI refuses to run with both `CARLOS_SERVICE_TOKEN` and a bucket
191variable set.
192
193**On a box, or in CI, don't log in.** There is no browser and no human, and a
194long-lived token sitting on a box is a standing credential nobody is watching.
195Name the owner outright instead β€” it needs no identity of its own and takes
196precedence over any logged-in one:
197
198 carlos accounts create --name kass --owner paul@keymail.dev
199
200Either way an owner is named. `carlos accounts create` **refuses** with no
201login and no `--owner`: an account with no owner materializes into the console
202with no members, which means nobody can open it, invite to it, or see the apps
203migrated into it.
204
205An exact sqid wins; otherwise a unique name match; an ambiguous name lists the
206candidates and refuses rather than guessing which tenant you meant.
207
208`--owner` is required and is recorded in the account manifest, which the console
209materializes into the account's first owner membership. Membership *is*
210authorization here, so an account created without one exists, holds its apps,
211and cannot be opened by anybody β€” a state the flagship reached for real during
212the multi-account migration.
213
214**Bucket layout, and the migration.** App objects live under
215`apps/<account-sqid>/<app>/…`, and that key path *is* the appβ†’account linkage
216β€” there is no separate manifest. **Every read path resolves those qualified
217keys and nothing else** (the legacy read-fallback was retired on 2026-08-03,
218once both deployments were migrated). Moving an app is a verb:
219
220 carlos accounts migrate --app kass --to <sqid> --dry-run # prints the plan
221 carlos accounts migrate --app kass --to <sqid>
222 console migrate-app --app kass --to <sqid> # on the console's box
223
224It copies the objects and re-stamps the app's routes. The console keeps its
225own app→account row — console state is the console's authority, and the
226carlos binary deliberately never opens its database β€” so `console
227migrate-app` completes the move; `carlos accounts migrate` prints that exact
228command when it finishes. Both halves are idempotent.
229
230**Upgrading from a pre-accounts binary?** Your objects are all at the old
231account-less `apps/<app>/…` keys, which nothing reads any more β€” so the apps
232will go quiet rather than serve something stale, and every miss tells you so
233by name:
234
235 carlos accounts migrate --app <app> --from legacy --to bab # --to <sqid> for a tenant
236
237That `--from legacy` source is permanent. However long a deployment sits on an
238old build, this is the way off those keys.
239
240**It never deletes a legacy object.** Deleting them is a separate operator
241step, and an unhurried one now that nothing resolves through them.
242
243## Releasing the platform
244
245A release is a `vX.Y.Z` tag on this repo plus signed binaries on [carlosframework/releases](https://github.com/carlosframework/releases). Everything downstream reads that one set of files: `carlos update`, the Homebrew tap, apt, and every deployment's `carlos system update`. Cut it from a green `main` and verify it from the public side, never from your build directory.
246
2471. **Tag.** `git tag -a vX.Y.Z <sha> -m "carlos vX.Y.Z"` on the `main` commit you are releasing, then `git push origin vX.Y.Z` to the forge.
2482. **Build.** Six targets, darwin, linux and windows on amd64 and arm64, each with `make build GOOS=<os> GOARCH=<arch> SHA=vX.Y.Z` from a clean checkout of the tag. Build with `TMPDIR` and `GOCACHE` on a real disk: `/tmp` is a shared tmpfs, and a full one fails the link with "no space left on device". Zip each Windows binary as a lone `carlos.exe`.
2493. **Debs.** One per architecture, built with `dpkg-deb --root-owner-group`, holding `usr/bin/carlos`. Copy the last release's `control` and change the version and the description's last sentence. Wrap the description without breaking on hyphens, or apt shows "step- by-step".
2504. **Sums and signature.** Write `SHA256SUMS.txt` over the eight files. Sign it with the ed25519 seed in SSM `/carlos/cli-update-key`, passed on stdin and never in argv, and write the signature base64-encoded to `SHA256SUMS.txt.sig`. Before signing, check that the key's public half equals `cliUpdatePubKey` in `update.go`. A mismatch publishes a release nobody can update to.
2515. **Release notes.** Draft them, and put them through copy review before anything is published. Lead with what changed for people using the CLI. Add a short section for operators, taken from the release notes below this recipe, about anything to check before rolling.
2526. **Publish.** `gh release create vX.Y.Z -R carlosframework/releases --latest --title vX.Y.Z --notes-file NOTES.md` with all ten files. Confirm it is the latest with `gh api repos/carlosframework/releases/releases/latest -q .tag_name`.
2537. **Homebrew.** In `carlosframework/homebrew-tap`, bump `Formula/carlos.rb`: the version, the four url and sha256 pairs from the published `SHA256SUMS.txt`, and the `carlos vX.Y.Z` test string. Open a PR, check each hash against the sums file, and squash-merge it.
2548. **apt.** Download the live tree, `apps/bab/apt-carlosframework/releases/<last>/site.tar.gz` from the flagship bucket, and unpack it. Add the two debs under `pool/main/c/carlos/`, and regenerate each architecture's `Packages` with `dpkg-scanpackages --multiversion`. Delete `Release`, `InRelease` and `Release.gpg`, then rebuild `Release` with `apt-ftparchive release`, passing Origin and Label `CARLOS`, Suite and Codename `stable`, the architectures and the component. Write it outside the tree first, or it lists itself. Sign `InRelease` and `Release.gpg` with the SSM key `/carlos/apt-signing-key` in a throwaway `GNUPGHOME`, set `latest.txt` to the tag, and check both signatures with `gpgv` against the tree's own keyring.
2559. **Ship apt.** Ship the directory and let `carlos ship` build the tarball. A hand-made tar with a leading `./` entry took apt down once. With `AWS_PROFILE=keymail` and `CARLOS_DEPLOYMENT_BUCKET` set to the flagship bucket, run `carlos ship -app apt-carlosframework -account-id bab -kind static -version vX.Y.Z-1 site/`, then `carlos promote -app apt-carlosframework -account-id bab vX.Y.Z-1 canary/apt` with `CARLOS_RELEASE_KEY` from SSM `/carlos/release-key` set for that one command. The channel is the literal `canary/apt`.
25610. **Changelog.** In a full checkout of `main`, with `gh` and `amadan` signed in, run `git fetch origin --tags '+refs/amadan/closed/*:refs/amadan/closed/*'`, then `go run ./cmd/changelog`. Map each of the new release's bullets to the branches behind it in `docs/site/changelog-links.json`, keyed by the bullet's bold lead, and run it again. Land `docs/site/changelog.json` and the links file on a branch. Then, in `carlosframework/website`, run `node hack/sync-docs.mjs <checkout>` and ship the site the way its AGENTS.md describes. The page is carlosframework.com/platform/changelog.
257
258Then verify from the public side:
259
260```sh
261v=vX.Y.Z; rel=https://github.com/carlosframework/releases/releases/download/$v
262curl -sSLO $rel/carlos-linux-arm64; curl -sSLO $rel/SHA256SUMS.txt; curl -sSLO $rel/SHA256SUMS.txt.sig
263printf '\x30\x2a\x30\x05\x06\x03\x2b\x65\x70\x03\x21\x00' > pub.der
264echo 'R+EO3qQ47VsuKl9Qq/pkDXBucPYvzp5lU2ut4zpnJPc=' | base64 -d >> pub.der
265base64 -d SHA256SUMS.txt.sig > sig.bin
266openssl pkeyutl -verify -pubin -inkey pub.der -keyform DER -rawin -in SHA256SUMS.txt -sigfile sig.bin
267sha256sum -c --ignore-missing SHA256SUMS.txt
268curl -sS https://apt.carlosframework.com/latest.txt # prints $v
269carlos system update --console <console> --version $v # then: carlos system status
270```
271
272Last, roll it, and roll dev before flagship. The order is fixed: dev boxes, then the dev console, then check dev end to end, then flagship boxes, then the flagship console. Do not start on flagship until dev has been exercised on the new build.
273
274`carlos system update --console <console> --version vX.Y.Z`, then `carlos system status`. Every box should read `current` within the spread. A box that reads `rolled-back` or `refused` says why on the same line; fix that box, and do not re-cut the release.
275
276**Checking dev.** The check is `wakecheck`, a standing hibernating app on dev. Each request writes one SQLite row and replies with its build, the box's host and the row count (`hits=`). It has no region, so both dev boxes serve it: `carlos-dev-ie`, which is the console box, and `carlos-dev-ie-b`. Once dev is rolled:
277
2781. Confirm it is asleep. `carlos logs --app wakecheck --console https://platform.dev.carlosframework.com --since 30m` should end in "instance exited". Do not use `instances list --health` for this, because it always reads Asleep (#287). Idle is 15 minutes and bots can keep it awake, so wait it out.
2792. Wake it on `carlos-dev-ie` through DNS: `curl -sS https://wakecheck.dev.carlosframework.com/`. A pass is a 200 whose `host=` is that box, in about 0.6 s to first byte.
2803. Wake it on `carlos-dev-ie-b` by pinning its address: `curl -sS --resolve wakecheck.dev.carlosframework.com:443:18.200.150.210 https://wakecheck.dev.carlosframework.com/`. A pass is a 200 whose `host=` is that box. It takes about 1.6 s, because it restores from S3.
2814. `carlos status --layout` shows `wakecheck` as `nested` on both boxes.
282
283On dev, `hits=` going backwards from one box to the other is not a failure. With the claim off, a wake that switches box can serve a stale local DB. That is the known hazard in #286, and it stays until the claim ruling lands. Judge the check on the 200s and the `host=` values, not on the count.
284
285Three things catch people out on the roll:
286
287- **`system update` rolls a whole channel.** It rolls every box on the channel within the spread. It cannot canary one box. To put a release on one box first, roll that box by hand with `scripts/update-platform-binary.sh` in platform-infrastructure.
288- **Old updaters need `carlos-instance-control` restarted.** A box whose updater predates v0.26.0 does not restart `carlos-instance-control` when it rolls. The moment the new edge serves, restart it by hand. Until then the new agent refuses isolated wakes as "running an older build". Use a one-off watcher to do the restart, not memory.
289- **The dev console needs its release key first.** Before dev can be rolled with `system update`, its console must hold the release key. That is `/etc/carlos/release.env` (root:root, 0600) and a `console.service.d/release-key.conf` drop-in that loads it.
290
291- Refresh the embedded timezone tables when a new IANA tzdata release is out:
292 `scripts/update-tzgeo.sh <version>`, then `go test ./internal/geo`.
293
294A release-specific note goes here when a release changes something an
295operator needs to check or act on before rolling it. Read the note for
296the release you are about to roll.
297
298**Binary size (#275).** The linux/arm64 platform binary doubled to 77.7 MB in earlier builds because SDK clients were kept whole by the linker. This release is 45.7 MB again, so boxes download about 32 MB less. CI now fails a build over 52 MB.
299
300**Pointer digests (#253): writers before readers.** Once a box has verified a channel pointer that carries manifest digests, it refuses pointers for that channel without them. So digests are turned on in order. First upgrade every writer: every console, and every CLI or deploy script that promotes, rolls back or fast-tracks. Then run `carlos channels digests on`. Then watch `carlos channels digests`: a channel marked as written by an old writer means one is still in use. Rolling a writer back below this release stops it writing pointers boxes accept; `carlos channels digests off` suspends digests while it is fixed, but does not clear the boxes that have already seen them. When `carlos channels digests` lists nothing, set `CARLOS_REQUIRE_POINTER_DIGEST=on` in every box's host.env, so a new box refuses old pointers too.
301
302**Instance update settings: the roll order.** Roll every box to the release that applies update settings before anyone turns on manual updates, a major hold or a pin: the console refuses those until every box reports it can protect the builds they name from pruning, and says how many boxes it is waiting on. Before rolling any box back below that release, turn retention off for the deployment (`carlos releases retention --off` for each app that has one), because an older housekeeper prunes builds a pin or a held instance still needs. After such a rollback the console shows *settings not enforced* for instances on the older boxes: they follow their channel until the box is rolled forward again.
303
304**v0.25.0: instances move to the nested layout on their own.** This is
305the first release on which an isolated (nested) instance wakes, and nested
306is the default. On each box, migration starts unattended on the first
307serial move after the release lands and the box's isolation probe passes:
308every activator-managed instance that is asleep is moved into its own
309directory and namespace at once; one that is awake gets an hour and is
310then stopped for its move. An instance whose move is refused for a reason
311a tenant could have planted (a file in its new directory, a second name
312for its database) is held down automatically, and stays down across agent
313restarts until the reason is cleared or the layout is reverted. An
314operator who wants control over any of that acts BEFORE rolling: set
315`CARLOS_LAYOUT_DEFAULT=flat` in each box's host.env to keep the fleet flat,
316or record `carlos system layout migrate --box <label>` to move one box at
317a time, or `carlos system layout revert` to opt the fleet out; a recorded
318intent always wins over the default. Watch with `carlos status --layout`,
319which marks a box `(nested by default)` and says why a box the default
320has not reached is still flat. Cut v0.25.0 from a main that contains
3217b7d3026 (plan 4's edge openat2 fix); without it nested instances cannot
322wake.
323
324**App regions rollback floor.** Once any app holds a region other than the
325home, do not roll the console binary below the first release that
326understands app regions: an older console places a no-region create at the
327home. That release's version is named here, in its release notes, and in the
328console roll recipe in carloku-infrastructure; check all three name the same
329version before turning `CONSOLE_APP_REGIONS` on. If a rollback below the
330floor happens anyway, write down when it started and ended: on its next
331start the console logs "possible placement discrepancy" lines, and the
332interval tells you which to review. Turning the switch off is the feature's
333own rollback: it stops new non-home regions and keeps existing ones in force.
334
335The first release that understands app regions is v0.23.0.
336
337**Turning app regions on.** In order:
338
3391. Stage the deployment's `CARLOS_SECRETS_KEY` on every box in every region
340 that should take placed instances, and set `CARLOS_SECRETS_PUBKEY` on the
341 console. A box without the key refuses to mint or wake placed exec instances
342 whose config contains sealed secrets.
3432. Roll the box release that publishes `serves_shared_pool`,
344 `accepts_inbound` and `secrets_key_fp`. Check every region's
345 `control/edges/*.json` carries them, and that each `secrets_key_fp` equals
346 the console's.
3473. Roll the console with the switch off.
3484. On dev, turn the switch on. Create an app and an instance, read their
349 region, and run the service gate below for every region.
3505. On production, with the switch still off, create one probe instance per
351 eligible region with an explicit `--region`, deploy a known build, and run
352 `scripts/region-service-gate.sh <probe-host> <build> <every other edge EIP>`
353 for each. Every region must pass. Delete the probes.
3546. Turn the switch on. Re-run step 5 after any period with the switch off,
355 and whenever a new region becomes eligible.
356
357**#292: instance-control changes.** The release carrying #292 changes
358`carlos-instance-control`: it orders a host's legacy run starts against
359its stops. So the service must restart on roll. A box whose updater is
360v0.26.0 or later does that itself, so no manual step is needed; an older
361updater needs the hand restart described under "Old updaters" above.
362
363### Tenant email senders, plan 2 of 5 (2026-09-21)
364
365This release turns the one implicit per-app email sender into an explicit
366sender record (design spec `2026-09-15-tenant-email-senders-design.md`).
367
368- SES event counts now attribute to the sender that sent them. an app
369 sharing a domain with a sibling app stops being charged for the
370 sibling's sends. a tenant watching their own numbers will see them move.
371- the app's email record moved to
372 `apps/<acct>/<app>/email/senders/app.json`, with a read fallback to the
373 old `email.json`. a console rolled back after the first sender write
374 loses any pause or rotation made in between. that has to be redone by
375 hand.
376- two prerequisites from the design spec (Β§4.2) before any release sends
377 with a tenant header.
378 - every domain provisioned before this release has no SES tenant
379 association, and nothing will create one on its own. a backfill, or
380 a re-run of `carlos email domains add` against every existing
381 domain, is needed before any release that sends with a tenant
382 header.
383 - the control-plane role needs `ses:CreateTenantResourceAssociation`
384 and `ses:DeleteTenantResourceAssociation` before this rolls.
385- `CARLOS_MAIL_LEGACY_SWEEP=1` is off by default. it does two separate
386 things. it deletes the bucket's legacy `email.json` objects, and it
387 drops the database's `mail_counts` table. do not set it until the
388 release after this one has been live for a while.
389
390### Tenant email senders, plan 4 of 5 (2026-09-22)
391
392This release adds the per-instance config layer (design spec
393`2026-09-15-tenant-email-senders-design.md` Β§6.2): a third config bundle,
394layered over an app's default and named-environment config, scoped to one
395route's host rather than the whole app.
396
397- a new failure mode comes with this layer. every config sweep now also
398 lists `apps/<acct>/<app>/instances-config/` before it converges that
399 app's routes. s3 throttling, an iam policy change that adds a prefix
400 condition to `s3:ListBucket`, or a transient 503 on that call all look
401 the same from a box: the listing fails, and every route of that app
402 freezes on its last-good config file until the listing succeeds again.
403 this holds for the whole app, not just routes with an overlay of their
404 own, because the listing runs once per app regardless of who uses the
405 layer.
406- grep the box's log for `instance config unresolved` to find it. it
407 prints once per app, naming the account, the app and one route the pass
408 reached. every other route of that app is covered by the same line and
409 the same cause.
410- it clears itself. once the listing succeeds again, the next pass
411 converges normally, with nothing to clean up by hand.
412- the in-repo iam policies grant `s3:ListBucket` on the whole deployment
413 bucket with no prefix condition, so a platform box is unaffected by this
414 on its own account's policy. check a hand-edited or self-hosted
415 deployment's policy before this rolls there, since a narrower grant is
416 exactly what would trip it.
417
418### Availability, and why the edge had no answer (2026-09-24)
419
420This release moves every 502, 503 and 504 the edge sees from the Errors
421tab to a new Availability tab, grouped by cause (errors spec
422`2026-09-07-errors-and-analytics-design.md` Β§11), and adds replica
423placements (#409).
424
425- roll the boxes and the console together. the boxes record causes and the
426 console shows them. the console alone gives the Availability tab, and
427 502s already stored regroup there under "cause not recorded"; until the
428 boxes roll, new 502s land there too. boxes alone, with an old console,
429 file new records the old console lists on Errors, one row per cause,
430 named like "502 Β· the instance refused the connection".
431- rolling the console back is safe. nothing in the bucket is rewritten;
432 the regrouping happens on read.
433- the Errors tab and `carlos errors` show fewer rows. edge 502/503/504 and
434 every platform-stream line (failed wakes, exits, sidecar restarts) are
435 on Availability now. anyone watching Errors for wake failures should
436 watch Availability instead.
437- a request whose visitor gives up after waiting five seconds or more,
438 before the instance has sent response headers, is now filed as an
439 outage, as "the visitor gave up waiting for the instance". the five
440 seconds count from when the edge received the request, so on a
441 hibernating route they include the wake. an endpoint that holds its
442 response headers longer than that by design, like a long poll, will
443 show a standing row for it. that row is expected; it is not a
444 regression. a slow export that sends its headers straight away does
445 not.
446- a visitor who leaves within five seconds is no longer filed at all.
447 previously those were filed as a 502 on whatever path they were
448 loading, so expect fewer single-count 502s on static assets.
449- on a route this box proxies over a tunnel or to a fleet, the front box
450 now files only its own hop failures. the box serving the app files the
451 rest, so a tunnelled 500 is filed once, not twice. two edges follow from
452 that: a visitor who leaves after five seconds can be filed as
453 "gave up waiting" on both boxes, and a far agent whose status store
454 failed to open files nothing, which the front box no longer covers.
455- a new CLI against a console that has not been updated:
456 - `carlos availability` fails with `not found: check --account and
457 --app, and that your token's address is a member`. until the
458 console rolls, that message means the console predates the
459 command, not that the app or your membership is missing.
460 - `carlos instances create --role replica` is silently ignored by an
461 old console, which creates an ordinary home instance in that region
462 instead. update the console before creating a replica.
463- replica placements add two registry columns (`routes.replicas`,
464 `routes.replica`) on each box's start, by an idempotent `ALTER TABLE`.
465 there is no migration to run, and a box rolled back keeps two unused
466 columns harmlessly. do not create a replica until every box is on this
467 release: only this release's DNS steering leaves replicas out of
468 latency routing.
469
470### Seven days of history for sleeping instances (2026-10-08)
471
472This release keeps seven days of database snapshots for instances that
473sleep, the same as instances that stay awake (#263). `carlos db pull --at`
474(#262) can then reach back a week on them, not a day.
475
476- boxes only. the console is unaffected.
477- the window fills in over the week after the roll. snapshots older than a
478 day were already deleted under the old setting, so on the day of the roll a
479 sleeping instance still reaches back about a day, and a week only seven
480 days later.
481- an instance's history moves only while it is awake. one that sleeps for
482 several days takes no snapshot in that time, and loses none either:
483 retention also runs only while it is awake.
484- storage grows by up to six more daily snapshots per sleeping instance,
485 plus the hourly files back to the oldest one. there are no extra s3
486 requests.
487
488### Bucket reads only when something changed (2026-10-22)
489
490This release stops every box and the console from reading the whole deployment bucket on every pass (#336). Writers now record which app changed in `control/changes.json`, and readers re-read only those apps. A full read of the bucket still runs every 30 minutes as a safety net.
491
492- roll every box and the console before you arm any reader. writers update the index as soon as they run the new build, and readers stay off until you arm them.
493- arm a box by adding `CARLOS_CHANGE_INDEX=read` to its `host.env` and restarting it, and the console the same way in its environment. do dev first, and boxes before the console.
494- to turn it off, remove the variable and restart. readers go back to reading the whole bucket, and writers keep updating the index, which does no harm.
495- anyone using an older carlos CLI that writes straight to the bucket (promote, env set, trash) should update it. until they do, their changes reach boxes at the next 30-minute read, not within seconds.
496- customer fleet boxes keep reading the way they do today.
497- to see the effect, look at daily S3 ListBucket and GetObject requests for the deployment bucket in Cost Explorer. they should fall within a day of arming.
498
499### Email sending users get a permissions boundary (2026-10-23)
500
501This release creates every email sending user (IAM path `/carlos-mail/`) under a permissions boundary, the way store users already are. Without one, anything that can act as the console's role can create a user on that path, give it an administrator policy and mint it a key.
502
503A console with `CARLOS_MAIL_REGION` set refuses to start on this release without `CARLOS_MAIL_USER_BOUNDARY`. Its value is the ARN of a customer managed policy in the same account, `arn:aws:iam::<12-digit account>:policy/<name>`, that allows `ses:SendRawEmail` and nothing else. A console without `CARLOS_MAIL_REGION` needs nothing.
504
505Roll it in this order:
506
5071. create the boundary policy. set `CARLOS_MAIL_USER_BOUNDARY` to its ARN in every console's environment before any console gets this release. an older console ignores the variable. this release refuses to start without it.
5082. apply IAM step 1 to the console role. add `iam:PutUserPermissionsBoundary` on `user/carlos-mail/*`, conditioned on `iam:PermissionsBoundary` equal to the boundary ARN. without that condition the role can set any boundary it likes. add `iam:ListUsers` for the sweep in step 4. AWS does not scope `ListUsers` to a path, so it is granted on `Resource "*"`. it only reveals user names and paths, and the sweep asks only for `/carlos-mail/`. the role already has `iam:GetUser` on the path. never grant `iam:DeleteUserPermissionsBoundary`. nothing calls it.
5093. roll the console. from now on every new sending user gets the boundary. an existing user gets it the next time the console touches it: enable, a standalone credential, a domain change, and key rotation too, since no key is minted for a user without the boundary.
5104. run the sweep, which also reaches users nothing will touch again. set `CARLOS_MAIL_BOUNDARY_SWEEP=1` on one console and restart it. it logs `set the boundary on <user>` for each user it fixes. its last line is either `all N mail users bounded` or the users that are not, with why. fix any failure and run it again. then remove the variable.
5115. check it yourself. for every user in `aws iam list-users --path-prefix /carlos-mail/`, `aws iam get-user --user-name <user>` must show `PermissionsBoundary` with the boundary ARN.
5126. apply IAM step 2. condition the role's `iam:CreateUser` and `iam:PutUserPolicy` on `iam:PermissionsBoundary` equal to the boundary ARN. do this only after every console runs this release, because an older console creates users without the boundary and step 2 refuses them.
5137. wait a few minutes for IAM to propagate, then run the check in step 5 again with your own operator credentials, not the console role. `iam:CreateAccessKey` cannot be conditioned on the boundary, so anyone holding the console role could have made an unbounded user between step 5 and step 2. delete any user without the boundary, or set the boundary on it. the gap is closed only when this second check is clean.
514
515## App config & secrets
516
517Per-app config lives in the deployment bucket
518(`apps/<acct>/<app>/config.json`): plain vars via `carlos env`, secrets
519sealed client-side via `carlos secrets` β€” the bucket never sees plaintext.
520
521 carlos secrets genkey # mint CARLOS_SECRETS_KEY / CARLOS_SECRETS_PUBKEY
522 carlos env set --app keymail LOG_LEVEL=debug
523 carlos secrets set --app keymail SES_SECRET=... # sealed with CARLOS_SECRETS_PUBKEY before upload
524 carlos secrets list --app keymail # key names only β€” a value is never readable again
525
526Like `ship`/`promote` (see "From the CLI" above), `env` and `secrets` work
527through a logged-in console with no `CARLOS_DEPLOYMENT_BUCKET`/`_DIR` on your
528machine at all (issue #43) β€” `carlos auth login` alone is enough, including
529for `secrets set`: with no `CARLOS_SECRETS_PUBKEY` in your own shell, it
530seals against the console's own served public key instead (set via
531`CARLOS_SECRETS_PUBKEY` in *the console's* environment; the private half
532never leaves the hosts that hold it either way). Direct-bucket mode
533(`CARLOS_DEPLOYMENT_BUCKET`/`_DIR` set) is unchanged.
534
535The keypair is the mirror image of the release signing pair:
536`CARLOS_SECRETS_PUBKEY` only encrypts and can be held widely (CI, laptops
537β€” write-only credentials); `CARLOS_SECRETS_KEY` decrypts and belongs in
538exactly two places, your secret manager and every host's
539`/etc/carlos/host.env`.
540
541Hosts converge during the hourly root `carlos adopt` pass: with
542`CARLOS_SECRETS_KEY` set, each pass materializes the manifest into
543`/etc/carlos/apps/<route.Host>.env` (the file `carlos-app@.service`
544already reads) and restarts routes whose file changed. With it unset,
545config sync is inert β€” the same convention as every other optional loop.
546A write also reaches exec-backed instances in seconds rather than waiting
547for that hourly pass (issue #44) via the agent's own fast poll loop; run
548`carlos env sync --app <a>` to request convergence on demand.
549
550Operational notes:
551
552- Delivery is split by backing: unit-backed routes (`Backing: "unit"`)
553 materialize to `/etc/carlos/apps/<host>.env` via the hourly root
554 `carlos adopt`; exec-backed instance routes (socket-upstream)
555 materialize to `/data/<host>.config.env` via the agent's in-process
556 hourly loop, which also bounces a running instance when its config
557 changes. At spawn the instance's own `/data/<host>.env` is layered on
558 top β€” it wins every conflict, so `CARLOS_ADMIN_TOKEN` and box-local
559 overrides are never clobbered from the bucket. Addr-upstream
560 (proxy-only) routes get no delivery β€” the process behind them is
561 externally managed.
562- Migrate an app's config into the bucket *before* enabling
563 `CARLOS_SECRETS_KEY` on its hosts: once a manifest object exists for an
564 app, the bucket is authoritative for its env files (fully unset β‡’
565 converges to empty). An app with no manifest at all is left alone.
566- Values are write-only. Rotating the keypair means re-entering every
567 secret of every app from its source of record β€” there is no rewrap yet.
568
569## Domains
570
571Every app has domains as a first-class concept: a **platform domain** it is
572born with, and any number of **custom domains** layered on top β€” each with
573DNS and TLS health visible in the console, and point-your-DNS-here guidance
574when a custom domain isn't connected yet. The scheme:
575
576 <app>.<account-sqid>.<apps-domain> kass.bab.oncarlos.com
577 <canary>.<app>.<account-sqid>.<apps-domain> pricing.kass.bab.oncarlos.com
578
579The account carries the sqid (base-23 over a frozen no-lookalike alphabet,
580encoding the account's minted id β€” deliberately not configurable, so nobody
581can squat a vanity label; custom domains are the vanity mechanism), the app
582carries its name β€” app names are unique per account, not globally. Account #1
583is `ops`, the deployment operator's built-in account, created by migration;
584CLI-added apps live there, and its sqid (`bab`) is identical on every
585deployment. The
586console writes `apps/<app>/account.json` at claim time; the agent's
587reconcile pass (riding the 5-minute tick) reads those manifests and mints
588platform routes, and removes only routes it minted itself β€” **custom
589domains are never touched by reconciliation**. DNS needs one wildcard A
590record (`*.<apps-domain>` β†’ edge; RFC 4592 wildcards match multiple
591labels); certs come per-host from the edge's existing ACME machinery.
592
593A platform domain is an **alias**: a second name for the app's canonical
594route, pointing at the same backend. It is not a lifecycle host β€” release
595adoption and the fleet status counts both skip minted routes, so a
596deployment that turns this feature on does not double-stage artifacts or
597publish an inflated instance count.
598
599**Three honest limits**, none of them papered over:
600
601- **Minting is additive β€” an app is never re-homed.** Once an app has a
602 platform apex, changing its account does *not* mint a new one or remove
603 the old: one app, one apex, and moving an app between accounts is a
604 deliberate operator migration (delete the old apex route by hand, let the
605 next pass mint the new one), never a silent side effect of editing a
606 manifest.
607- **A hibernating app's platform domain 502s while the app sleeps.** The
608 activator arms on the canonical route only; minted routes deliberately do
609 not copy the `hibernate` flag, because a minted route carrying it would
610 make the activator spawn a *second* process for the same app against an
611 empty `DBPath` β€” meaningfully worse than a 502. Wake the app through its
612 canonical host; the platform domain serves normally once it's up.
613- **Canary hosts are minted for static apps only.** For a static app the
614 channel genuinely is the backend (the edge resolves the channel pointer
615 to find that release's tar), so a canary host serves exactly the canary.
616 An instance app's minted canary would only be able to alias the app's
617 *stable* process β€” a URL claiming to serve the canary while proxying
618 stable β€” so none is minted; spawning a real per-host canary instance is
619 future work (`carlos canary`), and until it exists the pass logs the gap
620 rather than faking it.
621
622**Config surface:**
623
624- `PLATFORM_APPS_DOMAIN` β€” the deployment's apps domain (flagship:
625 `oncarlos.com`), read by `carlos agent`, the edge, and the console (the
626 member-facing `carlos instances enable` pins manifests to
627 `<sqid>.<this domain>`; the console refuses a malformed value at boot).
628 **Unset = the whole feature is inert**: no minting, no manifests read,
629 the enable door answers 503 β€” the same litestream convention as
630 everything else here.
631- `CONSOLE_RESERVE_PLATFORM_DOMAINS` β€” set to `true` on a deployment that
632 hosts apps for other people (the flagship, carloku). `carlos domains
633 attach` then refuses every name under `PLATFORM_APPS_DOMAIN` and under the
634 console's own hostname. **Unset = false**: a self-hosted deployment can
635 attach any name except the console's own hostname and
636 `CONSOLE_DENY_DOMAINS`. A value that is not a boolean stops the console at
637 boot.
638- `CONSOLE_DENY_DOMAINS`: comma-separated hostnames in the deployment's own domains (flagship: `carloku.com`). `carlos domains attach` refuses each one, and every name under it, to members. An owner of the ops account, or an address on `CARLOS_SYSTEM_OPERATORS`, can still attach one to an app with `carlos domains attach --platform`, and after that only they can change or detach it. The console's own hostnames, the steering host, the tunnel host and, with `CONSOLE_RESERVE_PLATFORM_DOMAINS`, everything under the apps domain stay refused to everyone.
639- `CONSOLE_DOMAIN_PROOF` β€” set to `true` to require proof of control before
640 an attached hostname is served: a TXT record at `_carlos-challenge.<host>`
641 (`carlos domains verify`). A deployment that hosts apps for other people
642 must set it, after the fleet runs a build that knows pending claims.
643 **Unset = false**: attach is first-writer-wins, as before. A value that is
644 not a boolean stops the console at boot.
645- `PLATFORM_EDGE_IPS` β€” comma-separated edge IPs the DNS health checks
646 compare against, read by both the agent (checks) and the console
647 (guidance text), so the two can never name different addresses. Default:
648 the box's public IP via IMDS, best-effort; if neither is knowable the
649 checks stand down and health reads unknown, never a guess.
650- `CONSOLE_APP_REGIONS`: `on` lets new apps be given a region other than the
651 home. It needs `CONSOLE_HOME_REGION` set too. With the home unnamed, the
652 console logs once and leaves app regions off. Default off. Automatic
653 placement outside the home also needs `CARLOS_SECRETS_PUBKEY`, so the
654 console can check that a box holds the deployment's secrets key.
655- `CONSOLE_GEOIP_DB`: the path to an operator-supplied MaxMind-format `.mmdb`.
656 DB-IP Lite is recommended, and it requires the attribution the new-app page
657 shows. Unset or unusable turns the IP step of the region guess off, and the
658 console starts either way. Replace the file by atomic rename, then restart.
659 For the platform-wide GeoIP that every edge serves to apps, use
660 `carlos system geoip publish` instead (docs/site/cli.md, "Platform GeoIP").
661 Uploads are staged in `geoip-staging/` next to `CONSOLE_DB`, which the
662 console clears at startup.
663- `CARLOS_SIDECAR_ACCOUNTS`: comma-separated account sqids (not names) that
664 may still enable and create native sidecars. Set, every other account gets
665 403 `sidecars-paused` from `carlos sidecars enable` and `carlos sidecars
666 create`. List, get, wake and delete still work, and existing sidecars keep
667 running. Native sidecars run on the flat tier, outside the nested tenants'
668 isolation, until nested sidecars land (discussion #370). **Unset or empty =
669 no gate.** An entry that is not a sqid stops the console at boot.
670
671**Canonical-host caveat**, stated honestly: an app that pins its own host
672(e.g. keymail's `--host keymail.dev`) will redirect or 404 on its platform
673domain β€” correct behavior. Rastrillo apps accept any granted host from day
674one; for legacy apps the platform domain is the pre-custom-domain bootstrap
675URL.
676
677## Fleet status
678
679Every deployment computes a fleet status view: how many instances are
680running, how busy the boxes are, whether replication is healthy β€” the
681CARLOS analogue of Eleven's own status pages. Each box's agent (`carlos
682agent`) writes its own snapshot to the deployment bucket every 5 minutes;
683whichever box holds the status lease that tick merges every snapshot into
684`status/fleet.json` plus the embedded viewer (`status/index.html`,
685`status/status.css`) and republishes all three. The edge serves them back
686out through a `kind=status` route, bucket-backed with a short cache β€” no
687release machinery involved, no CAS churn, just "read the key, cache it
688briefly, serve it."
689
690Three commitments hold end to end, enforced in the writer rather than the
691page: the page publishes only fleet-wide aggregates and band words
692(`comfortable`/`busy`/`critical`, `plenty`/`getting-full`/`at-capacity`) β€”
693never raw ratios, hostnames, or machine ids. An unknown reading publishes
694loudly as `null`, and a box that has stopped reporting renders `stale`,
695never silently dropped. App names are opt-in only, via `carlos add
696--status-named`, and everything else folds into one honest `apps_unlisted`
697count, so the aggregate never lies by omission.
698
699A route added this way is described by no bucket record, so nothing converges
700it: its channel and environment are box-local until an instance record adopts
701it. A provisioned route's channel is repointed through the record, not here.
702
703**Config surface** β€” every env var this feature reads, gathered here since
704no single reader owns the whole contract:
705
706- `CARLOS_STATUS_BUCKET` / `CARLOS_STATUS_PREFIX` / `CARLOS_STATUS_REGION`
707 β€” read by `carlos agent`'s status loop: the bucket each box writes its
708 snapshot to and, when it wins the lease, publishes the merged feed to;
709 region also feeds the cross-box status-lease election. **The serving side
710 (the edge's `kind=status` route and the console's operator view) must be
711 pointed at this same bucket** β€” nothing enforces that agreement
712 automatically, publishing and serving are separately configured. To make
713 the common case foolproof, an unset `CARLOS_STATUS_BUCKET` defaults to
714 `CARLOS_DEPLOYMENT_BUCKET` (an explicit `CARLOS_STATUS_BUCKET` still
715 always wins), so a single-bucket deployment needs only one env var for
716 both release adoption and status. Both unset disables the whole loop β€”
717 inert until configured, the same convention litestream replication uses.
718 **Honest limitation**: that default only ever resolves to an S3 bucket β€”
719 a deployment adopting from `CARLOS_DEPLOYMENT_DIR` (no bucket at all)
720 does not publish fleet status yet; give it its own `CARLOS_STATUS_BUCKET`
721 if it wants the page. `CARLOS_STATUS_PREFIX` does double duty: it
722 namespaces the status-lease key, AND (customer-fleets plan, Task 5)
723 rebases every key the status loop's AND the metrics loop's store
724 touches β€” a fleet box set to `CARLOS_STATUS_PREFIX=fleets/<name>/` writes
725 its own status snapshot at `fleets/<name>/status/boxes/<label>.json` and
726 its raw/rollup metrics and alert state under `fleets/<name>/metrics/boxes/…`
727 and `fleets/<name>/alerts/boxes/…`, instead of the shared paths a platform
728 box uses β€” so neither leaks into the platform's own `fleet.json`,
729 monitoring page, or alert webhook. Empty (every platform box today) is a
730 pure passthrough. A **publishing** box with a prefix set would relocate
731 `status/fleet.json` itself under that prefix too β€” moot today, since
732 nothing sets a prefix on a publishing (non-fleet) box, and a fleet box
733 never publishes at all (see `CARLOS_FLEET`, below).
734- A box with `CARLOS_HIBERNATE_BUCKET` set but no deployment store
735 (`CARLOS_DEPLOYMENT_BUCKET` or `CARLOS_DEPLOYMENT_DIR`) refuses placed exec
736 instances, because it cannot tell whether their config is sealed. Set
737 `CARLOS_NO_SEALED_CONFIG=1` to say this deployment has none.
738- `CARLOS_FLEET` β€” read by `carlos agent`'s status loop (customer-fleets
739 plan, Task 5). Set (to the fleet's name) marks this agent as a fleet box:
740 it still self-reports its own snapshot every tick, but never contends for
741 the cross-box publish lease and never merges/publishes `status/fleet.json`
742 β€” that feed belongs to the platform's own boxes only. Unset (every
743 platform box today) is unaffected.
744- `CARLOS_FLEET_BOX` β€” this box's operator-chosen `fleet_boxes.label`
745 (customer-fleets plan, Task 6 fix round). This β€” NOT the public status
746 page's hashed machine-id pseudonym β€” is the box's real identity: the
747 channel name the edge dials is `<fleet>/<label>`, so both the fleet
748 binding a box publishes on wake and its own heartbeat filename
749 (`fleets/<fleet>/status/boxes/<label>.json`) use it. Optional: defaults to
750 `CARLOS_TUNNEL_NAME` with the `<fleet>/` prefix stripped, since a fleet
751 box's tunnel name already has this shape. Both the fleet name and the
752 resolved label are validated with `registry.ValidTunnelName`; a fleet box
753 whose identity fails to resolve refuses (loudly) to arm binding-publish at
754 all, and its heartbeat falls back to the hashed pseudonym instead of going
755 dark.
756- `STATUS_BOX_REGION` β€” also read by `carlos agent`'s status loop: an
757 operator-set, coarse region word (e.g. `"EU Β· Ireland"`) carried onto
758 this box's snapshot verbatim; unset omits the field. Free text the
759 operator chooses, never derived from cloud metadata.
760 Keep it geographically consistent with the region's place name in the
761 console's region picker (for example "EU Β· Ireland" beside
762 "Ireland Β· Dublin"); it may stay coarser.
763- `STATUS_HOST` / `STATUS_PUBLIC` β€” not read by the agent process itself;
764 consumed by ops/tofu to decide whether the edge fronts the bucket's
765 `status/` prefix with a public hostname. `STATUS_PUBLIC=1` publishes the
766 page at `STATUS_HOST`; leaving it unset keeps the feed private, visible
767 only to console operators (`console grant --operator <address>`).
768
769**Dry run.** Before pointing a binary at a real bucket, `carlos status
770--dry-run <dir>` runs the whole publish pipeline β€” `CollectBox` β†’
771`WriteBox` β†’ `BuildFleet` β†’ `PublishFleet` β€” against a scratch directory
772stuffed with fixture secrets (hostnames, a machine id, mid-band fractions),
773then reads every object it just published back out and fails loudly if any
774of them leaked. There is no live-publish CLI verb; the 5-minute loop inside
775`carlos agent` is the only publisher.
776
777**Incident fallback, and its actual limits.** The page footer links "if this
778page is unreachable" straight at the bucket's own copy of `fleet.json`, but
779that link is built client-side from `location.origin` (see
780`internal/status/assets/index.html`) β€” it is only meaningful once you are
781*already* viewing the page served from the bucket's own origin (the public
782`STATUS_HOST`, or the raw bucket URL). It is not a link that rescues you from
783a *down* deployment; if the edge/console are unreachable you won't be looking
784at this page's footer in the first place. The real operational fallback for
785a down deployment is fetching the objects directly, either the raw bucket
786URL in a browser or:
787
788 aws s3 cp s3://<bucket>/status/fleet.json -
789 aws s3 cp s3://<bucket>/status/index.html -
790
791One current wrinkle: `index.html` fetched straight from S3 currently
792**downloads instead of rendering** in a browser, because the store's `Put`
793sets no `Content-Type` and S3 falls back to `application/octet-stream`. It
794still opens fine as plain text in an editor, and `aws s3 cp ... - | less`
795works, but "open the bucket URL in a browser and read it" doesn't work yet
796for the HTML β€” see the design spec's rollout list (Β§5) for the queued fix
797(setting content-type on publish). `fleet.json` is unaffected (any client
798reading raw JSON already expects to view its own headers).
799
800A fully-private deployment (`STATUS_PUBLIC` unset) whose console is down has
801no page at all β€” the `aws s3 cp` fallback above is the entire story there;
802nothing papers over it.
803
804## Host & process monitoring
805
806Operator-grade monitoring layered on top of fleet status: real CPU, memory,
807and disk numbers per host and per process, with history, alerting, and a
808console dashboard β€” needles where the public status page shows only band
809words. See
810the `2026-08-02-host-process-monitoring-design.md` design spec (private `platform-infrastructure` repo)
811for the full design.
812
813**Bucket layout:**
814
815- `metrics/boxes/<box>/raw/<YYYY-MM-DD-HH>.json` β€” 60-second host + per-unit
816 samples, one object per UTC hour, retained 48 hours.
817- `metrics/boxes/<box>/days/<YYYY-MM-DD>.json` β€” hourly min/avg/max rollups,
818 kept forever (the capacity/trend history).
819- `alerts/boxes/<box>.json` β€” on-box alert state (fire/clear, hysteresis).
820
821**Config surface** β€” read by the box agent from `/etc/carlos/host.env`:
822
823- `CARLOS_ALERT_WEBHOOK` β€” optional; the agent POSTs the alert JSON here on
824 fire and on clear. Unset simply skips the webhook channel (keymail
825 delivery and the console UI channel are unaffected).
826- `CARLOS_ALERT_SNS_TOPIC_ARN` β€” optional; the agent publishes each fire and
827 clear to this SNS topic as a plain-text email-sized message, signed with
828 the box's instance role (which needs `sns:Publish` on the topic). The
829 region is read from the ARN, not from `AWS_REGION`. Set it to the box's
830 own region's alerts topic and anyone subscribed to that topic gets the
831 email. Either sink works alone and both may be set. A failed send is
832 logged and counted, never fatal. The SDK tries a retryable failure up to
833 three times within ten seconds. After that, a later tick does not send
834 that alert again, unless the same tick's bucket write also failed, which
835 repeats the whole transition on the next tick. A value that is not an SNS
836 topic ARN (a subscription ARN, for example) is logged and the sink stays
837 off. So does a client that cannot be built, which `carlos status --live`
838 shows as `alerts=sns-unavailable`.
839- With neither sink set, the box says so: the agent logs `NO SINK
840 CONFIGURED` at start, and the box's private status snapshot carries
841 `metrics.alerts` with both sinks false, which `carlos status --live` prints
842 as `alerts=not-delivered`.
843- `CARLOS_ALERT_DISK_PCT` / `CARLOS_ALERT_MEM_PCT` / `CARLOS_ALERT_CPU_PCT` β€”
844 optional threshold overrides for the built-in disk / memory / host-CPU
845 rules, as percent integers 0-100 (e.g. `95`). Anything else is ignored
846 with a log line, and that rule keeps its built-in default.
847
848**Console surfaces:**
849
850- `GET /fleet/metrics` β€” operator-only dashboard: per-box needles and
851 48-hour sparklines, a per-box per-unit table (ordered by CPU, busiest
852 first), and active alerts at the top.
853- A "Resources" section on each app's own page β€” owner-facing, scoped to
854 that app's own instances only (current CPU and memory plus 48-hour
855 sparklines); never host-level totals or other tenants' units.
856
857The `days/` rollups are written from the first flush onward but nothing
858renders them yet β€” they are the history a later trend view will read.
859
860## App capabilities
861
862A handful of apps need more from the platform than the `carlos-app@<host>`
863template gives every instance for free β€” a subcommand other than `serve`, a
864privileged port, the box's cloud instance role, or the resource headroom a
865document-conversion engine needs. Rather than hand-provision a bespoke unit
866per app, a release **declares** what it needs and the platform decides how β€”
867and whether β€” to provide it, from a closed vocabulary of eight names. See
868the `2026-08-03-app-capabilities-design.md` design spec (private `platform-infrastructure` repo)
869for the full design; this section is the operator-facing summary.
870
871**The manifest shape** β€” an optional `capabilities` object on the release
872manifest, additive to schema 1 (a manifest with no `capabilities` key parses
873as "none", today's behaviour byte for byte):
874
875 "capabilities": {
876 "v": 1,
877 "command": "relay",
878 "daemon": true,
879 "children": true,
880 "runtime_dir": "keymail",
881 "ports": [ { "port": 25, "name": "mx" } ],
882 "data": { "replicate": "app" },
883 "host_role": true
884 }
885
886A document-conversion engine (Gotenberg, an OCR service) declares a different
887shape β€” `host_role` and `doc_engine` are mutually exclusive and a manifest
888carrying both is refused at ship, because `host_role` re-opens the cloud
889metadata address and `doc_engine` exists to fence the engine's egress away
890from private networks:
891
892 "capabilities": {
893 "v": 1,
894 "daemon": true,
895 "children": true,
896 "doc_engine": true
897 }
898
899`command`, `daemon`, `children`, `runtime_dir` and `data` are self-service β€”
900they shape only the app's own sandbox and materialize from the manifest
901alone. `ports`, `host_role` and `doc_engine` are operator-consented: declaring
902one asks for it, but nothing materializes until an operator has granted it
903**on that specific box**, because the resource (a privileged port, the box's
904IAM role, the private-network egress fence that pays for a conversion
905engine's JIT and memory headroom) is box-local, not fleet-wide.
906
907**Declaring a capability** rides `carlos ship --capabilities <file.json>`,
908where the file holds exactly the `capabilities` object above (not a whole
909manifest). The same strict decoder and validation gate ship and adopt both
910run: an unknown capability name or field, a bad charset, a reserved
911`runtime_dir`, or an out-of-range/reserved port is refused at the ship
912terminal, where a human is standing to read it β€” no per-capability flags,
913because a flag vocabulary would drift from `internal/caps`'s own.
914
915**Granting a capability** is `carlos route`, per route, per box:
916
917 carlos route --host keymail.dev --backing unit # first: grants need it
918 carlos route --host keymail.dev --grant port:25
919 carlos route --host keymail.dev --grant host-role
920 carlos route --host gotenberg.docs.example.com --grant doc-engine
921 carlos route --host keymail.dev --revoke port:25
922 carlos route --host keymail.dev --hibernate=false
923
924Each of `--backing`, `--grant`/`--revoke` and `--hibernate` is its own operation:
925they require `--host` (never `--app` β€” a bulk grant is precisely the operation
926nobody should be able to type by accident), and none of them combines with
927another, or with `--channel`/`--environment`, in one invocation.
928
929The order above is not cosmetic. **A grant requires unit backing**: a route
930that is not a `kind=instance`, `backing=unit` row has nothing for a grant to
931materialize into, so `--grant`/`--revoke` are refused there, and grants cannot
932be staged ahead of a migration. `--backing unit` is the switch that moves a
933route from an agent-run exec child onto a `carlos-app@<host>` systemd
934instance; an exec-backed route that declares capabilities materializes
935nothing and is advised once per pass until it is switched over (see the
936design's Β§5 migration steps). Stamping it arms the **hourly**
937`carlos-adopt.timer`, and `carlos adopt` for a `daemon` route enables and
938STARTS the service β€” so the data copy comes before the stamp, not after it.
939
940A declared-but-ungranted capability does not fail the app or fail adoption:
941the drop-in simply is not written, and the hourly adopt pass logs one line
942naming the route, the capability, and the exact grant command. The mirror
943line is there too β€” a token this box has granted that the live release no
944longer declares materializes nothing, and is named with the revoke command
945rather than left sitting in the registry looking like live authority.
946
947**`doc-engine` carries two box preconditions** the platform cannot check for
948you, which is what makes granting it an operator decision rather than a
949checkbox. The grant buys the app JIT memory and a raised resource tier, and
950in the same drop-in fences its egress away from every private network range β€”
951RFC1918, link-local, CGNAT, IPv6 ULA and the NAT64 prefix β€” because a
952conversion engine fetches whatever URLs the documents handed to it name, and
953an unfenced one is an SSRF proxy into the VPC. Two consequences:
954
955- **The box must hand the tenant a loopback DNS resolver.** The fence
956 deliberately leaves 127.0.0.0/8 open β€” the engine drives its own Chromium
957 and LibreOffice over loopback β€” but it closes 10.0.0.0/8, and on AWS the
958 VPC resolver lives inside that range (VPC base + 2). A box running
959 systemd-resolved on 127.0.0.53 resolves through loopback and the fence is
960 transparent; a box pointing tenants straight at the VPC resolver gives them
961 an engine that cannot resolve a hostname.
962- **The box should be dedicated.** Loopback staying open means an SSRF'd
963 engine can still reach anything listening on 127.0.0.1 on its own box β€” a
964 co-tenant console, a co-tenant's loopback-bound upstream. The fence closes
965 the network half of that risk; not co-locating tenants closes the rest.
966 Run `doc-engine` routes on a box of their own.
967
968**Deploy order is pinned**, same rule and reason as scoped signing keys: the
969platform binaries that understand a capability name have to be on the box
970*before* a release declaring it, never after. An older binary meeting a
971manifest it does not understand refuses the version on that box β€” not
972staged, not pointed at β€” rather than materialize a unit missing something
973the app says it needs.
974
975**"Platform binaries" includes the console binary**, and on the flagship that
976is the one that matters: `carlos ship` in API mode posts through the console,
977so the console is what either understands the `capabilities` key or silently
978drops it as an unknown JSON field β€” storing a manifest that declares nothing,
979which on a box is the instruction to REMOVE the drop-ins. The ship response
980therefore echoes the capability block it stored, and `carlos ship` fails hard
981when it sent a block and gets no echo back ("this console does not understand
982capability declarations β€” update the console binary first, or ship without
983`--capabilities`"). Update the console before shipping a capability-bearing
984release.
985
986**What refuses vs what leaves alone** β€” the distinction that matters most:
987
988- An unreadable or unresolved staged manifest **leaves every drop-in exactly
989 as it is** and logs once per pass. A bucket hiccup must never strip `:25`
990 off a running mail server.
991- A manifest that actually resolves and no longer declares a capability (or
992 a grant that gets revoked) **removes** the generated drop-in.
993- A hand-edited drop-in β€” anything at the generated path without the
994 `# GENERATED by carlos adopt β€” do not hand-edit` marker on its first
995 line β€” is never overwritten and never deleted; it is reported and that
996 unit's half is skipped while the other half still converges.
997- An unknown capability name or an unsupported `"v"` refuses the *version on
998 that box*, not the whole fleet β€” the box keeps serving what it already
999 runs.
1000
1001Console gets, at most, a read-only line beside each route's other facts β€”
1002"granted: host-role, port:25" for a route with grants, nothing at all
1003otherwise, and only on routes belonging to the account whose page it is.
1004There is no console UI for granting or revoking; grants stay a `carlos route`
1005operation on the box, and nothing about the console's
1006sign-in/session/CSRF/owner gates changes.
1007