# AGENTS.md

Guidance for AI coding agents working in this repository. This is the
canonical agent-instructions file — tool-specific files (`CLAUDE.md`)
are pointers to it, so keep it current *here*.

CARLOS platform — bucket-as-control-plane deployment platform. Design specs and plans
(`docs/superpowers/{specs,plans}/`, referenced by path throughout the code
comments) live in the private `platform-infrastructure` repo alongside the
deployment roots; read the spec behind whatever you touch.

## Delegation policy (Paul's rule, 2026-08-02)

Delegate mechanical work to subagents on the cheapest model that fits, and
have Opus review anything that grants authority or touches production paths:

- **Haiku** — transcription-grade work: the plan contains the exact code,
  single-file mechanical fixes, doc edits.
- **Sonnet** — implementation from a precise brief: 1-3 files, clear
  interfaces, established conventions to follow.
- **Opus** — design judgment, multi-file integration, anything security- or
  production-touching; and the **reviewer** for work in those areas.
- **Fable** — where the tier changes the outcome, not the speed (Paul,
  2026-08-03): implementation whose correctness rests on several interacting
  invariants at once (converge-grade, root-side, fleet-wide blast radius),
  whole-branch final reviews, and deep work that wants a fresh
  full-capability context window. It burns usage budget fastest — never
  spend it where a precise Sonnet brief or an Opus review already fits.

Always name the model explicitly when dispatching; an omitted model silently
inherits the session's (usually the most expensive). A precise brief beats a
capable model — exact values, file paths and interfaces go in the brief.

## Operate via API and CLI (Paul's rule, 2026-08-06)

Day-to-day CARLOS administration goes through the console API and the
`carlos` CLI — listing routes, inspecting apps, checking channels, all of it.
Direct AWS access (SSM sessions, S3 console, aws CLI) is reserved for work on
the platform itself: building CARLOS internals, provisioning infra, debugging
below the API surface.

If a day-to-day task has no API/CLI command, that is a product gap, not a
license to drop to AWS: plan and build the command (or file the gap) instead
of working around it. Box-local-only counts as a gap too. (2026-08-06:
`carlos instances` lists routes but only by opening the registry DB on local
disk, so an agent went to Systems Manager to run it — the missing API-backed
remote form was the bug.)

The design-side corollary (Paul, 2026-08-08): **every `carlos` command is
designed for a user with no access to the underlying infra and no box
commands.** The command alone must complete the job — no follow-up SSM
session, AWS console visit, or on-box verb may be required for it to
deliver or verify its result.

## Production infrastructure needs scoped approval (Paul's rule, 2026-09-15)

Before creating, changing, or deleting infrastructure resources or records in
production or shared accounts, or under a real company or customer domain,
get explicit user approval for the scoped work. This includes temporary
experiments and applies through every interface: DNS, SES identities,
configuration and tenants, IAM users, roles, policies and credentials,
queues, topics, buckets, compute, and related resources.

Prepare a concrete plan before asking for approval. State the account and
region, domains, exact resource changes, purpose, permissions and traffic
effects, expected cost, duration, and cleanup and verification steps. One
explicit approval covers the complete scoped experiment and its cleanup; do
not ask repeatedly for each API call. Existing explicit scoped authorization
and routine, preauthorized deployment workflows remain valid. Ask again if
the work materially expands the approved account, region, domain, resource
type, permissions, traffic, cost, or duration.

Read-only investigation and local mocks may proceed without approval. Live
tests may run unattended within an explicitly designated sandbox scope with
restricted permissions and automatic expiry. A sandbox domain alone does
not authorize IAM or other account-level changes in a production or shared
account; those still need scoped approval. A name beginning
with `spike-`, a planned cleanup, working credentials, or a generic instruction
to implement or test does not constitute approval or designate a sandbox.

This rule supplements the AWS access rule above and applies to subagents.
Pass the approved scope to delegates so they can check their work against it.

The rationale is concrete: on September 15, an SES spike created
`spike.teamtito.com`, DNS records, IAM credentials, and messaging in Tito's
production account. It was removed minutes later, but cleanup does not
substitute for approval.

