carlos / platform Public

AGENTS.md is the canonical agent file; CLAUDE.md points at it

Paul's rule, project-wide: the agent-instructions file is AGENTS.md, with
a short CLAUDE.md left behind so Claude Code still finds it. amadan,
paulca.com, seapointish and woodstar already work this way; this brings
the platform repo into line, using amadan's pointer text verbatim.

The in-repo references move with it: eleven Go files name the rules file
in comments, and those now say AGENTS.md. Plans and specs under
docs/superpowers/ are left alone — they record what was true when they
were written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 4dd1afc6e495886187bf5ba39ac646b80004af88 parent df9e89e
17 files changed, +214 −203
  • AGENTS.md +192 −0
  • CLAUDE.md +4 −185
  • domains_cli_test.go +2 −2
  • fleets_cli_test.go +1 −1
  • internal/appconfig/syncexec.go +2 −2
  • internal/appconfig/syncexec_test.go +1 −1
  • internal/applog/record.go +1 −1
  • internal/applog/record_test.go +1 −1
  • internal/console/apiservices.go +1 −1
  • internal/console/apiservices_test.go +1 −1
  • internal/console/apistore.go +1 −1
  • internal/console/economicspage_test.go +1 −1
  • internal/console/ladder_test.go +1 −1
  • internal/status/status_test.go +1 −1
  • services_cli_test.go +2 −2
  • store.go +1 −1
  • store_test.go +1 −1
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..ee741134a
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,192 @@
+# AGENTS.md
+
+This file provides guidance to AI coding agents working with code in
+this repository. It is the canonical agent-instructions file — keep it
+current here, not in a tool-specific file. (Claude Code reads it via the
+`CLAUDE.md` pointer.)
+
+CARLOS platform — bucket-as-control-plane deployment platform. Design docs in
+`docs/superpowers/specs/`, plans in `docs/superpowers/plans/`; 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.
+
+## Every change lands through a PR (Paul's rule, 2026-08-10)
+
+**Nothing merges to `main` without a pull request.** 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 a PR. That includes doc-only edits, one-line fixes, and revert
+commits.
+
+Agents: this rule is on you as much as on people. When work is complete,
+push the branch and open the PR — never a local merge, never a direct
+commit, however small the change.
+
+**Merging that PR 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
+PR has to exist, so that it can carry the context, the iterations and the
+several commits that a single squashed push to `main` would flatten. Once
+it exists and is green, `gh pr merge` is an ordinary step.
+
+Use the judgement the rest of this file asks for: a PR that is still
+collecting review, that is gated on something (`#62`), or whose diff you
+would want a second pair of eyes on, waits for a person.
+
+The reason is timing, not ceremony. `.github/workflows/ci.yml` triggers on
+`pull_request` *and* on `push` to `main`, so a locally merged change does
+still meet the gate — but only **after** it is on `main`, where a red run
+is an incident to clean up rather than a merge that simply doesn't happen.
+The PR is the one place the gate runs against the merge result while the
+change can still be turned away, and the one place the branch's diff is
+read as a single piece rather than commit by commit.
+
+## 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 for files you touch (three pre-existing unformatted files
+ elsewhere are left alone).
+- `.github/workflows/ci.yml` runs that same gate on every PR, plus a
+ `GOOS=darwin` compile. **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).
+
+## Rules this repo has paid for
+
+- **When a storage shape changes, the consumer list is everything that PARSES
+ the shape** — not everything that calls the API. (Learned three times.)
+- **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`)
+ **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.
+
+## Deploy reality (2026-08-02; convergence rewritten 2026-08-08)
+
+- Flagship (keymail AWS acct): console, kass, keymail, platform-site. Ship via
+ `scripts/ship-app.sh` / `promote-app.sh` in the pinfra checkout — the signing
+ key comes from SSM inside the script, never a terminal.
+- Tito production is self-hosted CARLOS on `carlos-tito-ie` (titogo AWS acct).
+- keymail.dev deploys via `carlos ship`/`promote` (account `bae`), 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). `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.
diff --git a/CLAUDE.md b/CLAUDE.md
index 9fa6ab4a3..270f5c9ca 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,187 +1,6 @@
# CLAUDE.md
-CARLOS platform — bucket-as-control-plane deployment platform. Design docs in
-`docs/superpowers/specs/`, plans in `docs/superpowers/plans/`; 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.
-
-## Every change lands through a PR (Paul's rule, 2026-08-10)
-
-**Nothing merges to `main` without a pull request.** 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 a PR. That includes doc-only edits, one-line fixes, and revert
-commits.
-
-Agents: this rule is on you as much as on people. When work is complete,
-push the branch and open the PR — never a local merge, never a direct
-commit, however small the change.
-
-**Merging that PR 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
-PR has to exist, so that it can carry the context, the iterations and the
-several commits that a single squashed push to `main` would flatten. Once
-it exists and is green, `gh pr merge` is an ordinary step.
-
-Use the judgement the rest of this file asks for: a PR that is still
-collecting review, that is gated on something (`#62`), or whose diff you
-would want a second pair of eyes on, waits for a person.
-
-The reason is timing, not ceremony. `.github/workflows/ci.yml` triggers on
-`pull_request` *and* on `push` to `main`, so a locally merged change does
-still meet the gate — but only **after** it is on `main`, where a red run
-is an incident to clean up rather than a merge that simply doesn't happen.
-The PR is the one place the gate runs against the merge result while the
-change can still be turned away, and the one place the branch's diff is
-read as a single piece rather than commit by commit.
-
-## 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 for files you touch (three pre-existing unformatted files
- elsewhere are left alone).
-- `.github/workflows/ci.yml` runs that same gate on every PR, plus a
- `GOOS=darwin` compile. **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).
-
-## Rules this repo has paid for
-
-- **When a storage shape changes, the consumer list is everything that PARSES
- the shape** — not everything that calls the API. (Learned three times.)
-- **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`)
- **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.
-
-## Deploy reality (2026-08-02; convergence rewritten 2026-08-08)
-
-- Flagship (keymail AWS acct): console, kass, keymail, platform-site. Ship via
- `scripts/ship-app.sh` / `promote-app.sh` in the pinfra checkout — the signing
- key comes from SSM inside the script, never a terminal.
-- Tito production is self-hosted CARLOS on `carlos-tito-ie` (titogo AWS acct).
-- keymail.dev deploys via `carlos ship`/`promote` (account `bae`), 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). `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.
+Guidance for AI coding agents working in this repository lives in
+**AGENTS.md**. Claude Code: read AGENTS.md. Update AGENTS.md — not this
+file — going forward; this file is only a pointer so Claude Code finds
+it.
diff --git a/domains_cli_test.go b/domains_cli_test.go
index bdf969f1f..5f8aa3fb7 100644
--- a/domains_cli_test.go
+++ b/domains_cli_test.go
@@ -6,7 +6,7 @@
// full verb (flag parsing, printing) against the same kind of fake console,
// credentials written straight to disk. Usage-error paths (log.Fatal, would
// kill an in-process test binary) run as a real subprocess, carlosTestEnv +
-// scratch cmd.Dir per CLAUDE.md's rule.
+// scratch cmd.Dir per AGENTS.md's rule.
package main
import (
@@ -529,7 +529,7 @@ func TestRunDomainsAttachWildcardFlagSentOnlyWhenNamed(t *testing.T) {
// malshaped arguments as a real subprocess (buildCarlos) — the only way to
// observe a log.Fatal/os.Exit refusal without killing the test binary.
// carlosTestEnv masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE and cmd.Dir is
-// a scratch dir (CLAUDE.md's rule), so a case that DID try to dial out would
+// a scratch dir (AGENTS.md's rule), so a case that DID try to dial out would
// fail on "not logged in", not silently succeed.
func TestDomainsCLIUsageErrors(t *testing.T) {
bin := buildCarlos(t)
diff --git a/fleets_cli_test.go b/fleets_cli_test.go
index 167ef85c2..3a0f257d9 100644
--- a/fleets_cli_test.go
+++ b/fleets_cli_test.go
@@ -649,7 +649,7 @@ func TestRunAppsPlaceHitsRightPathAndClears(t *testing.T) {
// a log.Fatal/os.Exit refusal without killing the test binary. Every case
// here must exit nonzero without ever reaching the network: carlosTestEnv
// masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE and cmd.Dir is a scratch dir
-// (CLAUDE.md's rule — a subprocess that inherited a real login has minted
+// (AGENTS.md's rule — a subprocess that inherited a real login has minted
// real accounts on the flagship before), so a case that DID try to dial out
// would fail on "not logged in", not silently succeed.
func TestFleetsAndPlaceCLIUsageErrors(t *testing.T) {
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go
index 2c7f9a381..1c897955b 100644
--- a/internal/appconfig/syncexec.go
+++ b/internal/appconfig/syncexec.go
@@ -184,7 +184,7 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv
// A value THIS binary doesn't understand, in a bundle that
// does state one: a statement, so it decides — treated as
// not opted in, loudly, once per bundle per pass, never a
- // hard failure (additive discipline, CLAUDE.md).
+ // hard failure (additive discipline, AGENTS.md).
fmt.Printf(" %s/%s: config: exec_delivery %q in environment %q is not a value this binary understands — ignoring it (not opted in); upgrade carlos if this is expected\n", acct, app, eb.m.ExecDelivery, env)
c, decided = optedInRes{v: false, known: true}, true
}
@@ -233,7 +233,7 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv
default:
// A manifest a NEWER carlos wrote with a value THIS binary
// doesn't understand must not brick this box's sync (additive
- // discipline, CLAUDE.md) — ignore it, loudly, once, and treat
+ // discipline, AGENTS.md) — ignore it, loudly, once, and treat
// the app as not opted in rather than refusing the whole pass.
fmt.Printf(" %s/%s: config: exec_delivery %q in the default bundle is not a value this binary understands — ignoring it (not opted in); upgrade carlos if this is expected\n", acct, app, def.m.ExecDelivery)
c = optedInRes{v: false, known: true}
diff --git a/internal/appconfig/syncexec_test.go b/internal/appconfig/syncexec_test.go
index 9daa680cf..aab960ce0 100644
--- a/internal/appconfig/syncexec_test.go
+++ b/internal/appconfig/syncexec_test.go
@@ -706,7 +706,7 @@ func TestSyncExecUnoptedAddrRouteIsSkipped(t *testing.T) {
}
// TestSyncExecUnrecognizedExecDeliveryValueIgnoredNotFatal covers additive
-// discipline (CLAUDE.md): a manifest a NEWER carlos wrote, carrying an
+// discipline (AGENTS.md): a manifest a NEWER carlos wrote, carrying an
// exec_delivery value this binary doesn't understand, must not brick this
// box's sync — the whole pass must still succeed, the unknown value must
// be treated as "not opted in" (not a fatal error, not silently treated
diff --git a/internal/applog/record.go b/internal/applog/record.go
index 8e08f14c6..92ae49d69 100644
--- a/internal/applog/record.go
+++ b/internal/applog/record.go
@@ -63,7 +63,7 @@ func levelRank(level string) int {
func ValidLevel(s string) bool { return levelRank(s) >= 0 }
// Record is one log line on the wire — one JSONL line inside a chunk.
-// Additive shape (CLAUDE.md rule): every field but TS and Msg is omitempty,
+// Additive shape (AGENTS.md rule): every field but TS and Msg is omitempty,
// unknown keys are ignored on read, and anything written before a shape
// change must still parse. TS is the box clock in UTC; cross-box order is
// best-effort by TS with (Box, Seq) as tiebreak — per-box order is the only
diff --git a/internal/applog/record_test.go b/internal/applog/record_test.go
index ee89c54ae..98d176924 100644
--- a/internal/applog/record_test.go
+++ b/internal/applog/record_test.go
@@ -11,7 +11,7 @@ import (
// and msg is omitempty, and a record written by a future revision with keys
// this one has never heard of still parses. Live chunks exist in production
// buckets from the moment this ships — anything written before a change
-// must still parse (CLAUDE.md wire-shape rule).
+// must still parse (AGENTS.md wire-shape rule).
func TestRecordWireShapeIsAdditive(t *testing.T) {
r := Record{
TS: time.Date(2026, 8, 10, 14, 3, 11, 482913000, time.UTC),
diff --git a/internal/console/apiservices.go b/internal/console/apiservices.go
index c7a6127ae..2447b6053 100644
--- a/internal/console/apiservices.go
+++ b/internal/console/apiservices.go
@@ -118,7 +118,7 @@ func (s *Server) apiOwnerAccount(w http.ResponseWriter, r *http.Request) (Princi
// actually be admitted to. A hand-written list in a help string would be
// right on the day it was written and wrong on the day a door moved.
//
-// This exists because of CLAUDE.md's no-infra rule read as a rule about
+// This exists because of AGENTS.md's no-infra rule read as a rule about
// comprehension rather than only about access: an operator must be able to
// see what they granted without reading a spec.
func (s *Server) doorsFor(role svcrole.Role) []string {
diff --git a/internal/console/apiservices_test.go b/internal/console/apiservices_test.go
index 873d31fd8..f896bb8cc 100644
--- a/internal/console/apiservices_test.go
+++ b/internal/console/apiservices_test.go
@@ -263,7 +263,7 @@ func TestMintRefusesADuplicateName(t *testing.T) {
}
// TestCreateAnswersWithTheDoorsItGranted — an operator must be able to see
-// what they gave away without reading a spec (CLAUDE.md's no-infra rule
+// what they gave away without reading a spec (AGENTS.md's no-infra rule
// applied to comprehension, not just access). The list is derived from the
// same map the gate reads, so it cannot drift from what the gate allows.
func TestCreateAnswersWithTheDoorsItGranted(t *testing.T) {
diff --git a/internal/console/apistore.go b/internal/console/apistore.go
index 631d7a81a..d49849e92 100644
--- a/internal/console/apistore.go
+++ b/internal/console/apistore.go
@@ -105,7 +105,7 @@ func storeEffectivePrefix(rec *objstore.Record) string {
// it is here because without it `status` cannot answer the question a waiting
// member actually has: is my declaration queued behind an operator, or does
// this deployment have no object storage at all and never will? The
-// no-infra-access rule (CLAUDE.md) says the command alone must complete the
+// no-infra-access rule (AGENTS.md) says the command alone must complete the
// job, and "declared, nothing happening" with no way to tell those apart
// fails it. Disclosure-wise it is the same class as secrets_pubkey and the
// apps domain: a deployment-wide fact about the platform, carrying nothing
diff --git a/internal/console/economicspage_test.go b/internal/console/economicspage_test.go
index a45cf2e53..f2cd2a16a 100644
--- a/internal/console/economicspage_test.go
+++ b/internal/console/economicspage_test.go
@@ -683,7 +683,7 @@ func TestEconomicsPagePooledMarginIsDash(t *testing.T) {
// (customer hardware, zero cost to the platform — PriceAccount's cost
// formula has no fleet term at all), and a number that looks like a
// modelling error gets an explanation somewhere on the page (dashboard-truth
-// rule, CLAUDE.md). The title= tooltip on the Margin header is invisible on
+// rule, AGENTS.md). The title= tooltip on the Margin header is invisible on
// touch/keyboard, so both notes ALSO need a visible one-line legend under
// each table — this asserts the tooltip AND the legend paragraph, on both
// pages, not just "the text appears somewhere" (which the tooltip alone
diff --git a/internal/console/ladder_test.go b/internal/console/ladder_test.go
index 3d4ccc712..0425f65f8 100644
--- a/internal/console/ladder_test.go
+++ b/internal/console/ladder_test.go
@@ -452,7 +452,7 @@ func TestLadderEnvironmentAndProdWiredVisibleToNonOwner(t *testing.T) {
// A canary promoted moments ago — exactly what every real promotion through
// this console produces, Promote always stamping "now" — must carry no
-// expired badge. This is the case CLAUDE.md's own console route is in on
+// expired badge. This is the case AGENTS.md's own console route is in on
// every deploy (canary/console, re-promoted each time): a false positive
// here would badge the console's own production traffic as neglected.
func TestLadderFreshCanaryCarriesNoExpiredBadge(t *testing.T) {
diff --git a/internal/status/status_test.go b/internal/status/status_test.go
index 85790bde9..03ae43667 100644
--- a/internal/status/status_test.go
+++ b/internal/status/status_test.go
@@ -250,7 +250,7 @@ func TestCollectBoxOmitsMetricsWhenAllUnknown(t *testing.T) {
// TestBoxBackCompatParse: yesterday's Box shape (no metrics key) must parse
// today, and a FUTURE box's unknown keys (inside or alongside metrics) must
-// not break today's parse either — additive wire shape, per CLAUDE.md's
+// not break today's parse either — additive wire shape, per AGENTS.md's
// "consumer = everything that PARSES the shape" rule.
func TestBoxBackCompatParse(t *testing.T) {
yesterday := []byte(`{"generated_at":"2026-08-03T10:00:00Z","label":"ada-stargazing",
diff --git a/services_cli_test.go b/services_cli_test.go
index 3a51719c4..c03d213ce 100644
--- a/services_cli_test.go
+++ b/services_cli_test.go
@@ -19,7 +19,7 @@
//
// EVERY test here that shells out to the built carlos binary builds its env
// through carlosTestEnv (masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE) and
-// sets cmd.Dir to its own scratch dir — CLAUDE.md's rule, restated in this
+// sets cmd.Dir to its own scratch dir — AGENTS.md's rule, restated in this
// task's brief: a subprocess that inherited a real login has minted real
// accounts on the flagship before.
package main
@@ -748,7 +748,7 @@ func TestRunServicesRevokeMatchedFalseDoesNotPrintSuccess(t *testing.T) {
// log.Fatal/os.Exit refusal without killing the test binary. Every case
// here must exit nonzero without ever reaching the network:
// carlosTestEnv masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE and cmd.Dir is
-// a scratch dir (CLAUDE.md's rule — a subprocess that inherited a real login
+// a scratch dir (AGENTS.md's rule — a subprocess that inherited a real login
// has minted real accounts on the flagship before), so a case that DID try
// to dial out would fail on "not logged in", not silently succeed.
func TestServicesCLIUsageErrors(t *testing.T) {
diff --git a/store.go b/store.go
index bdca699e5..c1d99e10b 100644
--- a/store.go
+++ b/store.go
@@ -245,7 +245,7 @@ func runStoreRotate(args []string) {
// that fact is added too: a member cannot see CARLOS_STORE_BACKEND for
// themselves, and "declared, nothing happening" with no way to tell a queued
// request from a deployment that will never grant one fails the no-infra-
-// access rule (CLAUDE.md) just as surely as a missing CLI command would.
+// access rule (AGENTS.md) just as surely as a missing CLI command would.
func printStoreCreate(w io.Writer, res apiStoreReply) {
c := colorFor(w)
prefix := ""
diff --git a/store_test.go b/store_test.go
index cf1f6af97..9aa983c76 100644
--- a/store_test.go
+++ b/store_test.go
@@ -499,7 +499,7 @@ func TestPrintStoreStatusDeclaredNotProvisioned(t *testing.T) {
// ---- runStore dispatch / refusals (real binary) -----------------------------
// TestStoreVerbDispatchAndRefusals drives the real binary through
-// carlosTestEnv with a scratch cmd.Dir — the repo rule (CLAUDE.md) — so a
+// carlosTestEnv with a scratch cmd.Dir — the repo rule (AGENTS.md) — so a
// logged-in laptop's real credential and console can never be what this test
// exercises: unknown sub-verb, missing -app on each sub-verb, and direct
// bucket mode refused with store's own sentence.