# Instance catch-alls: `<label>.<parent>` serves the app's instance `<label>`

**Date:** 2026-10-05
**Status:** branch `instance-catchall` on amadan, not landed. Revised after
the review on [#233](https://amadan.net/carlos/platform/-/discussion/233)
(§8).

## 1. Problem

Jelly (lazyatom/jam) runs one instance of `dev-managed-jelly` per team, at
`<team>.bad.carlos.exciting.io`. Teams should also answer at
`<team>.letsjelly.dev`. Today the manager makes that happen with one exact
claim per team at signup (`AttachDomain` in `manager/carlos.go`), plus a wait
for DNS. The existing catch-all (`Wildcard`+`Catchall` on a claim) cannot do
it, because it sends every subdomain to ONE route
(`domains.ReconcileAliases`, the `*.<host>` → primary row). It cannot fan
out to per-team instances, and it belongs to whoever holds the apex claim.
Here that is the static site `dev-jelly-site`.

## 2. Design

**The claim.** An instance catch-all is a `domains.Claim` at the key
`domains/*.<parent>.json`, with `Host: "*.<parent>"`, `Account`, `App`,
`Instances: true` and nothing else: no Route, Wildcard, Catchall or Paths.

- The star in the key means there is one catch-all per parent, held by the
  same create-only bucket-key gate that every claim uses.
- No attach door accepts a `*` (`NormalizeAliasHost`), so a catch-all can
  never collide with an ordinary claim.

**The door.** `carlos domains attach --app <a> '*.<parent>'` (API: the
existing attach body with `host: "*.<parent>"`). `detach` removes it the
same way. Writing a catch-all requires all of these:

1. A **person** (`apiPerson`), like `carlos instances enable`. A catch-all
   is standing policy over every unclaimed name under the parent. A
   credential the app holds must not be able to set it for itself. Without
   this, an app token could also put its instances under a sibling app's
   parent.
2. No route, kind, channel, wildcard, catchall or platform field in the
   request. They are refused, not ignored.
3. The app has an instance manifest. Without one it has nothing to serve.
   An app without one is also the only kind of app a rename can still
   move, which would leave the claim naming a dead app.
4. The parent passes `catchallParentRefused`. It refuses a parent when:
   - the parent is in a deny tier (reserved or platform, so operators are
     refused too);
   - any console, reserved or platform hostname is equal to the parent,
     under it, or above it. The box cannot see the deny lists, so a
     derived name could otherwise land on the tunnel host or a console
     host;
   - the parent is equal to the apps domain, under it, or above it. That
     space holds the account-sqid and app-apex names. This is checked
     whatever `CONSOLE_RESERVE_PLATFORM_DOMAINS` says: on a self-hosted box
     the apps domain is not reserved, and `edge.<apps-domain>` would
     otherwise be derivable;
   - the parent is a public suffix (`co.uk`, `github.io`);
   - the parent is under the sidecar suffix.
5. **The parent's own claim is held by the same account, by any of its
   apps** (`domains.AttachInstanceCatchall`). With wildcard DNS at the edge,
   a catch-all covers every unclaimed name under the parent at once, so only
   whoever already holds the parent may make one. If the parent has no claim
   at all, the request is refused: claim the parent first (an alias to any
   route of the account will do). Holding a catch-all in the same account
   but under a different app from the apex is the point of the design
   (static site at the apex, teams below it).

**On the box.** `ReconcileAliases` gains `deriveInstanceAliases`. For each
catch-all whose parent is still claimed by the same account, it walks the
box's local routes and derives one ordinary `route_aliases` row,
`<rt.Slug>.<parent>` → `rt.Host`, for each route that meets every one of
these:

- `Provisioned`, not a sidecar, and not a replica;
- the claim's own app, and the claim's own account (`RouteAccount`);
- a `Slug` that is a single DNS label and is the first label of the host.
  The instance create door makes every record this shape.

A derived name loses to, in this order:

1. **any claim on that exact name, whoever holds it**. This covers the
   manager's existing per-team claims, `www`, `auth`, and another
   account's claim made before the catch-all existed;
2. a real route on this box;
3. a real route on any live box (`deploymentRoutes`).

The desired set is rebuilt on every pass:

- a deleted or moved instance drops its name on the next pass;
- a detached catch-all drops every name;
- a released exact claim hands its name back to the catch-all.

Rows are written only when they change, so a steady state causes no edge
reload. At most 1000 derived names per box (`maxInstanceAliases`), in a
deterministic order, with the overflow logged (§4.11).

**What the derived rows are.** They are the same rows an exact alias claim
for each name produces today: `Alias`, `Host`, `Account`, `App`. That is the
property the rest of this note relies on. Every reader of `route_aliases`
already handles such rows correctly, and none of them looks for a claim
behind a row. The only code that deletes alias rows is the pass's own
desired-set sweep. The edge itself needed only one change, made after
review: the cross-box precedence fix in §8, item 5.

**The attach guard.** While a catch-all is live, the attach doors refuse
**other accounts** the one-label names under its parent that it reserves
(`domains.ForeignInstanceCatchall`, then `catchallInstanceName` in the
console). A refused attach gets the neutral 409 from the same place
`deploymentRouteTaken` gives it, in all three attach paths (API alias, form
alias, first-route). What it reserves (§8, review P1s):

- **Proved parent: every label.** The account proves it controls the parent
  in DNS with TXT `_carlos-catchall.<parent>` =
  `carlos-catchall-account=<sqid>`. The value names the account, the same
  shape as the mail ownership proof (#216).
  - The guard checks the record live, so it follows the domain's DNS rather
    than a stamp taken once.
  - A lookup failure counts as "not proved": the narrower reservation, never
    a refusal.
  - `carlos domains list` reports `reserves: all` or `reserves: instances`
    on the catch-all's row, and names the record when it is missing.
- **Unproved parent: the labels of the app's existing instances**, under
  any instance domain the app has used. These are counted from the app's
  instance record keys, so a manifest-domain change does not orphan them.

- **Why it exists.** Without it, any account could attach
  `acme.letsjelly.dev`. Its exact claim would beat the derived name and
  take a live team's address. Attach needs no DNS proof, so nothing else
  stops that.
- **Why an unproved parent holds less (first review, finding 3).** Attach
  needs no DNS proof, so a parent claim proves nothing. Holding every label
  for an unproved parent would let one squatted apex claim plus a
  catch-all block the real domain owner from every name under it.
- **One label only**, because a catch-all serves one label only. Deeper
  names stay as open as they are today.
- **The caller's own existing claim is never refused (review finding 1).**
  A claim made before the catch-all keeps re-attaching and converging.
  titogo's router re-attaches every name on every pass.
  `deploymentRouteTaken` makes the same allowance.
- **"Live"** is the box's rule: the catch-all's parent is still claimed by
  its account. A catch-all whose parent was released, or has moved to
  another account, holds nothing. The parent's new holder may replace it
  (`AttachInstanceCatchall` overwrites a dead one).
- **Read failures.** A bucket read failure in the guard is a retryable
  502. It is never a 409 (which would read as "held") and never a skipped
  guard.

**Precedence is decided from the listing (review finding 2).** The box
decides "a claim on this name exists" from the bucket listing's keys, not
from the claim bodies that decoded this pass. A body whose GET fails is
still a claim, so a spurious store error cannot hand another account's name
to a derived alias for a pass.

**Certificates.** Each derived name gets per-host HTTP-01/ALPN certificates
exactly as exact aliases do: the cert warm orders them once DNS points
here, and the first handshake orders one otherwise. A delegated wildcard
certificate is optional on top, by setting `--wildcard` on the parent's
own claim. It then covers every derived name on every box (§4.10).

## 3. Mixed versions

- **An old box or old console reading a catch-all** decodes a claim with
  no Route, no Wildcard and no Paths. Every pass already skips that shape.
  It logs nothing and makes no DNS lookup or ACME order for the literal
  `*.<parent>`. Certwarm drops `*.` hosts anyway.
- **An old console** lists the row with an empty route and "checking"
  state. Its Detach button answers 404, because `NormalizeAliasHost` refuses
  the star. No old door can rewrite the claim, for the same reason, so the
  unknown `instances` field cannot be stripped.
- **Keys:** `*` is safe in S3 keys, in DirStore on Linux, and through the
  fleet store door. `canonicalKey` accepts it, and `aclRead` and
  `aclListScope` already grant `domains/**` to fleet boxes. DirStore on
  Windows would refuse it, but no deployment bucket runs there.
- **A new console with old boxes:** the catch-all is inert until the boxes
  upgrade. The attach guard is already live at that point, so other
  accounts are refused names the boxes do not serve yet. That is harmless,
  and it is what the owner asked for.

## 4. Deployment types

Each item says whether it is affected and what guards it.

1. **Flagship (console as an app, carloku.com, #209).** Not affected unless
   a catch-all is made, and none can be made on the deployment's own
   zones: refused for everyone, operators included. The console's names,
   steering, tunnel and the apps domain are refused at, under and above the
   parent. There is no `--platform` form; it can be added if the flagship
   ever wants one.
2. **Tito / titogo.** Its router re-attaches hostnames with an instance
   token, and it cannot create or remove a catch-all: those are
   person-only. Store-mode custom instance domains (go.tito.io) are
   unaffected, since nothing derives without a catch-all. The re-attach
   loop pays one extra GET per attach (`*.<parent>` of the host, usually a
   404). A Hetzner spurious 403 there is a retryable 502. Re-attach is
   idempotent, so the next pass succeeds.
3. **Pages-hub one-route catch-all, and woodstar.app** (apex plus
   `Wildcard`+`Catchall`, same app). Unaffected unless an instance
   catch-all is added on the same parent. If one is, derived exact names
   beat the route catch-all name by name (`lookupIn`: exact key first, then
   `*.`). That is same-account by construction, and it is the useful
   combination: unknown labels fall to the site, known ones to their team.
   - On woodstar it would take a PDS handle whose label matches an instance
     label, so do not combine the two there.
   - The ceecb3a9 self-removal loop cannot recur. Derived rows are exact
     names published with `Alias: true`, and `DeploymentRoutes` counts only
     `Role==home && !Alias`.
4. **Self-hosted single box, and this hand-staged box.** Same as above:
   nothing changes without a catch-all. The apps domain is refused as a
   parent even though it is not reserved there. Hand-added routes
   (`carlos route add`) are not `Provisioned` and derive nothing. That also
   keeps a legacy account-name column, which reads as ops, out of reach.
5. **Customer fleets / `carlos apps place`.** Fleet boxes read `domains/**`
   through the store door, so they derive for their own instances. The store
   door does not grant `control/edges/`, so `deploymentRoutes` is nil
   (permissive) there. This gap already exists for plain aliases, and the
   exact-claim skip still applies.
6. **Multi-box, multi-region edges, the edges file.** Derived rows publish
   exactly like exact aliases: only once `StampHealth` has stamped them
   `pointing`, and backhauled by remote edges to the home box. A new or
   moved name can take up to about 12 minutes to reach other edges (one
   stamp period plus one edges-file period). Exact claims have the same
   delay today; a single box serves at once. Duplicate publication
   (everywhere-class instances) folds to the first-sorted box, as for exact
   aliases.
7. **Edge replicas, steering.**
   - Replica rows are excluded explicitly (`rt.Replica`). A door-created
     replica carries the label as its Slug, so the Slug shape alone would
     not exclude it.
   - Steering looks at platform names only, and never at aliases or claims.
8. **Instance moves.** During the cut the source row is `moving`, and the
   edge and edges file skip it. After the flip the target box derives the
   name. The name follows the instance, as an exact alias claim would.
9. **Hibernation and activation.** An alias entry is its primary's upstream
   copied whole, `route` and `hibernating` included. A request on a derived
   name therefore wakes the instance itself, and metering, the gate and
   analytics are keyed on `rt.Host`.
   - The response cache keys on the requested host, so the app purges a
     derived name's entries with `Carlos-Purge-Host`. That is pre-existing
     for exact aliases.
   - The backend sees `Host: <label>.<parent>`, so the app must accept it.
     Jelly does today, via the exact claims.
10. **Isolated exec, path-prefix maps.**
    - Isolated exec: units and sockets are keyed on `rt.Host`, so it is
      unaffected.
    - Path-prefix maps: a derived name inherits its instance's map, as any
      alias does. A Paths claim on a derived name itself is an exact claim
      and suppresses the derived row.
11. **Platform apps-domain wildcard, platform-domain claims (#209),
    reserved/deny lists, readings sweep, cert sweep, LE limits, alias-table
    growth, `maxWildcardDomains`.**
    - `parentsFor` takes only platform/provisioned non-alias hosts, so
      derived names cannot mint platform parents.
    - The deny tiers are covered in §2.
    - The readings sweep (`StampHealth`) does one serial `LookupHost` per
      alias (3s timeout) every 5–6 minutes. Each alias adds about 140 bytes
      to the box's status snapshot, and readers skip a snapshot above 1 MiB.
      A skipped box also drops out of `DeploymentRoutes` and the edge-IP
      fold. That is why there is a **per-box cap of 1000 derived names**,
      logged when it is hit.
    - The cert sweep is expiry-only, and `ownsCertName` checks
      `deploymentHost`, not claims.
    - **Let's Encrypt:** without a wildcard, N instances means N new
      CT-logged certs against the registered domain's limit (50 per 7 days
      as of writing). About 200 teams take about four weeks of issuance;
      past the limit, certwarm backs off 12h per host. That is identical to
      today's per-team exact claims. The remedy is `--wildcard` on the
      parent claim, which needs `CARLOS_ACME_DELEGATION_ZONE` and a DNS
      provider. The testbed has neither.
    - The instance catch-all is not a Wildcard claim, so it takes no slot
      in `maxWildcardDomains` or `maxDelegatedParents`.
12. **Trash, purge, rename and move of apps; instance delete with parked
    data.**
    - Trash drops the app's routes, so the derived names go with them. The
      catch-all claim stays, as every claim does, and comes back to life on
      restore.
    - Purge never deletes `domains/` (pre-existing for all claims). An
      orphaned catch-all keeps holding names under its parent for its
      account, and passes to a later app of the same name in the same
      account. Detach it before purging.
    - Rename only applies to never-shipped apps, and the door requires an
      instance manifest.
    - Account move leaves claims on the old account, which derive nothing
      (pre-existing).
    - Instance delete removes the route but keeps the parked data. The name
      drops, and comes back if the instance is recreated.

## 5. Security cases

- **(a) A label colliding with another account's exact claim:** the exact
  claim wins, on every box. A derived name is skipped whenever any claim
  exists for it. New claims of that kind are refused by the attach guard
  while the catch-all is live. Claims made before the catch-all keep
  working, so it cannot take a name away from anyone.
- **(b) A sibling app's apex label:** cannot happen.
  - Only the claim's own app's routes derive.
  - Sibling apex hosts are not under the parent.
  - The instance create door refuses a label that names a live or trashed
    sibling app, and `apex.go` refuses the name arriving afterwards.
- **(c) A team's name taken before the team exists.**
  - **Proved parent:** impossible while the catch-all is live, for every
    label, including a deleted team's.
  - **Unproved parent:** the name is protected once the instance exists. A
    foreign claim made before that wins, which is the first-come rule every
    claim has.
  - A same-account exact claim wins on purpose.
- **(d) Races between the claim pass and the instance reconcile:** both
  sides are rebuilt from scratch each pass, and every precedence check is
  made against the same pass's claim list and route table. A catch-all
  write racing a foreign attach can let both land; the exact claim then
  wins, which is the same outcome as (c). A parent released between the
  door's check and its write is caught by the box's parent re-check.
- **(e) The parent changes hands:** the old catch-all goes inert on the
  box, stops holding names at the door, and the new holder can replace it.
- **(f) A derived alias pointing at another account's route:** prevented by
  three filters, all of which stay: `rt.App == c.App`,
  `RouteAccount(rt) == c.Account` and `rt.Provisioned`.
- **Abuse of the guard as a squat.**
  - **Unproved parent:** the guard holds only names of instances that
    exist, each a real, rate-limited instance. That is the same class of
    squat as claiming those names directly.
  - **Proved parent:** holding every label takes write access to the
    parent's DNS, which is what owning the domain means.
  - Public suffixes are refused as parents either way.

## 7. Review

Reviewed by an Opus agent before landing. Three medium findings were all
fixed, each with a test that was watched to fail first where practical:

1. The guard refused an account's re-attach of its own older claim.
2. Precedence was decided from decoded bodies, which fails open on a read
   error.
3. The guard held every label, which amplified a squat.

Lows taken:

- the browser form now refuses a channel on a catch-all.

Lows left as they are:

- **Member, not owner, on the API door.** The API door admits any person
  in the account, the browser door only owners. That is the same split
  ordinary attach has.
- **A reassigned name keeps its old `dns_status` until the next stamp.**
  Pre-existing for exact claims changing hands.
- **Once-per-process refusal logs** do not repeat if a catch-all recovers
  and fails again.

## 8. Second review ([#233](https://amadan.net/carlos/platform/-/discussion/233), Paul's agent with Astra)

Two P1s and three P2s. Every one was checked against the code and found
real, and all five are fixed with tests that were watched to fail first.

1. **P1: a team's name could be taken before the team exists.** Wildcard
   DNS points every label at the edge, and attach needs no proof.
   **Fixed:** a proved parent reserves every label (§2, the attach guard).
2. **P1: the guard checked only the current manifest domain.** **Fixed:**
   the guard counts the app's instance record keys by first label, across
   every instance domain (`domains.InstanceLabelCounts`), with one listing
   and no record bodies.
3. **P2: a duplicate label routed differently per box.** Two instances
   sharing a label, possible only after an instance-domain change, were
   each named by the box that held them. **Fixed:** a label shared by two
   records derives nothing, on every box alike, and the refusal is logged.
   - Seen from the same record listing, asked once per catch-all and only
     when the box has an instance to name.
   - If the listing fails, the catch-all's existing rows are kept for that
     pass, never dropped.
   - The earlier "first in host order wins" text is withdrawn.
4. **P2: an old owner's detach could delete the new owner's catch-all.**
   This happened after the parent changed hands and the catch-all was
   replaced in place. **Fixed:** `DetachClaim` deletes an instance
   catch-all only at the revision it read (`release.DeleteIfMatch`).
   - Ordinary claims are unchanged. They cannot be replaced while held, so
     the race does not exist for them.
   - Both production stores (S3, DirStore) support the conditional delete.
5. **P2: a route catch-all on one box beat an instance name on another.**
   The local `*.<parent>` matched before backhaul was considered.
   **Fixed in the edge:** a local match made only through a star key now
   yields to another box's exact host (`homeMap.ExactHome`, then backhaul).
   - That is the single-box precedence, exact then star, extended across
     the deployment.
   - An arrived hop and a door call keep the local answer.
   - A two-box test covers it.

The review's answers to this note's asks:

- **`--platform` catch-alls:** keep refusing them.
- **CLI spelling:** agreed.
- **"Delete now" leftovers:** filed as their own discussion.

## 6. Surfaces

- **CLI.** The catch-all is spelled `carlos domains attach --app <a>
  '*.<parent>'` and `carlos domains detach --app <a> '*.<parent>'`.
  - The star is the mode, and no other flag combines with it.
  - This keeps the meaning of `attach <host>` as "this hostname now
    serves this app", and needs no new flag grammar.
  - `--catchall` stays the bool capability it is; `--catchall instances`
    would have made it a value flag and broken bare `--catchall`.
  - `carlos domains list` shows the catch-all row with mode `instances`,
    then each derived name the fleet reports, marked `via *.<parent>`.
- **API.** Same endpoints. The attach answer and list rows gain
  `instances: true`, and the list gains `via` on derived rows. Both are
  additive.
- **Web UI.** The app's Domains & TLS table shows the catch-all and its
  derived names. The page does not edit wildcard or catchall today, and
  the catch-all is person-only, so the page lets an owner attach and detach
  it the same way as any hostname: typing `*.<parent>` into the attach
  form.