## Every change lands through a reviewed branch on amadan (Paul's rule, 2026-08-10; amadan made the source of truth by Bill, 2026-09-24)

**Nothing reaches `main` except through a branch landed on amadan.** No
local merge to `main`, no direct commit to `main`, no fast-forward "it's
only a docs change" — every change, however small, goes up as a branch
and lands through `amadan branch merge`. That includes doc-only edits,
one-line fixes, and revert commits.

**amadan (`carlos/platform` on amadan.net) is the source of truth.** Its
`main` is the real one, its CI is the only CI, and its branch description
is where a change's context lives. GitHub is a mirror of it: do not open
pull requests there, and never merge there. (Until 2026-09-24 this rule
said "through a PR" and pointed at GitHub, while the issues rule below
already said `main` lands through `amadan branch merge`. Both were
followed, and GitHub-first merges ran CI only after the merge. That
contradiction is what this rewrite removes.)

The steps, for agents and people alike:

1. **Push the branch to amadan.** The push is what triggers CI. The remote
   may be named `amadan` or `origin` depending on the checkout; it is
   whichever one points at `amadan.net`.
2. **Describe it** — what changes, why, how it was tested and reviewed.
   This is the pull request's job and it carries the same weight:

       amadan branch describe carlos/platform <branch> -body -

3. **Squash it to one commit** before landing, with a message that says
   what the change is and why. `amadan branch merge` has no squash mode,
   so this is done on the branch: `main` then gets the hub's merge commit
   and exactly one change commit under it, not the branch's iterations.
   The iterations are not lost: the description and the reviews record
   them. Squashing moves the tip, so CI runs again on it.
4. **Land it once CI is green on that tip:**

       amadan ci status carlos/platform -branch <branch>
       amadan branch merge carlos/platform <branch> -expect <tip> -message "..."

   The merge refuses a branch whose CI is not green (exit 2) and, with
   `-expect`, one that moved after you checked it. That ordering is the
   point: a red run stops the merge instead of landing on `main` as an
   incident to clean up.

**Merging is fine** (Paul, 2026-08-16, clarifying an earlier wording that
read as a blanket ban and cost a session a round trip). The rule is about
how work REACHES `main`, not about whose hand lands it: the described
branch has to exist, so that it can carry the context, the iterations and
the review. Once it is green, `amadan branch merge` is an ordinary step.

Use the judgement the rest of this file asks for: a branch that is still
collecting review, that is gated on something, or whose diff you would
want a second pair of eyes on, waits for a person.

## Work is tracked on amadan, not GitHub (Paul's rule, 2026-09-05)

**Do not open issues on github.com/carlosframework/platform.** Not for a
bug you hit, not for a follow-up you noticed while doing something else,
not for a "just so it's written down somewhere" note. That tracker is
closed for new work.

Everything goes on amadan instead, as a discussion on `carlos/platform`:

    amadan discuss new carlos/platform -title "..." -body -
    amadan discuss list carlos/platform          # what's already there
    amadan discuss show carlos/platform <id>
    amadan discuss reply carlos/platform <id> -body -

Give it a type — `Issue`, `Feature Request` or `Idea` — with
`amadan discuss type`, or leave it untyped and let a person label it.
`amadan discuss types` lists what this repo uses.

The reason is that a tracker nobody reads is worse than no tracker. CI
moved here on 2026-08-24 and `main` lands here through `amadan branch
merge`; GitHub issues were the last thing left pointing somewhere the
daily loop had stopped looking. 110 open ones had accumulated by
2026-09-05, and triaging every one against `main` found 20 already
fixed — several of them by work that cited the issue number in its own
comments and still never closed it — plus one duplicate. The remaining
89 were migrated to discussions that day, each carrying its full
original text, and all 110 were closed on GitHub with a comment saying
where it went and why.

That ratio is the argument. Roughly one issue in six was answered
without anyone noticing, which is what a tracker looks like when it has
stopped being part of the loop.

