carlos / platform Public
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:
- Push the branch to amadan. The push is what triggers CI. The remote may be named
amadanororigindepending on the checkout; it is whichever one points atamadan.net. - 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 -
- Squash it to one commit before landing, with a message that says what the change is and why.
amadan branch mergehas no squash mode, so this is done on the branch:mainthen 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. - 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 testchecks 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.mdrecords what each step descends from, and.amadan/ci.d/10-gate's header records why the switch was safe.10-gateisgo build ./...besidemake test, so CI and what you run locally are one definition rather than two that can drift.make testsplits 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 redmainis worth knowing about the same day. On 2026-08-31, when merges still happened on GitHub, amadan'smainwas 86 commits and three days behind, and the gate had not run onmainonce in that window; landing on amadan is what ends that. - GitHub follows amadan. If GitHub's
mainis 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'smain. 20-cross@5addsGOOS=darwin/windows/linux-arm64compiles, one per shard. The gate is not Linux-only:internal/hostis linked into the CLI, which people run on Macs, so a Linux-only type assumption breaksgo 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)
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 the census owner's approval on #280 before the merge. Most are approvals: the same reads in an edited body, or an envelope fingerprint that moved. Get them approved like this:
make census-cardprints 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,removedorother(any census discrepancy none of these explains, shown with the census's own message): a decision.- 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>. 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@legacyfingerprints, the envelope re-pins and the new seal; - its line intestdata/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.
- Show the card to the census owner and ask for an approval. Ask in the session when they are there (one question: approve or hold); otherwise post the card on #280. Record the approval on #280: who, when, and the tip. Rows carry
rev3=census-ruling-<NAME>from the start, so there is nothing to flip afterwards and no second CI run.
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 seen by a person 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:PutObjectTaggingout of the app's Allow — stated in capitals instorePolicyJSON, 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-taggingonPutObject— 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 explicitDenyons3:PutObjectTaggingdid not close it; only aDenyons3:PutObjectconditioned ons3:RequestObjectTagKeysdid. 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 theenforceDeny — which keys on the tag being present — never engaged. So:s3:PutObjectTaggingmust 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 inTestDeliveredContractMinio, 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
carloscommand is not done untildocs/site/cli.mdnames it.internal/docsitereads 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/docslink whose page or#fragmentdoes not resolve. The check is a heading and not a substring on purpose:routeis a prefix ofroutes, and a substring check let a deleted section pass. The corpus is rendered bycarlosframework/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 oncanary/*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 fromapps/<sqid>/<app>/instances/<host>.jsonon every pass, so acarlos route -channelon the box lands and is reverted within seconds of the next serial bump —carlos routerefuses 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 stampsProvisioned, andProvisionedis 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/edgeembed the writer below, soHeader()is ONE shared map from handler to connection — andmaybeStorecachesrec.Header().Clone(). The gzip wrapper (internal/edge/compress.go) keeps a private map and writesContent-Encodingonly 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 AFTERWriteHeaderis a TRAILER, so the wrapper must sync late additions down or it silently eats them; and canonical forETagisEtag, 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) →
requireFreshAuthLAST. 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_BINbinaries 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(masksCARLOS_CREDENTIALS,HOME,CARLOS_CONSOLE,CARLOS_DESTINATION) and setcmd.Dirto 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 runninggo test ./...minted 40+ real accounts on the production console before this was isolated. At.TempDir()is scratch in that sense only because the root package'sTestMain(isolateTempRoot, maintest.go) checks both temp roots, TMPDIR and GOTMPDIR (t.TempDir()uses GOTMPDIR when it is set), and moves whichever has a.carlos/configabove 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./tmpis never used, and with/var/tmpunavailable the binary stops and says which variable to set.TestMainalso drops an exported `CARLOSCONFIGandCARLOS_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 (aTunnel/Regionit never should have earned), sopublishResidentFleetBindingsalso 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. Useregistry.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.Fatalinside 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 whileTestStoreVerbDispatchAndRefusalshid 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-channeledgepipeline at create; per-channel rules are console-enforced, bake windows box-enforced through theFirstSeen.Holdsratchet.
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-infrastructurefor the flagship (carloku.com, split out 2026-10-01; the tofu root is the repo root) andcarlosframework/platform-infrastructurefor 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.Zof 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, andtofu init -upgradeplus a read plan pass. Dev still tracksmain. - keymail.dev deploys via
carlos ship/promote, NOT via its repo's legacydeploy/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 deployis the member one-command form: ship → promote → wait untilX-Carlos-Versionon the URL reports the shipped build over 2xx. Verify deploys with that header (curl -sI --resolve), never a cached answer.-Iis fine for the VERSION header, but it sends a HEAD — and HEAD is excluded from edge compression, so anything aboutContent-Encodingneeds 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 touchesapps/<acct>/<app>/restart-gen.json+ bumps the serial; boxes act on the serial move (exec children bounced in-process, unit-shaped routes via rootcarlos adopt -restart-onlythroughcarlos-restart.path, which update-platform-binary.sh arms since pinfra PR #10). Hibernating instances are never woken. Static routes carryX-Carlos-Versionfrom the same roll (#112), socarlos deploy -kind static -host <site>verifies static sites too.carlos-system-update.pathrelays the agent's platform self-update request tocarlos system apply(root; spec 2026-09-06-carlos-system-update-design.md §6).carlos-system-recover.serviceruns once per boot, before the edge, and on the apply's failure. Both must be armed; the last run ofupdate-platform-binary.shdoes it, and the hourlycarlos adoptpass installs and arms them if it did not. A box followsCARLOS_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 followscarlos secrets set --environment relay, since a bundle is delivered to an ENVIRONMENT.--hostwins 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 selectorkindit does not know rather than falling back to the app — never widen on ignorance. Boxes remember the last request id they acted on (scopesinrestart-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 CONSOLEAPPSDOMAINALIASACCOUNTS (comma-separated sqids; empty by default). A listed account can claim an unoccupied single-label hostname below PLATFORMAPPSDOMAIN 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 CONSOLERESERVEPLATFORM_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 PLATFORMAPPSDOMAIN and under the console's own hostnames ONLY when the console sets CONSOLERESERVEPLATFORMDOMAINS=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, CONSOLEDENY_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 CONSOLEDENYDOMAINS entries and, with CONSOLERESERVEPLATFORMDOMAINS, the names under the console's own hostnames. Members are refused them. A platform operator (an ops account owner, or an address on CARLOSSYSTEM_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 CONSOLEDOMAINPROOF=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.