Reading the old ones is still fine and often useful — code comments cite
them by number constantly (`issue #195`, `(#152)`), and those citations
are historical references, not an invitation to reopen anything. When a
comment cites a GitHub issue that is still real outstanding work, it now
has a discussion too; cite that alongside it rather than renumbering
anything.

## Build & test

- `go build ./...`, `go vet ./...`, `go test ./... -count=1` — all clean,
  every package (21 as of 2026-08-08; the count grows), before any commit
  lands.
- `gofmt -l .` clean over the whole tree — `make test` checks it, and the
  three pre-existing unformatted files that used to be exempted are gone.
- **`.amadan/ci.d/` is the only CI this repo has.** The GitHub workflow
  that used to run this gate was deleted on 2026-08-24, once the amadan
  steps covered everything it did and more; `.amadan/README.md` records
  what each step descends from, and `.amadan/ci.d/10-gate`'s header
  records why the switch was safe. `10-gate` is `go build ./...` beside
  `make test`, so CI and what you run locally are one definition rather
  than two that can drift.
- **`make test` splits the slowest packages' tests across processes**
  (`.amadan/test-gate.sh`, `TEST_SPLIT`). Each shard is its own process,
  so a test must not depend on another top-level test having run first
  in the same process. Shards are dealt by `.amadan/test-weights.tsv`;
  refresh it when the gate's shard times in the CI log drift far apart
  (its header says how). A test missing from it still runs.
- **CI is triggered by a push to amadan, not by GitHub.** Nothing on
  GitHub runs a build. A branch that exists only on GitHub has been gated
  by exactly nothing. Read a branch's verdict with
  `amadan ci status carlos/platform -branch <branch>` (exit 0 pass, 1
  fail, 2 pending, 3 no status yet) before calling it green.
- **After a merge, read `main`'s run too.** Each branch was green against
  the base it was cut from, not against what landed beside it. A red
  `main` is worth knowing about the same day. On 2026-08-31, when merges
  still happened on GitHub, amadan's `main` was 86 commits and three days
  behind, and the gate had not run on `main` once in that window; landing
  on amadan is what ends that.
- **GitHub follows amadan.** If GitHub's `main` is behind amadan's after a
  merge, fast-forward it from amadan; never the other way round, and
  never with anything that is not already on amadan's `main`.
- `20-cross@5` adds `GOOS=darwin`/`windows`/`linux-arm64` compiles, one
  per shard. **The gate is not Linux-only**: `internal/host` is linked
  into the CLI, which people run on Macs, so a Linux-only type assumption
  breaks `go build .` for them. Cross-compiling is enough to catch it —
  no Mac required.
- Sandbox note: use `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod
  GOPROXY=off`; go commands may need the sandbox disabled ("read-only file
  system" from the toolchain is the tell).

## The census: an approval, not a decision (Paul, 2026-10-24, #369; reviewer, 2026-10-25)

`TestPlan14RootReadCensus` (`internal/provision`) fails when a branch
changes a unit that reads root-owned state, or a package inside the
census envelope. Every such change needs an approval on #280 before the
merge. Most are approvals: the same reads in an edited body, or an
envelope fingerprint that moved. An independent reviewer gives those;
the census owner (Paul) sees only decisions and escalations. Get them
approved like this:

1. `make census-card` prints what changed and writes nothing. Each change
   has a class:
   - `body`: the digest moved and the reads did not;
   - `sites`: more or fewer reads of kinds the unit already makes;
   - `envelope`: a package was re-pinned;
   - `reads` (a kind of read the unit did not make before), `new`,
     `removed` or `other` (any census discrepancy none of these explains,
     shown with the census's own message): a decision.
2. Write a CLAIMS file with one line per changed unit:
   `<file>:<unit><TAB><what changed, and that no read was added or which one was>`.
3. `make census-propose NAME=<branch> CLAIMS=<file>` writes the rest:
   - the overlay (`testdata/plan14-root-reads-<NAME>.tsv`), each unit
     re-frozen at its existing disposition with the claim appended;
   - the `@legacy` fingerprints, the envelope re-pins and the new seal;
   - its line in `testdata/plan14-root-reads-chain.tsv`;
   - the approval card, `docs/superpowers/notes/census-proposal-<NAME>.md`.

   It checks that the overlay leaves the census clean, and writes nothing
   if any step fails. If the code changes again, rerun it with the same
   NAME: it replaces its own overlay (only one it wrote itself), which must
   still be the last line of the chain.
4. Get the approval from an independent reviewer: a model from the other
   family than the one that wrote the change (Astra for a Claude author,
   Opus or Fable for a Codex one), never the author itself. Give it the
   card and the branch's diff against main on the amadan.net remote
   (`origin` or `amadan`, as in the steps above). It gives a verdict for
   every unit and every re-pinned envelope, citing the lines it read:
   - a unit is compared with its recorded disposition and basis, which
     the card lists;
   - an envelope means every changed function in that package and what
     it reaches, since an envelope re-pin is how the census sees reads its
     scanner cannot attribute to a unit.

   It escalates if the change lets root open, run, query or follow
   anything it did not before from a path or input a tenant, an app or a
   box agent can create, write or name; if it weakens a check, refusal,
   validation, argument or environment the basis relies on; or if the
   diff cannot settle either. Otherwise it approves. Record each verdict
   on #280 with the reviewer and the tip. An escalation goes to the
   census owner as a decision.
   Rows carry `rev3=census-ruling-<NAME>` from the start, so there is
   nothing to flip afterwards and no second CI run.

Why a reviewer and not an automatic pass: the classes count kinds of
read, not where a read points, so a `body` change can aim an existing
`Open` at a tenant-writable path. Someone other than the author has to
read the diff, but it need not be the owner.

The tool refuses decisions (`reads`, `new`, `removed`, `other`): a new
kind of read can make a unit's disposition wrong, so it is the owner's
call, not an approval. Write those rows by hand, following the overlays in the
chain, and ask on #280 with the card attached; the census owner decides
those. Nothing about the census's strength changes: every changed read is
still named, digested and read by someone other than its author before
merge. The record-write
census (9B8, the `*_census_test.go` inventories) still takes hand-written
entries.

## Rules this repo has paid for

- **A rule stated as an absence can only be checked by looking for the absent
  thing, and cannot see a second door.** (2026-09-04, object-storage scanning.)
  Every scan verdict rests on the app being unable to tag its own object, and
  that was enforced by keeping `s3:PutObjectTagging` out of the app's Allow —
  stated in capitals in `storePolicyJSON`, and asserted by a helper that
  searched the policy for the string `"s3:PutObjectTagging"`. Correct, enforced,
  and insufficient: **there are two ways to tag an object**, and the second —
  `x-amz-tagging` on `PutObject` — names no action at all, so no search for an
  action name could ever have found it. Against MinIO the pre-tagged upload
  succeeded, and an explicit `Deny` on `s3:PutObjectTagging` did not close it;
  only a `Deny` on `s3:PutObject` conditioned on `s3:RequestObjectTagKeys` did.
  A second hole in the same property survived that fix: an app filling the
  ten-tag budget with its own tags made its objects permanently *unmarkable*,
  so the `enforce` Deny — which keys on the tag being present — never engaged.
  So: **`s3:PutObjectTagging` must never be in the app's Allow, and its absence
  is not the property.** The property is "this credential cannot put this tag,
  by any route", it is asserted against a real server in
  `TestDeliveredContractMinio`, and a policy change is not done until that test
  has been watched to fail.
- **When a storage shape changes, the consumer list is everything that PARSES
  the shape** — not everything that calls the API. (Learned three times.)
- **A new `carlos` command is not done until `docs/site/cli.md` names it.**
  `internal/docsite` reads main.go's dispatch switch and fails the gate over a
  command with no heading on the CLI page, over a fenced example running a verb
  that no longer exists, and over a `/docs` link whose page or `#fragment` does
  not resolve. The check is a heading and not a substring on purpose: `route` is
  a prefix of `routes`, and a substring check let a deleted section pass. The
  corpus is rendered by `carlosframework/website`, which vendors a copy — so a
  change here reaches the site only after that repo re-syncs and ships.
- **Wire-shape changes are additive**: live pointers/manifests exist in
  production buckets; anything written before your change must still parse.
- **Production channel names are pinned in tests**
  (`internal/release/laddercheck_test.go`) — the fleet runs on `canary/*`
  channels today; a ladder change that refuses them stops deploys.
- **Adoption swaps a symlink; it does not restart processes.** Order is:
  promote → adopt → restart. Long-running units (console, keymail relay) need
  the restart; hibernating tenants pick it up on wake.
- **A route's Channel and Environment belong to its instance record** once the
  route is `Provisioned` (main, 2026-08-10). The provisioning reconciler
  converges both from `apps/<sqid>/<app>/instances/<host>.json` on every pass,
  so a `carlos route -channel` on the box lands and is reverted within seconds
  of the next serial bump — `carlos route` refuses both axes on such a row and
  names the record. Reconcile also ADOPTS an existing route whose shape matches
  an appearing record, which is how the fleet gets backfilled with no box
  commands. Adoption stamps `Provisioned`, and `Provisioned` is the one
  condition that GRANTS the removal phase authority over a row (the loop also
  gates on region/pool scope and on the record being gone) — so adoption is an
  authority transfer,
  and the shape match is what stands between a backfill and a retired
  production route. It refuses any row carrying a column the record cannot
  express (`Backing`, `Tunnel`, `Caps`) or owned by another pass (`Platform`,
  `Canary`).
- **A ResponseWriter wrapper that edits headers needs its own header map.**
  Wrappers in `internal/edge` embed the writer below, so `Header()` is ONE
  shared map from handler to connection — and `maybeStore` caches
  `rec.Header().Clone()`. The gzip wrapper (`internal/edge/compress.go`) keeps
  a private map and writes `Content-Encoding` only to the real writer's; on the
  shared map it would have stored an identity body labelled gzip and served it
  to every later visitor. Two corollaries it paid for: a header set AFTER
  `WriteHeader` is a TRAILER, so the wrapper must sync late additions down or
  it silently eats them; and canonical for `ETag` is `Etag`, so a raw map-key
  comparison spelled `"ETag"` matches nothing. Ordering is load-bearing too:
  recorder ABOVE the compressor (cache stores identity), usage counter BELOW it
  (tenants are billed for wire bytes), wrap site AFTER the upgrade branch.
- **Console security gates are ordered**: session → CSRF → owner (byte-identical
  404) → `requireFreshAuth` LAST. A non-member must never get a redirect that
  confirms an account exists.
- **Verify against the thing you changed, with the binary you built.** Stale
  DNS answers and stale `CARLOS_BIN` binaries have both produced false
  "verified" reports in one day.
- Icons are vendored Lucide (in-code SVG constants, never CDN); emoji is
  content, icons are chrome.
- **A test that shells out to the carlos binary must build its env through
  `carlosTestEnv`** (masks `CARLOS_CREDENTIALS`, `HOME`, `CARLOS_CONSOLE`,
  `CARLOS_DESTINATION`)
  **and set `cmd.Dir` to a scratch dir** — the binary walks cwd ancestors for
  `.carlos/config`. The fallback is the operator's real login and default
  console; a logged-in laptop running `go test ./...` minted 40+ real accounts
  on the production console before this was isolated. A `t.TempDir()` is
  scratch in that sense only because the root package's `TestMain`
  (`isolateTempRoot`, main_test.go) checks both temp roots, TMPDIR and
  GOTMPDIR (`t.TempDir()` uses GOTMPDIR when it is set), and moves whichever
  has a `.carlos/config` above it, which is the case wherever one is under a
  logged-in home (#226, #258). The move is to a new
  `/var/tmp/carlos-test-*` directory, named on stderr and removed when the
  run ends. A run that is aborted (`log.Fatal`, a panic, a timeout) leaves
  that one directory behind: remove it by the name that was printed, never
  by pattern. `/tmp` is never used, and with `/var/tmp` unavailable the
  binary stops and says which variable to set. `TestMain` also drops an
  exported `CARLOS_CONFIG` and `CARLOS_DESTINATION`, which no shared helper
  pins. A test that leaves its cwd at the checkout is not covered by any of
  that.
- **A sidecar is a member that nothing can dial.** `Kind == "sidecar"` rows
  carry no socket and no addr, and are refused by the edge ON KIND — not
  merely unmatched by it — before dispatch ever looks at shape. That refusal
  has to be explicit: a fleet-placed sidecar row reaches the edge's fleet
  arm on shape alone (a `Tunnel`/`Region` it never should have earned), so
  `publishResidentFleetBindings` also skips it — a sidecar is never
  advertised as served in a fleet binding. Separately, each reconcile pass's
  removal authority is scoped to its OWN kind: without that scoping the
  instance pass and the sidecar pass delete each other's rows on every
  tick, forever, with no error anywhere. Use `registry.Route.IsSidecar()` in
  any code that needs the check — never the bare `"sidecar"` literal.
- **A known-failing test is a defect in its own right.** `log.Fatal` inside
  an in-process test aborts the whole binary rather than failing the one
  test, so everything after it silently never runs — and what gets read
  afterward is the failure COUNT, not the names. A red root package sat
  "known-failing" for a week while `TestStoreVerbDispatchAndRefusals` hid a
  stale usage assertion behind it, and CI stayed red on every push with
  nobody the wiser (discussion #17).
- **Absence of `apps/<acct>/<app>/pipeline.json` = the legacy ladder**,
  byte-for-byte; new apps get a one-channel `edge` pipeline at create;
  per-channel rules are console-enforced, bake windows box-enforced through
  the `FirstSeen.Holds` ratchet.

## Deploy reality (2026-08-02; convergence rewritten 2026-08-08)

- Deployment-specific facts (which apps live where, box names, accounts,
  ship/promote scripts) live in private deployment repos, not here:
  `carlosframework/carloku-infrastructure` for the flagship (carloku.com,
  split out 2026-10-01; the tofu root is the repo root) and
  `carlosframework/platform-infrastructure` for dev and the design docs.
  The signing key comes from the deployment's secret store inside those
  scripts, never a terminal.
- **The flagship consumes the platform modules at a release tag**
  (`?ref=vX.Y.Z` of the GitHub mirror), not `?ref=main`, the same way Tito
  and any self-hoster do. A module change on main does not reach carloku
  until a release is cut, its tag is pushed to the GitHub mirror
  (`git push https://github.com/carlosframework/platform.git
  refs/tags/vX.Y.Z`; the mirror had no tags past v0.9.0 until v0.21.0 went
  up on 2026-10-01), every `?ref=` in carloku-infrastructure is bumped, and
  `tofu init -upgrade` plus a read plan pass. Dev still tracks `main`.
- keymail.dev deploys via `carlos ship`/`promote`, NOT via its repo's legacy
  `deploy/deploy.sh` — see that repo's CLAUDE.md.
- **Since main 1fba18f (PR #108, live both boxes 2026-08-08): convergence is
  seconds, not ticks.** Console-mediated writes bump `control/serial`; boxes
  poll it at 2s and run the full pass set. Direct-bucket promotes (pinfra
  scripts) don't bump but canary/* channels are name-scoped fast anyway.
  Blocked stages relay to a root pass (`carlos-stage.path` — must be armed;
  update-platform-binary.sh does it). That root pass stages, repoints and
  stamps in ~8s but cannot cycle the agent's exec children, so the agent's
  2s tick finishes the job: a warm instance whose route is stamped with the
  version its symlink names is cycled at once (`convergeAdopted`, #231). The
  activator sweep's 90s grace is only the backstop. `carlos deploy` is the
  member one-command
  form: ship → promote → wait until `X-Carlos-Version` on the URL reports the
  shipped build over 2xx. Verify deploys with that header (`curl -sI
  --resolve`), never a cached answer. `-I` is fine for the VERSION header, but
  it sends a HEAD — and HEAD is excluded from edge compression, so anything
  about `Content-Encoding` needs a GET (`curl -s -D - -o /tmp/b`) instead.
- **Console restarts: manual until the route is unit-stamped** (#110, fixed
  in main 916cd6b, not yet rolled). Routes can now name the systemd unit that
  owns their process (`carlos route -registry /data/registry.db -host
  <console-host> -unit console.service`, run as carlos, not root); a stamped
  route is cycled by adoption itself, and a failed named-unit restart rolls
  the adoption back instead of stamping a lying header. Until a box runs
  916cd6b+ AND its console row is stamped, the old rule stands: promote →
  adopt → `systemctl restart console.service`, and the header overclaims
  until the restart. Ordinary unit/exec tenants restart themselves either way.
- **`carlos restart -app X`** (#113, merged 916cd6b, live after the same
  roll): member-facing process cycle with no version or config change —
  console door touches `apps/<acct>/<app>/restart-gen.json` + bumps the
  serial; boxes act on the serial move (exec children bounced in-process,
  unit-shaped routes via root `carlos adopt -restart-only` through
  `carlos-restart.path`, which update-platform-binary.sh arms since pinfra
  PR #10). Hibernating instances are never woken. Static routes carry
  `X-Carlos-Version` from the same roll (#112), so `carlos deploy -kind
  static -host <site>` verifies static sites too.
- `carlos-system-update.path` relays the agent's platform self-update request
  to `carlos system apply` (root; spec 2026-09-06-carlos-system-update-design.md
  §6). `carlos-system-recover.service` runs once per boot, before the edge,
  and on the apply's failure. Both must be armed; the last run of
  `update-platform-binary.sh` does it, and the hourly `carlos adopt` pass
  installs and arms them if it did not. A box follows `CARLOS_SYSTEM_CHANNEL`
  (canary|stable, default stable) in host.env.
- **`carlos restart --host` / `--environment` / `--dry-run`** (discussion
  #16): the same verb narrowed to one route, or to everything reading one
  config bundle — the shape that follows `carlos secrets set --environment
  relay`, since a bundle is delivered to an ENVIRONMENT. `--host` wins over
  `--environment`, and the console refuses the pair when that host does not
  read that environment; a selector matching nothing is refused with what
  the app actually has, never widened. The narrowed request is a SECOND
  bucket object (`apps/<acct>/<app>/restart-scope.json`, an append log of
  `{id, requested_at, scope}`) reached through a SECOND console path
  (`POST .../restart/scope`), because both old readers act on the app-wide
  object alone: an old box never reads the log and an old console answers
  404, so neither can turn a narrowed request into a whole-app restart. A
  box steps OVER a selector `kind` it does not know rather than falling
  back to the app — never widen on ignorance. Boxes remember the last
  request id they acted on (`scopes` in `restart-marks.json`, additive in
  both directions); a mark that has rolled off the log re-baselines and
  cycles nothing.

## Short platform aliases (Paul's request, 2026-09-15)

Tito may delegate direct app-domain aliases to named accounts with
CONSOLE_APPS_DOMAIN_ALIAS_ACCOUNTS (comma-separated sqids; empty by default).
A listed account can claim an unoccupied single-label hostname below
PLATFORM_APPS_DOMAIN through the existing domain attach API. This is explicit
operator delegation of namespace authority, not DNS ownership evidence.
The exception supersedes blanket ancestor deny entries only for these names;
exact denied hosts, Origin, RedirectHosts, SteeringHost, account-sqid labels,
the domain apex and nested account namespaces remain protected. Alias targets
still require an existing app route. First-route creation and wildcard or
catchall capabilities are refused. Global claims and deployment-route conflict
checks remain authoritative. Tito's initial approved account is bap (Pullup).

The delegation only means something on a deployment that sets
CONSOLE_RESERVE_PLATFORM_DOMAINS=true (below). Without that setting, the
apps domain is not reserved, so every account can already attach names under
it, and the delegated-alias limits do not apply.

## Reserving platform domains is opt-in (James, 2026-09-17)

`carlos domains attach` refuses everything under PLATFORM_APPS_DOMAIN and
under the console's own hostnames ONLY when the console sets
CONSOLE_RESERVE_PLATFORM_DOMAINS=true. That rule protects tenants from each
other, so a deployment that hosts apps for other people (the flagship,
carloku, Tito) must set it. The default is off: a self-hosted deployment
usually keeps its console, its apps and its custom domains in one zone that
one person owns, and the rule refused ordinary names there. Every deployment
still refuses the console's own hostnames by exact name, the steering host,
CONSOLE_DENY_DOMAINS, and a hostname another app's route holds.

Two refusals do NOT depend on the setting (#215): every deployment refuses a
name in another live account's `<sqid>.<apps-domain>` namespace, and a first
route under a sqid-shaped namespace that is not the caller's own.

## The operator's own hostnames (#209, Paul, 2026-10-03)

The deny list has two tiers (domainHostPolicy, internal/console/domainpolicy.go).
**Platform** names are CONSOLE_DENY_DOMAINS entries and, with
CONSOLE_RESERVE_PLATFORM_DOMAINS, the names under the console's own hostnames.
Members are refused them. A platform operator (an ops account owner, or an
address on CARLOS_SYSTEM_OPERATORS) may attach one to an app in an account they
are a member of, with `carlos domains attach --platform`. That is how carloku.com
runs as an ordinary console app. **Reserved** names are refused to everyone,
operators included: the console's own hostnames by exact name, the steering
host, the tunnel host (Config.TunnelHost, kept out of DenyDomains so the door
cannot reach it) and, with the reserve setting, the apps domain.

Once a platform name is claimed, every later write to that claim is the
operator's alone: a member of the holding account cannot repoint it, flip
wildcard or catchall, publish its paths or detach it. These checks read the
hostname, never the claim, so they reveal nothing about who holds it. The
browser form has no platform door. Its attach refuses platform names to
everyone, and its detach admits the operator.

## Instance catch-alls (James, 2026-10-05)

A claim on `*.<parent>` (`carlos domains attach --app <a> '*.<parent>'`)
serves each of the app's instances at `<label>.<parent>`. Every box derives
ordinary `route_aliases` rows from it for its own instance routes
(`domains.ReconcileAliases`). The design is
`docs/superpowers/specs/2026-10-05-instance-catchall-design.md`. Four rules
hold it together; keep all of them:

- **Only a person, in the account that holds `<parent>`'s own claim, may
  attach one.** The box re-checks the parent on every pass, and a catch-all
  whose parent has left the account is inert.
- **An exact claim on a derived name always wins, whoever holds it.** A
  real route also wins over a derived name.
- **While a catch-all is live, the attach doors refuse other accounts the
  names it reserves**, with the neutral 409. Without that, any account
  could claim a team's name, and its exact claim would win.
  - It reserves **every** one-label name under the parent once the account
    proves the parent in DNS: TXT `_carlos-catchall.<parent>` =
    `carlos-catchall-account=<sqid>`, checked live.
  - Otherwise it reserves only the labels of the app's existing instances,
    under any instance domain, so an unproven apex claim cannot block a
    whole domain.
  - It never refuses the caller's own existing claim.
  - The box decides "claimed" from the bucket listing, never from the
    bodies that decoded.
  - A label two instances share derives nothing.
- **Derivation takes only provisioned, non-sidecar, non-replica routes of
  the claim's own account and app.**

## Proof of control for custom domains is opt-in (#215, 2026-10-04)

With CONSOLE_DOMAIN_PROOF=true, an attached hostname is stored pending and is
neither routed nor given a certificate until a TXT record at
`_carlos-challenge.<host>` carries the account's token (`carlos domains
verify`). A pending claim yields to a proven attach from another account, and
lapses after seven days. Without it, attach is first-writer-wins exactly as
before: an account can take over a name whose DNS already points at the
edge, or hold any name it never controlled. A deployment that hosts apps for
other people must set it. Roll the fleet first: a box from before #215 reads
a pending claim as live and routes it. The default is off because the
2026-08-26/27 scope ruling kept first-writer-wins; turning it on per
deployment is the operator's decision. A pending claim does not hold a
parent for an instance catch-all.
