# Tenant Email, Plan 5 of 5: Instance senders

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Give one instance of an app its own From address, its own IAM user, its own SES tenant, its own cap, its own pause and its own counts, with the credential delivered through plan 4's per-instance config layer and never through the bundle every sibling reads. An app enters **per-instance mode** on its first instance enable — one CAS write on `senders/app.json` that sets `PerInstance`, deactivates the app sender and stamps `PauseKindPerInstance`, followed by a list-and-delete reap of every key the app sender and its standalones hold, followed by unsetting the app sender's `CARLOS_SMTP_*` from the default bundle — and leaves it when the last instance sender is torn down. Seven doors under `/api/cli/apps/{account}/{app}/instances/{host}/email` carry `svcrole.Mail`; `enable` refuses twice, with a sentence each: once when the instance's process would share a uid with a sibling, and once when the box it runs on is not running an agent that renders the instance layer.

**Architecture:** `internal/mail` gains `instance.go`: `DeclareInstance`, `EnterPerInstance`, `LeavePerInstance`, `EnableInstance`, `RotateInstance`, `TeardownInstance`, and the delivery helpers that write through `appconfig.ApplyInstance` instead of `appconfig.Apply`. The app-level walks are widened rather than duplicated: `grantedDomains` and `RefreshPolicies` stop walking `DomainRecord.Apps` and walk a new `Senders []string` of `"<app>/<sender>"` references, with a legacy record's `Apps` read as one app-sender reference each, so an instance sender's policy is refreshed when its configuration set is rebound — the 554 that #271 and #279 already cost this codebase, reached through a new door. `claimDomain` carries the sender and refuses a re-claim by a different `(app, sender)`, including inside one account. `EnsureDomain` and `Teardown` keep their signatures and delegate to new `…ForSender` forms with `SenderAppID`, so every existing caller and test compiles unchanged. Console-side, `internal/console/apimailinstance.go` holds the family: one gate on `apiinstancemove.go`'s `moveGate` shape, the two enable refusals, and the seven handlers. Both refusals read the box record, because a box is the only thing that knows either answer and a console reads only its own registry: task 7 adds `ConfigSchema` (which config-layer schema this agent renders) and `RouteUIDs` (host -> the uid shape this box renders for that route) to `status.Probes` and `status.BoxMetrics`, stamped on the status tick that already stamps `Release` and `RouteVersions`.

**Tech Stack:** Go 1.26, `internal/mail` (senders, walks, domains, claims), `internal/appconfig` (plan 4's instance layer and `ConfigAck`), `internal/provision` (`InstanceID`, `GetRecord`, `ValidHostShape`), `internal/registry` (route rows), `internal/caps` (the host_role render rule), `internal/status` (the box record), `internal/host` (the agent's status tick), `internal/console` (the email and instance door families), `internal/svcrole`, root-package `mail_cli.go`.

**Spec:** `docs/superpowers/specs/2026-09-15-tenant-email-senders-design.md` — §11 step 5 is the whole mandate: §6.3 (entering and leaving per-instance mode), §6.4 (the doors, the uid check, the domain attach), and the parts of §8 that key caps, pauses, counts and status to an instance sender. §5 landed in plan 2, §7 in plan 3, §6.2 and §6.5 in plan 4. §10 (the relay), §12's `slices.Compact` policy-size work and §13 are out of scope. §11 step 6 (per-instance machine tokens) is explicitly **not** this plan: the instance doors are `Allow(svcrole.Mail)` and the caller is titogo's hub, not the customer's own code.

**Four rulings, taken before the tasks and not to be reopened during them.**

1. **`claimDomain` refuses a same-account re-claim by a different `(app, sender)`, and that ends cross-app domain sharing inside one account.** Today `claimDomain` returns success for any re-claim by the holder's own account (`domainclaim.go`, the `cur.Account != acct` branch), and `EnsureDomain` then appends the second app to `rec.Apps` — so two apps in one account can legitimately send from one domain, and some do. §6.4 requires the refusal anyway, because without it one instance sender attaches a sibling instance's already-verified domain and inherits its identity with no proof of its own, which is the cross-account hole §4a closed reappearing one level down. The refusal is therefore literal: a claim held by `(app A, sender app)` refuses `(app B, sender app)` with `ErrDomainHeldBySender`. **This is a behaviour change with a member-visible consequence and it belongs in the roll note** (task 13): an account that wants two apps on one domain needs a second domain, or a per-domain grant this design does not build. Every domain already claimed keeps working — the refusal only ever fires on a *new* attach.

2. **Both enable refusals read the box record, and the box record gains the two fields that make them answerable.** §6.5 says the delivery check "reads the box record's agent version, so its refusal names the box and the version rather than guessing" from an absent ack. The box record has two version-shaped fields and neither answers the question: `BoxMetrics.Release` is `version.Build`, a git short sha (`internal/version/version.go`, `Makefile`'s `LDFLAGS`), so no comparison is definable over it; `BoxMetrics.System.Version` is the semver of the last *self-update*, absent entirely on a box rolled by hand with `update-platform-binary.sh`. The uid check has a worse problem: the facts it needs — `registry.Route.Backing` and the operator's `caps` grant column — live in a box's own SQLite registry, and a console reads only the registry of the box it runs on (`internal/console/view.go`'s `openRegistry`, `apiroutes.go`'s 503 when `s.cfg.Registry == ""`), so a console cannot answer it for an instance on any other box. Both are therefore answered the only honest way: **the box says**. Task 7 adds `Probes.ConfigSchema`/`BoxMetrics.ConfigSchema` (stamped from `appconfig.ConfigAckSchema`) and `Probes.RouteUIDs`/`BoxMetrics.RouteUIDs` (host -> `"dynamic"`, `"app"` or `"agent"`), both on the status tick that already stamps `Release` and `RouteVersions`, both bucket-private in `Metrics` exactly as `RouteVersions` is and for the same reason (it is hostname-keyed and `BuildFleet` never copies `Metrics` into the public feed). Both are present on every box within one heartbeat of a roll, both let the refusal name the box, and `status.BoxStale` decides a silent box.

  **Acks stay change-only, and absence means "not yet delivered".** §6.5 records why the every-pass ack was refused: it is a Get and a Put per converged route per pass on every box, and on a fleet-door box every one of those writes would fail, because `control/config-ack/` is outside the door's read and write scopes (`internal/edge/storedoor.go:389`, `:489`). So plan 4 gives this plan `ConfigAck.Schema` and `ConfigAck.InstanceConfigETag` written when a delivery changes, `LatestConfigAck`, and `InstanceConfigDelivery` returning `InstanceConfigDelivered`, `InstanceConfigStale`, `InstanceConfigNotYetDelivered` or `InstanceConfigUnknownAgent`. Delivery is still decidable where it matters, because writing an instance's credential always changes that instance's file and so always produces an ack. A missing ack is never read as "old agent" — the box record answers that — and **an instance on a fleet-door box can be enabled but its delivery reads as unconfirmed forever**, which task 13 puts in the roll note and which the door grant is its own piece of work.

3. **`EnsureDomain`, `Teardown`, `RefreshPolicies` and `grantedDomains` are widened by delegation, not by changing every caller.** `EnsureDomain(ctx, s, p, w, d, acct, app, domain, now)` and `Teardown(ctx, s, p, w, d, acct, app)` keep their exact signatures and become one-line wrappers over `EnsureDomainForSender` / `TeardownSender` with `SenderAppID`. `grantedDomains` and `RefreshPolicies` do change shape, because every one of their callers is inside `internal/mail` and the console, and a sender-blind version of either is the #271 failure waiting to happen.

4. **The uid check has exactly ONE passing shape today, because the isolated-exec layout does not give a per-instance uid.** §6.4 names two: `DynamicUser=yes` with no host_role grant, or "an exec route under an isolated-exec layout with a per-instance uid". The second does not exist in this tree. `internal/host/units/carlos-instance@.service` sets `User=carlos`/`Group=carlos`, as do the seed and sidecar templates; the isolated-exec design says so deliberately under "Why nested and not per-instance uids" (`docs/superpowers/specs/2026-09-11-isolated-exec-design.md`), which records that per-instance uids need a uid allocator and a root chown per migration and leaves it as later hardening, and §12 of the same document records that the agent cannot do it without `CAP_SETUID`. Nesting buys a mount namespace, which hides a sibling's *files* but not a sibling's *uid*: every nested instance shares uid `carlos` with every other nested instance and with the agent. So the uid verdict this plan builds has three values and only `"dynamic"` passes. **Do not add a nested-layout branch to make the second shape pass** — it would assert a property the units do not have, and `internal/edge/purge.go` already documents the exact gap. File the per-instance-uid follow-up as an amadan discussion on `carlos/platform`, never a GitHub issue.

## Global Constraints

- **Run `gofmt -w` on every file you touched before running the gate.** `gofmt -l .` only NAMES an unformatted file; it fixes nothing. Several blocks below are pasted with aligned trailing comments (task 1's `Sender` field writes, task 2's `SenderRefs` table test, task 7's `BoxMetrics` field) that gofmt will realign, so a plan followed literally goes red on the gate's last step unless `gofmt -w` ran first.
- **The gate before every commit:** `go build ./... && go vet ./... && go test ./... -count=1` all clean, and `gofmt -l .` empty over the whole tree. Env: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`. Go may need `GOCACHE` pointed somewhere writable — "read-only file system" from the toolchain is that tell, and the Bash sandbox may need disabling for the Go commands alone.
- **Socket-binding tests need `TMPDIR=/tmp/g`.** `internal/host` and `internal/console` both have tests that bind unix sockets, and the default sandbox `TMPDIR` produces a path past `sockaddr_un`'s 107 bytes. Task 7 touches `internal/host`, so run that package's tests as `TMPDIR=/tmp/g go test ./internal/host/ -count=1`.
- **This repo lands through `amadan branch merge`, never a GitHub PR.** Branch `worktree-tenant-email-senders`. CI is `.amadan/ci.d/`, triggered by a push to the `amadan` remote; a branch pushed only to `origin` has been gated by nothing. Do not run `gh pr create` and do not file a GitHub issue — follow-ups are amadan discussions on `carlos/platform`.
- **Every line number in this plan is PRE-plans-2-through-4.** Plans 2, 3 and 4 land before this one and move every file this plan touches. Treat every `file.go:NNN` below as provenance for where a thing was when this plan was written, and **navigate by function name**, never by line. Where a plan-2/3/4 name is consumed, it is named in full in the task's Consumes block.
- **Cross-compile gate.** CI builds darwin and windows as well as linux. Nothing in this plan should reach for a linux-only syscall, but if a task ends up in `internal/host` needing one, it needs a `//go:build linux` file and a `!linux` stub beside it.
- **No new capability for anyone who does not ask for it.** Every task must leave the tree shippable on its own: an app sending today sends after each commit, with the same credential, the same cap, the same delivered variables and the same doors. The deliberate behaviour changes are ruling 1's claim refusal (task 3) and the app sender's credential being reaped on entry to per-instance mode (task 4) — both are roll notes, and both are reachable only by an account that asks for an instance sender.
- **Wire-shape changes are additive.** Live `DomainRecord`, `DomainClaim`, `Sender` and `Box` objects exist in production buckets and must still parse after every task. `Senders`, `Sender` (on the claim) and `ConfigSchema` are all `omitempty`, and a record that carries none of them reads exactly as it reads today.
- **Copy:** no em dashes. Full stop and a new sentence, lower case. Comments are full sentences saying why, never process history; log lines use `->` between values.
- **Tasks 9, 10, 11 and 12 add member-facing strings** (door refusals, CLI status lines). Run the `copy-review` skill on the new strings BEFORE they land in a file, and apply the user's edits verbatim. Count each string's occurrences at the base commit first: a refusal reused by two doors is one review, not two.
- Commit after every task with the trailer `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`.

---

## File Structure

| File | Responsibility |
|---|---|
| `internal/mail/instance.go` (create) | `InstanceSenderID`, `DeclareInstance`, `EnterPerInstance`, `LeavePerInstance`, `EnableInstance`, `RotateInstance`, `TeardownInstance`, `EnvDeliveredInstance`, `writeSMTPEnvInstance` |
| `internal/mail/instance_test.go` (create) | the instance sender's record and names, the transition's one CAS, the reap-not-deactivate rule, the enable race, delivery into the layer only |
| `internal/mail/domain.go` (modify) | `DomainRecord.Senders`, `SenderRef`, `SenderRefs`, `AddSenderRef`, `RemoveSenderRef` |
| `internal/mail/domainclaim.go` (modify) | `DomainClaim.Sender`; `claimDomain` takes a sender and refuses a different `(app, sender)`; `ErrDomainHeldBySender` |
| `internal/mail/walk.go` (modify) | `grantedDomains` and `RefreshPolicies` walk sender refs; `EnsureDomainForSender`; `TeardownSender`; `Enable` refuses under `PerInstance` |
| `internal/status/snapshot.go` (modify) | `Probes.ConfigSchema`/`RouteUIDs`, `BoxMetrics.ConfigSchema`/`RouteUIDs`, both carried through `CollectBox` |
| `internal/host/agent.go` (modify) | the status tick stamps `ConfigSchema` from `appconfig.ConfigAckSchema` and `RouteUIDs` from `routeUIDShape` |
| `internal/host/metrics_loop.go` (modify) | `routeUIDShape(rt registry.Route) string`, the one place the uid verdict is computed |
| `internal/console/apimailinstance.go` (create) | `emailInstanceGate`, `instanceBoxFacts`, `instanceOwnUID`, `instanceLayerAgent`, the seven handlers, `registerEmailInstanceRoutes` |
| `internal/console/apimailinstance_test.go` (create) | the two refusals, the door list, a stranger's 404, the transition through the door |
| `internal/console/apimail.go` (modify) | `mailAppDomains` walks sender refs; `emailReply`'s `senders` array carries instance senders; the status door resumes every sender; the pause door's `instance` field |
| `internal/console/svcroutes_test.go` (modify) | the seven doors join `TestMailRoleReachesExactlyTheEmailDoors`'s `want` |
| `mail_cli.go` (modify) | `apiEmailSender` gains its instance fields; `carlos email status --instance`; a line per instance sender |
| `README.md` (modify) | the roll note: the claim refusal, the app credential reap, the ConfigSchema heartbeat |

---

### Task 1: The instance sender's record

**Files:**
- Create: `internal/mail/instance.go`, `internal/mail/instance_test.go`

**Interfaces:**
- Consumes: plan 2's `Sender`, `ScopeInstance`, `SourceDomain`, `SenderAppID`, `ValidSenderID`, `SenderKey`, `GetSender`, `PutSender`, `ListSenders`, `SenderNames`, `SenderNameSet` (`internal/mail/sender.go`); `Declared` (`mail.go`), `ValidEnvPrefix`/`DefaultEnvPrefix` (`envnames.go`), `appconfig.ValidVarValue` (`internal/appconfig/materialize.go:23`), `release.ValidAccount`/`ValidApp`.
- Produces:
  ```go
  func InstanceSenderID(host string) string // == provision.InstanceID(host), spelled here so internal/mail never imports internal/provision
  func DeclareInstance(ctx context.Context, s release.Store, acct, app, senderID, host, envPrefix, from, actor string, now time.Time) (*Sender, error)
  func InstanceSenders(ctx context.Context, s release.Store, acct, app string) ([]*Sender, error)
  var ErrNotInstanceSender = errors.New("mail: that sender is not an instance sender")
  var ErrInstanceHostMoved = errors.New("mail: an instance sender's host cannot change")
  ```

- [ ] **Step 1: Write the failing test**

`internal/mail/instance_test.go` (create):

```go
package mail

import (
	"context"
	"errors"
	"testing"
	"time"

	"github.com/carlosframework/platform/internal/release"
)

// An instance sender is declared the way an app sender is, and carries the
// three things an app sender never has: a scope, a host and a slug. The slug
// is computed once, here, because nothing can parse a hash back and the
// caller index (plan 2) is keyed on the name the slug produces.
func TestDeclareInstanceWritesItsOwnSenderWithASlug(t *testing.T) {
	ctx := context.Background()
	s := release.NewDirStore(t.TempDir())
	now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
	const host = "book.example.com"

	sn, err := DeclareInstance(ctx, s, "bag", "harbourmail", InstanceSenderID(host), host,
		"HARBOURMAIL_SMTP", "hello@book.example.com", "owner@example.test", now)
	if err != nil {
		t.Fatal(err)
	}
	if sn.Scope != ScopeInstance || sn.Source != SourceDomain || sn.Host != host {
		t.Fatalf("declared sender is not an instance sender: %+v", sn)
	}
	if sn.ID != host {
		t.Fatalf("sender id = %q, want the instance id of %q", sn.ID, host)
	}
	if sn.Slug == "" {
		t.Fatal("an instance sender was declared with no slug, so nothing can name its IAM user")
	}
	// It lives under its own key, so two instances declaring at once never
	// contend on one etag.
	back, _, err := GetSender(ctx, s, "bag", "harbourmail", sn.ID)
	if err != nil {
		t.Fatal(err)
	}
	if back == nil || back.Slug != sn.Slug || back.Declared.From != "hello@book.example.com" {
		t.Fatalf("round trip lost the sender: %+v", back)
	}
	// The app sender is untouched by an instance declaration.
	app, _, err := GetSender(ctx, s, "bag", "harbourmail", SenderAppID)
	if err != nil {
		t.Fatal(err)
	}
	if app != nil {
		t.Fatalf("declaring an instance sender wrote the app sender: %+v", app)
	}
}

// The slug is stable across a re-declare, because it is the IAM user name
// and the tenant name. Recomputing it on every declare would strand a
// principal under the old name on every From change.
func TestDeclareInstanceKeepsTheSlugAndRefusesAHostMove(t *testing.T) {
	ctx := context.Background()
	s := release.NewDirStore(t.TempDir())
	now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
	const host = "book.example.com"
	id := InstanceSenderID(host)

	first, err := DeclareInstance(ctx, s, "bag", "harbourmail", id, host,
		"HARBOURMAIL_SMTP", "hello@book.example.com", "owner@example.test", now)
	if err != nil {
		t.Fatal(err)
	}
	second, err := DeclareInstance(ctx, s, "bag", "harbourmail", id, host,
		"HARBOURMAIL_SMTP", "sales@book.example.com", "owner@example.test", now)
	if err != nil {
		t.Fatal(err)
	}
	if second.Slug != first.Slug {
		t.Fatalf("re-declare moved the slug %q -> %q, stranding the IAM user", first.Slug, second.Slug)
	}
	if _, err := DeclareInstance(ctx, s, "bag", "harbourmail", id, "other.example.com",
		"HARBOURMAIL_SMTP", "hello@other.example.com", "owner@example.test", now); !errors.Is(err, ErrInstanceHostMoved) {
		t.Fatalf("re-declare under a different host = %v, want ErrInstanceHostMoved", err)
	}
}

// InstanceSenders is every instance sender of an app and never the app
// sender, which is what the status reply and the teardown door both walk.
func TestInstanceSendersExcludesTheAppSender(t *testing.T) {
	ctx := context.Background()
	s := release.NewDirStore(t.TempDir())
	now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
	if _, err := PutSender(ctx, s, "bag", "harbourmail", &Sender{
		ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain,
		Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "x@y.com", At: now, By: "o"},
	}, ""); err != nil {
		t.Fatal(err)
	}
	for _, host := range []string{"book.example.com", "shop.example.com"} {
		if _, err := DeclareInstance(ctx, s, "bag", "harbourmail", InstanceSenderID(host), host,
			"HARBOURMAIL_SMTP", "hello@"+host, "owner@example.test", now); err != nil {
			t.Fatal(err)
		}
	}
	got, err := InstanceSenders(ctx, s, "bag", "harbourmail")
	if err != nil {
		t.Fatal(err)
	}
	if len(got) != 2 || got[0].ID != "book.example.com" || got[1].ID != "shop.example.com" {
		t.Fatalf("InstanceSenders = %+v, want the two instance senders sorted by id", got)
	}
}
```

- [ ] **Step 2: Run the test — expect failure**

```bash
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run TestDeclareInstance -count=1
```

Expected: build failure, `undefined: InstanceSenderID`, `undefined: DeclareInstance`, `undefined: InstanceSenders`, `undefined: ErrInstanceHostMoved`.

- [ ] **Step 3: Implement**

`internal/mail/instance.go` (create):

```go
// Instance senders (design §6.3, §6.4): one instance of an app with its own
// From address, its own IAM principal, its own tenant, its own cap and its
// own counts, delivered through the per-instance config layer and never
// through the bundle every sibling of that instance reads.
package mail

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"sort"
	"strings"
	"time"

	"github.com/carlosframework/platform/internal/appconfig"
	"github.com/carlosframework/platform/internal/release"
)

var (
	// ErrNotInstanceSender is an instance verb aimed at the app sender, or
	// at a sender some other scope owns. The verbs in this file edit one
	// instance's estate and must never reach the app's.
	ErrNotInstanceSender = errors.New("mail: that sender is not an instance sender")
	// ErrInstanceHostMoved is a re-declare naming a different host for an
	// id that already exists. The id is derived from the host, so this can
	// only mean a hash collision between two long hosts, and treating it as
	// the same sender would deliver one instance's credential to another.
	ErrInstanceHostMoved = errors.New("mail: an instance sender's host cannot change")
)

// maxInstanceID mirrors provision.MaxInstanceID. InstanceSenderID is spelled
// here rather than delegating so internal/mail keeps its three imports:
// internal/provision pulls in internal/registry and internal/controlname,
// and a mail package that imported the provisioning layer would be a
// dependency edge in the wrong direction (the console composes the two, and
// nothing in internal/mail knows what an instance record is).
//
// The two implementations are pinned against each other by a test in
// internal/console, where both packages are already in scope.
const maxInstanceID = 40

// InstanceSenderID is the sender id for a host: the host itself when it is
// short enough to be its own identifier, and the same bounded hash form
// provision.InstanceID produces otherwise. The underscore prefix is what
// keeps the two forms in one namespace: no valid host contains one.
func InstanceSenderID(host string) string {
	if len(host) <= maxInstanceID {
		return host
	}
	sum := sha256.Sum256([]byte(host))
	return "_" + host[:22] + "-" + hex.EncodeToString(sum[:8])
}

// DeclareInstance records one instance's half: the env prefix it wants its
// variables under and the From address it sends as. It is Declare's
// counterpart, and it refuses the same things Declare refuses, for the same
// reasons: the prefix is locked once delivery has happened, and the From
// address is validated before anything is spent because it is delivered
// verbatim and an unrenderable byte caught later would strand a key.
//
// The SLUG is computed once, here, and never again. It is the second half of
// this sender's IAM user name and the whole of its tenant name, so
// recomputing it on a later declare would leave a live principal under a
// name no record points at. Plan 2's ResolveSenderNames owns the collision
// case, at enable, where the caller index it writes can see one.
func DeclareInstance(ctx context.Context, s release.Store, acct, app, senderID, host, envPrefix, from, actor string,
	now time.Time) (*Sender, error) {

	if !release.ValidAccount(acct) {
		return nil, fmt.Errorf("mail: invalid account %q", acct)
	}
	if !release.ValidApp(app) {
		return nil, fmt.Errorf("mail: invalid app %q", app)
	}
	if !ValidSenderID(senderID) || senderID == SenderAppID {
		return nil, fmt.Errorf("mail: invalid instance sender id %q", senderID)
	}
	if host == "" || InstanceSenderID(host) != senderID {
		return nil, fmt.Errorf("mail: sender id %q does not name host %q", senderID, host)
	}
	if envPrefix == "" {
		envPrefix = DefaultEnvPrefix
	}
	if !ValidEnvPrefix(envPrefix) {
		return nil, fmt.Errorf("mail: invalid env prefix %q — want ^[A-Z][A-Z0-9_]{0,31}$", envPrefix)
	}
	if !strings.Contains(from, "@") {
		return nil, fmt.Errorf("mail: From %q is not an address", from)
	}
	if err := appconfig.ValidVarValue(from); err != nil {
		return nil, fmt.Errorf("mail: From %q is not EnvironmentFile-safe: %w", from, err)
	}

	sn, etag, err := GetSender(ctx, s, acct, app, senderID)
	if err != nil {
		return nil, err
	}
	if sn == nil {
		names, err := SenderNames(acct, app, senderID, "")
		if err != nil {
			return nil, err
		}
		sn = &Sender{ID: senderID, Slug: names.Slug, Scope: ScopeInstance, Source: SourceDomain, Host: host}
	}
	if sn.Scope != ScopeInstance {
		return nil, ErrNotInstanceSender
	}
	if sn.Host != host {
		return nil, fmt.Errorf("mail: sender %q is host %q: %w", senderID, sn.Host, ErrInstanceHostMoved)
	}
	if sn.Declared != nil && sn.Provisioned != nil && sn.Declared.EnvPrefix != envPrefix {
		return nil, fmt.Errorf("mail: env prefix is %q and cannot move after delivery", sn.Declared.EnvPrefix)
	}
	sn.Declared = &Declared{EnvPrefix: envPrefix, From: from, At: now, By: actor}
	if _, err := PutSender(ctx, s, acct, app, sn, etag); err != nil {
		return nil, err
	}
	return sn, nil
}

// InstanceSenders is every instance sender an app has, sorted by id, and
// never the app sender. The teardown door walks it to decide whether the
// one it just removed was the last, and the status reply walks it to print
// a line each.
func InstanceSenders(ctx context.Context, s release.Store, acct, app string) ([]*Sender, error) {
	all, err := ListSenders(ctx, s, acct, app)
	if err != nil {
		return nil, err
	}
	var out []*Sender
	for _, sn := range all {
		if sn != nil && sn.Scope == ScopeInstance {
			out = append(out, sn)
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
	return out, nil
}
```

- [ ] **Step 4: Run the test — expect success**

```bash
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1
```

- [ ] **Step 5: Gate and commit**

```bash
gofmt -w internal/mail/instance.go internal/mail/instance_test.go
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
gofmt -l .
git add internal/mail/instance.go internal/mail/instance_test.go
git commit -m "mail: declare an instance sender, with its own slug and key (plan 5, task 1)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 2: A domain record names senders, and the two policy walks follow them

**Files:**
- Modify: `internal/mail/domain.go` (`DomainRecord` struct, `AddApp`, `RemoveApp`), `internal/mail/walk.go` (`grantedDomains`, `RefreshPolicies`, and their callers in `EnsureDomain`, `VerifyDomain`, `Enable`, `CreateStandalone`), `internal/mail/instance.go` (`senderNamesOf`), `internal/console/apimail.go` (`mailAppDomains`), `internal/mail/domain_test.go`, `internal/mail/walk_lifecycle_test.go`

**Interfaces:**
- Consumes: plan 2's `SenderNames`, `SenderNameSet`, `GetSender`, `Sender.Salt`, `SenderAppID`; `UserName`/`StandaloneUserName`/`UserPath` (`policy.go`), `VerifiedARNs`/`VerifiedConfigSetARNs` (`domain.go:144`, `:164`), `Provisioner.EnsureUser`.
- Produces:
  ```go
  // internal/mail/domain.go
  // DomainRecord gains: Senders []string `json:"senders,omitempty"`
  func SenderRef(app, sender string) string                     // "<app>/<sender>"
  func SplitSenderRef(ref string) (app, sender string, ok bool)
  func (r *DomainRecord) SenderRefs() []string                  // Senders, plus one app-sender ref per legacy Apps entry
  func (r *DomainRecord) HasSender(app, sender string) bool
  func (r *DomainRecord) AddSenderRef(app, sender string) bool
  func (r *DomainRecord) RemoveSenderRef(app, sender string) bool
  // internal/mail/walk.go
  func grantedDomains(ctx context.Context, s release.Store, acct, app, sender string) ([]*DomainRecord, error) // unexported, gains sender
  func RefreshPolicies(ctx context.Context, s release.Store, p Provisioner, acct string, refs []string) error  // was apps []string
  // internal/mail/instance.go
  func senderNamesOf(acct, app string, sn *Sender) (SenderNameSet, error) // unexported
  // internal/console/apimail.go
  func (s *Server) mailAppDomains(ctx context.Context, acct *Account, app string) ([]*mail.DomainRecord, error) // now filters on the APP sender's ref
  ```

- [ ] **Step 1: Write the failing test**

`internal/mail/domain_test.go` (append; create in package `mail` if it does not exist):

```go
// A record written before senders existed carries Apps and no Senders, and
// must read as one app-sender reference per app. Without that, the first
// RefreshPolicies after this release would find no principals for every
// domain in the fleet and silently refresh nothing.
func TestSenderRefsReadsALegacyRecordsAppsAsAppSenders(t *testing.T) {
	rec := &DomainRecord{Domain: "x.com", Apps: []string{"harbourmail", "keymail"}}
	want := []string{"harbourmail/app", "keymail/app"}
	if got := rec.SenderRefs(); !reflect.DeepEqual(got, want) {
		t.Fatalf("SenderRefs() = %q, want %q", got, want)
	}
	if !rec.HasSender("harbourmail", SenderAppID) || rec.HasSender("harbourmail", "book.example.com") {
		t.Fatal("a legacy record answered HasSender for something other than its app senders")
	}
}

// An instance sender joins Senders and NEVER joins Apps. Apps is what the
// app sender's own policy is computed from (grantedDomains), so a customer
// domain reaching it would put that customer's identity in the app-wide
// credential every sibling instance holds.
func TestAddSenderRefForAnInstanceNeverJoinsApps(t *testing.T) {
	rec := &DomainRecord{Domain: "book.example.com"}
	if !rec.AddSenderRef("harbourmail", "book.example.com") {
		t.Fatal("AddSenderRef reported no change on a fresh record")
	}
	if len(rec.Apps) != 0 {
		t.Fatalf("an instance sender's attach added to Apps: %q", rec.Apps)
	}
	if !rec.HasSender("harbourmail", "book.example.com") || rec.HasSender("harbourmail", SenderAppID) {
		t.Fatalf("HasSender is wrong after the attach: %+v", rec)
	}
	// Idempotent, and the union with a legacy Apps entry is deduplicated.
	rec.Apps = []string{"harbourmail"}
	if rec.AddSenderRef("harbourmail", "book.example.com") {
		t.Fatal("a second AddSenderRef reported a change")
	}
	want := []string{"harbourmail/app", "harbourmail/book.example.com"}
	if got := rec.SenderRefs(); !reflect.DeepEqual(got, want) {
		t.Fatalf("SenderRefs() = %q, want %q", got, want)
	}
	if !rec.RemoveSenderRef("harbourmail", "book.example.com") || rec.HasSender("harbourmail", "book.example.com") {
		t.Fatalf("RemoveSenderRef left the reference behind: %+v", rec)
	}
}
```

`internal/mail/walk_lifecycle_test.go` (append):

```go
// THE #271 FAILURE, REACHED THROUGH THE NEW DOOR. VerifyDomain rebinds an
// identity's configuration set and then refreshes every policy that names
// the domain, from the record. Walking rec.Apps would find nothing for a
// domain only an instance sender holds, and the instance's policy would go
// on naming no configuration set: a 554 on every send it makes.
func TestRefreshPoliciesReachesASenderThatIsNotInApps(t *testing.T) {
	ctx, s, p, _, now := enableFixture(t)
	rec := &DomainRecord{
		Domain: "book.example.com", Kind: KindCustom,
		Regions:   map[string]*RegionState{"eu-west-1": {IdentityARN: "arn:aws:ses:eu-west-1:1:identity/book.example.com", ConfigSet: "carlos-bag", Status: StatusVerified}},
		CreatedAt: now,
	}
	rec.AddSenderRef("harbourmail", "book.example.com")
	if _, err := PutDomain(ctx, s, "bag", rec, ""); err != nil {
		t.Fatal(err)
	}
	if _, err := DeclareInstance(ctx, s, "bag", "harbourmail", "book.example.com", "book.example.com",
		"HARBOURMAIL_SMTP", "hello@book.example.com", "owner@example.test", now); err != nil {
		t.Fatal(err)
	}
	sn, etag, err := GetSender(ctx, s, "bag", "harbourmail", "book.example.com")
	if err != nil {
		t.Fatal(err)
	}
	names, err := senderNamesOf("bag", "harbourmail", sn)
	if err != nil {
		t.Fatal(err)
	}
	sn.Provisioned = &Provisioned{IAMUser: names.IAMUser, KeyID: "AKIA9", Active: true, At: now}
	if _, err := PutSender(ctx, s, "bag", "harbourmail", sn, etag); err != nil {
		t.Fatal(err)
	}

	if err := RefreshPolicies(ctx, s, p, "bag", rec.SenderRefs()); err != nil {
		t.Fatal(err)
	}
	pol, ok := p.UserPolicy(names.IAMUser)
	if !ok {
		t.Fatalf("no policy written for %s; the instance sender was never refreshed", names.IAMUser)
	}
	if !strings.Contains(pol, "configuration-set/carlos-bag") {
		t.Fatalf("policy for %s names no configuration set, which is a 554 on every send:\n%s", names.IAMUser, pol)
	}
}

// The app sender's policy can never name a domain only an instance holds.
// grantedDomains is the one function that decides this, and it now filters
// on the sender rather than on the app.
func TestGrantedDomainsKeepsAnInstancesDomainOutOfTheAppSendersPolicy(t *testing.T) {
	ctx, s, _, _, now := enableFixture(t)
	rec := &DomainRecord{
		Domain: "book.example.com", Kind: KindCustom,
		Regions:   map[string]*RegionState{"eu-west-1": {IdentityARN: "arn:aws:ses:eu-west-1:1:identity/book.example.com", Status: StatusVerified}},
		CreatedAt: now,
	}
	rec.AddSenderRef("harbourmail", "book.example.com")
	if _, err := PutDomain(ctx, s, "bag", rec, ""); err != nil {
		t.Fatal(err)
	}
	got, err := grantedDomains(ctx, s, "bag", "harbourmail", SenderAppID)
	if err != nil {
		t.Fatal(err)
	}
	for _, d := range got {
		if d.Domain == "book.example.com" {
			t.Fatal("the app sender was granted a domain only an instance sender holds")
		}
	}
	got, err = grantedDomains(ctx, s, "bag", "harbourmail", "book.example.com")
	if err != nil || len(got) != 1 || got[0].Domain != "book.example.com" {
		t.Fatalf("the instance sender was not granted its own domain: %+v %v", got, err)
	}
}
```

`MemProvisioner` needs a policy reader for the first test. If `UserPolicy(name string) (string, bool)` does not already exist on it (plan 3 leans on the same accessor for its address-policy tests), add it in `internal/mail/memprovisioner.go` returning the last policy `EnsureUser` rendered for that user.

- [ ] **Step 2: Run the test — expect failure**

```bash
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run 'TestSenderRefs|TestAddSenderRef|TestRefreshPoliciesReaches|TestGrantedDomainsKeeps' -count=1
```

Expected: build failure, `undefined: SenderRef`, `rec.SenderRefs undefined`, and `too many arguments in call to grantedDomains`.

- [ ] **Step 3: Implement**

`internal/mail/domain.go`, on `DomainRecord`, beside `Apps`:

```go
	// Senders is every sender authorised to send from this domain, as
	// "<app>/<sender>" references (design §6.3). It exists because Apps
	// cannot express one: an instance sender is one of many inside an app,
	// and an app-shaped grant would hand the app-wide credential every
	// sibling instance holds an identity that belongs to one customer.
	//
	// omitempty, and absent on every record written before this release —
	// SenderRefs reads such a record's Apps as one app-sender reference
	// each, so nothing needs backfilling and a rolled-back console reads
	// what it always read.
	Senders []string `json:"senders,omitempty"`
```

and, after `RemoveApp`:

```go
// SenderRef is how a sender is named inside a domain record: the app, a
// slash, the sender id. The slash is safe as a separator because ValidApp
// forbids one and ValidSenderID forbids one.
func SenderRef(app, sender string) string { return app + "/" + sender }

// SplitSenderRef is SenderRef's inverse. ok is false for anything that is
// not exactly one slash with a non-empty side each — a malformed reference
// is skipped by every caller rather than guessed at, because guessing would
// write a policy for a principal nobody named.
func SplitSenderRef(ref string) (string, string, bool) {
	app, sender, found := strings.Cut(ref, "/")
	if !found || app == "" || sender == "" || strings.Contains(sender, "/") {
		return "", "", false
	}
	return app, sender, true
}

// SenderRefs is every sender this domain grants, sorted and deduplicated,
// with each legacy Apps entry read as that app's APP sender. That fallback
// is the whole migration: nothing rewrites a domain record, and the first
// RefreshPolicies after this release must still find every principal it
// found before it.
func (r *DomainRecord) SenderRefs() []string {
	seen := map[string]bool{}
	var out []string
	add := func(ref string) {
		if _, _, ok := SplitSenderRef(ref); !ok || seen[ref] {
			return
		}
		seen[ref] = true
		out = append(out, ref)
	}
	for _, a := range r.Apps {
		add(SenderRef(a, SenderAppID))
	}
	for _, ref := range r.Senders {
		add(ref)
	}
	sort.Strings(out)
	return out
}

// HasSender reports whether this domain grants one sender.
func (r *DomainRecord) HasSender(app, sender string) bool {
	want := SenderRef(app, sender)
	for _, ref := range r.SenderRefs() {
		if ref == want {
			return true
		}
	}
	return false
}

// AddSenderRef records a sender as a referrer. Reports whether it changed
// anything. It NEVER touches Apps: an app sender's attach calls AddApp too
// (EnsureDomainForSender does both), and an instance sender's attach must
// not, or grantedDomains would hand the app-wide credential a customer's
// identity.
func (r *DomainRecord) AddSenderRef(app, sender string) bool {
	if r.HasSender(app, sender) {
		return false
	}
	r.Senders = append(r.Senders, SenderRef(app, sender))
	sort.Strings(r.Senders)
	return true
}

// RemoveSenderRef drops a referrer from Senders. An app sender recorded only
// in the legacy Apps list is removed by RemoveApp, which is what
// TeardownSender calls for it.
func (r *DomainRecord) RemoveSenderRef(app, sender string) bool {
	want := SenderRef(app, sender)
	for i, ref := range r.Senders {
		if ref == want {
			r.Senders = append(r.Senders[:i], r.Senders[i+1:]...)
			return true
		}
	}
	return false
}
```

`internal/mail/instance.go`, append:

```go
// senderNamesOf recomputes one sender's AWS names from the record, and
// refuses when the computed slug disagrees with the stored one.
//
// Recomputing rather than reading Provisioned.IAMUser is the discipline
// RefreshPolicies already follows for standalone credentials, and the
// refusal is what makes it safe: if the naming rule ever moved, the old
// behaviour would quietly write a policy for a brand new principal and
// leave the live one stale, which presents as "this sender stopped working
// and nothing changed". Failing loudly here turns that into one error with
// the two names in it.
func senderNamesOf(acct, app string, sn *Sender) (SenderNameSet, error) {
	names, err := SenderNames(acct, app, sn.ID, sn.Salt)
	if err != nil {
		return SenderNameSet{}, err
	}
	if names.Slug != sn.Slug {
		return SenderNameSet{}, fmt.Errorf(
			"mail: sender %s/%s is recorded with slug %q and the naming rule now computes %q — refusing to write a policy for a principal no record names",
			app, sn.ID, sn.Slug, names.Slug)
	}
	return names, nil
}
```

`internal/mail/walk.go`, `grantedDomains`: take `sender string` after `app`, and replace the `for _, a := range d.Apps` loop with `if d.HasSender(app, sender) { out = append(out, d) }`. Its three callers pass a sender: `Enable` and `CreateStandalone` pass `SenderAppID`, and `RefreshPolicies` passes the sender it is iterating.

`internal/mail/walk.go`, `RefreshPolicies`: the parameter becomes `refs []string` and the body iterates sender references rather than apps.

```go
func RefreshPolicies(ctx context.Context, s release.Store, p Provisioner, acct string, refs []string) error {
	for _, ref := range refs {
		app, sender, ok := SplitSenderRef(ref)
		if !ok {
			// A malformed reference names no principal. Skipping is the
			// only safe direction: inventing an app or a sender from it
			// would write a policy for somebody who never asked for one.
			log.Printf("mail: refresh policies: skipping malformed sender reference %q", ref)
			continue
		}
		sn, _, err := GetSender(ctx, s, acct, app, sender)
		if err != nil {
			return err
		}
		if sn == nil || sn.Declared == nil {
			continue
		}
		if sn.Provisioned == nil && len(sn.Standalone) == 0 {
			continue
		}
		domains, err := grantedDomains(ctx, s, acct, app, sender)
		if err != nil {
			return err
		}
		if len(domains) == 0 {
			continue
		}
		var arns, configSets, names []string
		for _, dom := range domains {
			arns = append(arns, dom.VerifiedARNs()...)
			configSets = append(configSets, dom.VerifiedConfigSetARNs()...)
			names = append(names, dom.Domain)
		}
		sort.Strings(arns)
		sort.Strings(configSets)
		sort.Strings(names)
		if len(arns) == 0 {
			// Granted domains exist but none is verified in any region, so
			// SendPolicyJSON would refuse anyway. Nothing to write.
			continue
		}
		nameSet, err := senderNamesOf(acct, app, sn)
		if err != nil {
			return err
		}
		if sn.Provisioned != nil {
			if _, err := p.EnsureUser(ctx, nameSet.IAMUser, UserPath, arns, configSets, names); err != nil {
				return fmt.Errorf("mail: refresh policy for %s: %w", nameSet.IAMUser, err)
			}
		}
		for _, sa := range sn.Standalone {
			user, err := nameSet.StandaloneUser(acct, app, sa.Name)
			if err != nil {
				return err
			}
			if _, err := p.EnsureUser(ctx, user, UserPath, arns, configSets, names); err != nil {
				return fmt.Errorf("mail: refresh policy for %s: %w", user, err)
			}
		}
	}
	return nil
}
```

Add `"log"` to `walk.go`'s imports. `EnsureDomain`'s call becomes `RefreshPolicies(ctx, s, p, acct, rec.SenderRefs())` and `VerifyDomain`'s rebound branch the same.

**An address sender's principal (plan 3) is reached through the same path.** Plan 3's `ensureAddressPrincipal` calls `EnsureAddressUser`, not `EnsureUser`, so an address sender whose policy this loop rewrote with `EnsureUser` would lose its `ses:FromAddress` condition. Guard it: skip a sender whose `Source == SourceAddress` in this loop, with the comment saying its policy is `EnsureAddressUser`'s and is rewritten by that sender's own enable.

`internal/console/apimail.go`, `mailAppDomains`: replace the `for _, a := range d.Apps` loop with `if d.HasSender(app, mail.SenderAppID)`. Its doc comment gains the sentence that says why the app sender specifically: the enable door feeds this list to `ApplyGrant` for the app sender, and an instance sender's domain reaching it would put a customer's identity in the app-wide credential.

- [ ] **Step 4: Run the test — expect success**

```bash
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ ./internal/console/ -count=1
```

- [ ] **Step 5: Gate and commit**

```bash
gofmt -w internal/mail/domain.go internal/mail/walk.go internal/mail/instance.go internal/console/apimail.go internal/mail/domain_test.go internal/mail/walk_lifecycle_test.go
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
gofmt -l .
git commit -am "mail: a domain record names senders, and both policy walks follow them (plan 5, task 2)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 3: The claim carries the sender, and an instance never attaches a sibling's domain

**Files:**
- Modify: `internal/mail/domainclaim.go` (`DomainClaim`, `claimDomain`, `releaseDomainClaim`), `internal/mail/walk.go` (`EnsureDomain` -> `EnsureDomainForSender`, `Teardown` -> `TeardownSender`), `internal/mail/walk_domainclaim_test.go`

**Interfaces:**
- Consumes: `getDomainClaimETag` (`domainclaim.go:88`), `ErrDomainClaimed` (`:75`), `ListDomainClaims` (`:185`), `release.ErrCASConflict`, plan 3's `ExternalIdentityFor`/`ErrExternalIdentity` refusal that already sits before `claimDomain` in `EnsureDomain`, plan 2's `SenderAppID`, task 2's `AddSenderRef`/`RemoveSenderRef`/`SenderRefs`.
- Produces:
  ```go
  // DomainClaim gains: Sender string `json:"sender,omitempty"`
  var ErrDomainHeldBySender = errors.New("mail: domain is held by another sender in this account")
  func claimDomain(ctx context.Context, s release.Store, domain, acct, app, sender string, now time.Time) error // unexported, gains sender
  func EnsureDomainForSender(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer, d Deployment, acct, app, sender, domain string, now time.Time) (*DomainRecord, []dnsrec.Record, error)
  func EnsureDomain(...) // unchanged signature, delegates with SenderAppID
  func TeardownSender(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer, d Deployment, acct, app, sender string) error
  func Teardown(...) // unchanged signature, delegates with SenderAppID
  ```

- [ ] **Step 1: Write the failing test**

`internal/mail/walk_domainclaim_test.go` (append):

```go
// THE HOLE §6.4 CLOSES. Two instances of one app, in one account. The
// second attaches the first's already-verified domain, and today the claim
// says yes (same account, so the re-claim is a silent no-op) and the second
// instance inherits an identity it proved nothing about.
func TestClaimDomainRefusesAnotherSendersDomainInsideOneAccount(t *testing.T) {
	ctx := context.Background()
	s := release.NewDirStore(t.TempDir())
	now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)

	if err := claimDomain(ctx, s, "book.example.com", "bag", "harbourmail", "book.example.com", now); err != nil {
		t.Fatal(err)
	}
	// The same sender re-claiming is idempotent success: EnsureDomain is
	// re-run on every attach and must never be refused by the gate it holds.
	if err := claimDomain(ctx, s, "book.example.com", "bag", "harbourmail", "book.example.com", now); err != nil {
		t.Fatalf("a re-claim by the holder was refused: %v", err)
	}
	// A sibling instance of the same app, in the same account, is refused.
	if err := claimDomain(ctx, s, "book.example.com", "bag", "harbourmail", "shop.example.com", now); !errors.Is(err, ErrDomainHeldBySender) {
		t.Fatalf("a sibling instance's claim = %v, want ErrDomainHeldBySender", err)
	}
	// So is the app sender of the same app, and a second app in the account.
	if err := claimDomain(ctx, s, "book.example.com", "bag", "harbourmail", SenderAppID, now); !errors.Is(err, ErrDomainHeldBySender) {
		t.Fatalf("the app sender's claim = %v, want ErrDomainHeldBySender", err)
	}
	if err := claimDomain(ctx, s, "book.example.com", "bag", "keymail", SenderAppID, now); !errors.Is(err, ErrDomainHeldBySender) {
		t.Fatalf("a second app's claim = %v, want ErrDomainHeldBySender", err)
	}
	// Another account is still the neutral refusal that never names a holder.
	if err := claimDomain(ctx, s, "book.example.com", "jam", "jelly", SenderAppID, now); !errors.Is(err, ErrDomainClaimed) {
		t.Fatalf("a stranger's claim = %v, want ErrDomainClaimed", err)
	}
}

// A claim written before this release carries no Sender. It belongs to that
// app's APP sender, which is the only sender that existed, so a re-attach by
// that app keeps working and nothing in the fleet has to be backfilled.
func TestALegacyClaimBelongsToTheAppSender(t *testing.T) {
	ctx := context.Background()
	s := release.NewDirStore(t.TempDir())
	now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)

	body, err := json.Marshal(DomainClaim{Schema: 1, Domain: "x.com", Account: "bag", App: "harbourmail", CreatedAt: now})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.Put(ctx, domainClaimKey("x.com"), body, ""); err != nil {
		t.Fatal(err)
	}
	if err := claimDomain(ctx, s, "x.com", "bag", "harbourmail", SenderAppID, now); err != nil {
		t.Fatalf("a legacy claim refused its own app sender: %v", err)
	}
	if err := claimDomain(ctx, s, "x.com", "bag", "harbourmail", "book.example.com", now); !errors.Is(err, ErrDomainHeldBySender) {
		t.Fatalf("an instance sender took a legacy app claim: %v", err)
	}
}

// EnsureDomainForSender records an instance sender in Senders and never in
// Apps, and the orphan-claim sweep in TeardownSender only ever releases a
// claim THIS sender created.
func TestEnsureDomainForSenderRecordsTheSenderAndSweepsOnlyItsOwnOrphan(t *testing.T) {
	ctx, s, p, _, now := enableFixture(t)
	rec, _, err := EnsureDomainForSender(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(),
		"bag", "harbourmail", "book.example.com", "book.example.com", now)
	if err != nil {
		t.Fatal(err)
	}
	if len(rec.Apps) != 0 {
		t.Fatalf("an instance sender's attach joined Apps: %q", rec.Apps)
	}
	if !rec.HasSender("harbourmail", "book.example.com") {
		t.Fatalf("the sender was not recorded: %+v", rec)
	}

	// An orphan this sender never created survives its teardown.
	if err := claimDomain(ctx, s, "shop.example.com", "bag", "harbourmail", "shop.example.com", now); err != nil {
		t.Fatal(err)
	}
	if err := TeardownSender(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "bag", "harbourmail", "book.example.com"); err != nil {
		t.Fatal(err)
	}
	got, err := getDomainClaim(ctx, s, "shop.example.com")
	if err != nil || got == nil {
		t.Fatalf("a sibling sender's orphan claim was swept: %+v %v", got, err)
	}
	if got, err := getDomainClaim(ctx, s, "book.example.com"); err != nil || got != nil {
		t.Fatalf("this sender's own claim survived its teardown: %+v %v", got, err)
	}
}
```

- [ ] **Step 2: Run the test — expect failure**

```bash
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run 'TestClaimDomainRefuses|TestALegacyClaim|TestEnsureDomainForSender' -count=1
```

Expected: build failure, `not enough arguments in call to claimDomain`, `undefined: ErrDomainHeldBySender`, `undefined: EnsureDomainForSender`, `undefined: TeardownSender`.

- [ ] **Step 3: Implement**

`internal/mail/domainclaim.go`, on `DomainClaim`, after `App`:

```go
	// Sender is the sender that holds the claim, inside App. omitempty,
	// and absent on every claim written before this release: an empty
	// Sender reads as SenderAppID, because the app sender is the only
	// sender that existed when such a claim was written. Nothing is
	// backfilled and a rolled-back console reads what it always read.
	Sender string `json:"sender,omitempty"`
```

and a new error beside `ErrDomainClaimed`:

```go
// ErrDomainHeldBySender is a claim on a domain this ACCOUNT already holds,
// through a different sender. It names no holder either, for the same
// reason ErrDomainClaimed names none, and it is deliberately a different
// error so the console can say "another app or instance in this account"
// rather than the cross-account sentence, which would be a lie.
//
// This is a behaviour change (design §6.4): before it, any re-claim by the
// holder's own account was a silent no-op, which is how two apps in one
// account came to share a domain, and is also how one instance sender would
// have attached a sibling's already-verified identity with no proof of its
// own. Domains already attached keep working — this only ever fires on a new
// attach.
var ErrDomainHeldBySender = errors.New("mail: domain is held by another sender in this account")
```

`claimDomain` takes `sender string` after `app` and writes it into the body. Its judging tail becomes:

```go
		if cur.Account != acct {
			return ErrDomainClaimed
		}
		// An empty Sender on a stored claim is the app sender: that is the
		// only sender that existed when it was written.
		held := cur.Sender
		if held == "" {
			held = SenderAppID
		}
		if cur.App != app || held != sender {
			return ErrDomainHeldBySender
		}
		return nil // already ours: idempotent no-op
```

`internal/mail/walk.go`: rename `EnsureDomain`'s body to `EnsureDomainForSender` with `sender string` after `app`, and leave `EnsureDomain` as the app-sender wrapper. Inside it, four changes and nothing else:

```go
// EnsureDomain is EnsureDomainForSender for the app sender. Every caller
// outside this package still calls this, so the whole console and CLI are
// untouched by senders existing.
func EnsureDomain(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer,
	d Deployment, acct, app, domain string, now time.Time) (*DomainRecord, []dnsrec.Record, error) {
	return EnsureDomainForSender(ctx, s, p, w, d, acct, app, SenderAppID, domain, now)
}
```

- the claim call becomes `claimDomain(ctx, s, domain, acct, app, sender, now)`, and the wrap gains a second branch so `ErrDomainHeldBySender` survives `errors.Is` for the door;
- `rec.AddApp(app)` becomes:

```go
	// The APP sender joins Apps as well as Senders, because Apps is what a
	// console rolled back to the previous release reads, and because
	// RemoveApp is still how an app sender leaves. An INSTANCE sender joins
	// Senders alone: Apps is what grantedDomains computes the app-wide
	// credential's policy from, and a customer's identity reaching it would
	// be delivered to every sibling instance (design §6.3).
	if sender == SenderAppID {
		rec.AddApp(app)
	}
	rec.AddSenderRef(app, sender)
```

- `RefreshPolicies(ctx, s, p, acct, rec.Apps)` is already `rec.SenderRefs()` from task 2;
- the platform-name ownership check and plan 3's external-identity refusal are untouched, and both still run before the claim.

`internal/mail/walk.go`, `Teardown` -> `TeardownSender` with `sender string`, and `Teardown` as the app-sender wrapper. Four changes inside:

- the principal loop reads the SENDER, not the record: `GetSender(ctx, s, acct, app, sender)`, then `DeleteUser` on its `Provisioned.IAMUser` and on each `Standalone.IAMUser`;
- the domain loop's `cur.RemoveApp(app)` becomes `changed := cur.RemoveSenderRef(app, sender); if sender == SenderAppID { changed = cur.RemoveApp(app) || changed }`, and `if len(cur.Apps) > 0` becomes `if len(cur.SenderRefs()) > 0` — a domain is destroyed only when the LAST sender referencing it goes, which is the same rule one level down;
- the orphan-claim sweep's `if c.Account != acct || c.App != app` gains the sender: a claim's empty `Sender` reads as `SenderAppID`, so `TeardownSender(…, SenderAppID)` still sweeps a legacy orphan and an instance teardown never touches one;
- the final `s.Delete(ctx, Key(acct, app))` becomes `s.Delete(ctx, SenderKey(acct, app, sender))`, so an instance teardown deletes its own sender object and the app sender's teardown deletes `senders/app.json` exactly as it deletes the record today.

**Plan 3's address branch stays where it is.** `TeardownSender` keeps plan 3's `teardownAddressSender` call on the `Source == SourceAddress` path and keeps its rule of never calling `DeleteIdentity` for a borrowed identity. This task only widens who the teardown is *for*.

- [ ] **Step 4: Run the test — expect success**

```bash
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ ./internal/console/ -count=1
```

- [ ] **Step 5: Gate and commit**

```bash
gofmt -w internal/mail/domainclaim.go internal/mail/walk.go internal/mail/walk_domainclaim_test.go
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
gofmt -l .
git commit -am "mail: the domain claim carries its sender, and refuses a sibling's re-claim (plan 5, task 3)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 4: Entering and leaving per-instance mode

**Files:**
- Modify: `internal/mail/instance.go`, `internal/mail/walk.go` (`Enable`'s refusal), `internal/mail/instance_test.go`

**Interfaces:**
- Consumes: plan 2's `GetSender`/`PutSender`, `PauseKindPerInstance`, `ErrPerInstance`, `SetSenderPaused`, `Sender.PerInstance`; `Provisioner.ReapKeys` (`provisioner.go`), `EnvNames` (`envnames.go`), plan 3's `EnvNamesAddress` and `SourceAddress`, `appconfig.Apply` (`internal/appconfig/generation.go:119`), `senderNamesOf` (task 2).
- Produces:
  ```go
  func EnterPerInstance(ctx context.Context, s release.Store, p Provisioner, acct, app string, now time.Time) (*Sender, error)
  func LeavePerInstance(ctx context.Context, s release.Store, acct, app string, now time.Time) (*Sender, error)
  var ErrNotPerInstance = errors.New("mail: this app is not in per-instance mode")
  func appSenderEnvUnsets(sn *Sender) []string // unexported
  ```

- [ ] **Step 1: Write the failing test**

`internal/mail/instance_test.go` (append):

```go
// The transition is one CAS write, then a reap, then the unset. Order
// matters: the flag has to be durable before any key is destroyed, or a
// crash between the two leaves an app with no credential and no record
// saying why.
func TestEnterPerInstanceStampsTheFlagReapsAndUnsets(t *testing.T) {
	ctx, s, p, pub, now := enableFixture(t)
	if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
		t.Fatal(err)
	}
	if _, err := CreateStandalone(ctx, s, p, testDeployment(), "bag", "harbourmail", "laptop", "o@example.test", now); err != nil {
		t.Fatal(err)
	}

	sn, err := EnterPerInstance(ctx, s, p, "bag", "harbourmail", now)
	if err != nil {
		t.Fatal(err)
	}
	if !sn.PerInstance || sn.Provisioned.Active || sn.Provisioned.PauseKind != PauseKindPerInstance {
		t.Fatalf("the transition did not stamp the app sender: %+v", sn.Provisioned)
	}
	// DELETED, not deactivated. An enable racing the transition mints and
	// DELIVERS before its own CAS Put, so deactivating the ids the record
	// names would leave an active key nothing ever reaps in every
	// instance's bundle (design §6.3).
	names, err := senderNamesOf("bag", "harbourmail", sn)
	if err != nil {
		t.Fatal(err)
	}
	if keys := p.Keys(names.IAMUser); len(keys) != 0 {
		t.Fatalf("the app sender still holds %d keys after the transition", len(keys))
	}
	saUser, err := names.StandaloneUser("bag", "harbourmail", "laptop")
	if err != nil {
		t.Fatal(err)
	}
	if keys := p.Keys(saUser); len(keys) != 0 {
		t.Fatalf("the standalone credential still holds %d keys after the transition", len(keys))
	}
	// And the app's own bundle no longer carries a credential for anyone.
	man, err := appconfig.Get(ctx, s, "bag", "harbourmail")
	if err != nil {
		t.Fatal(err)
	}
	for _, name := range appSenderEnvUnsets(sn) {
		if _, ok := man.Vars[name]; ok {
			t.Errorf("%s is still delivered to every instance of this app", name)
		}
	}
}

// THE RACE §6.3 NAMES. An enable that has already minted and delivered,
// interleaved with the transition, must leave no usable credential: the
// reap deletes whatever keys exist, so the delivered variables are inert
// even though they landed.
func TestAnEnableRacingTheTransitionLeavesNoUsableAppCredential(t *testing.T) {
	ctx, s, p, pub, now := enableFixture(t)
	if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
		t.Fatal(err)
	}
	sn, _, err := GetSender(ctx, s, "bag", "harbourmail", SenderAppID)
	if err != nil {
		t.Fatal(err)
	}
	names, err := senderNamesOf("bag", "harbourmail", sn)
	if err != nil {
		t.Fatal(err)
	}
	// The racing enable's half: a key minted and delivered, with no record
	// naming it yet, exactly as walk.go mints and writes before its Put.
	keyID, secret, err := p.MintKey(ctx, names.IAMUser)
	if err != nil {
		t.Fatal(err)
	}
	if err := writeSMTPEnv(ctx, s, pub, "bag", "harbourmail", "HARBOURMAIL_SMTP",
		testDeployment().Region, keyID, secret, sn.Declared.From, sn); err != nil {
		t.Fatal(err)
	}
	if _, err := EnterPerInstance(ctx, s, p, "bag", "harbourmail", now); err != nil {
		t.Fatal(err)
	}
	if keys := p.Keys(names.IAMUser); len(keys) != 0 {
		t.Fatalf("the unrecorded key the racing enable minted survived: %q", keys)
	}
}

// Re-entering converges rather than refusing: a crash between the CAS and
// the reap has to be repairable by running the transition again.
func TestEnterPerInstanceIsIdempotent(t *testing.T) {
	ctx, s, p, pub, now := enableFixture(t)
	if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
		t.Fatal(err)
	}
	if _, err := EnterPerInstance(ctx, s, p, "bag", "harbourmail", now); err != nil {
		t.Fatal(err)
	}
	if _, err := EnterPerInstance(ctx, s, p, "bag", "harbourmail", now); err != nil {
		t.Fatalf("a second transition was refused: %v", err)
	}
	// The app sender's own enable is refused under the flag: carrying the
	// false Active forward would mint a key that is dead on arrival.
	if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); !errors.Is(err, ErrPerInstance) {
		t.Fatalf("Enable in per-instance mode = %v, want ErrPerInstance", err)
	}
	// Leaving clears all three, and only then does Enable work again.
	sn, err := LeavePerInstance(ctx, s, "bag", "harbourmail", now)
	if err != nil {
		t.Fatal(err)
	}
	if sn.PerInstance || !sn.Provisioned.Active || sn.Provisioned.PauseKind != "" {
		t.Fatalf("leaving left the app sender in %+v", sn.Provisioned)
	}
	if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
		t.Fatalf("Enable after leaving per-instance mode: %v", err)
	}
}
```

`MemProvisioner` needs `Keys(user string) []string`, the live key ids for a principal. Add it if plan 2's reap tests did not.

- [ ] **Step 2: Run the test — expect failure**

```bash
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run 'PerInstance' -count=1
```

Expected: build failure, `undefined: EnterPerInstance`, `undefined: LeavePerInstance`, `undefined: appSenderEnvUnsets`.

- [ ] **Step 3: Implement**

`internal/mail/instance.go`, append:

```go
// ErrNotPerInstance is an instance verb against an app that never entered
// per-instance mode. Instance Enable requires the flag, because until it is
// set every instance of this app is still holding the app-wide credential,
// and a per-instance credential delivered alongside it would isolate
// nothing.
var ErrNotPerInstance = errors.New("mail: this app is not in per-instance mode")

// appSenderEnvUnsets is every variable the APP sender delivers into the
// default bundle: the five SMTP names, plus plan 3's configuration-set and
// tenant names when this sender sends under a borrowed identity. Computed
// from the sender rather than assumed, because an address sender that kept
// two variables standing would leave every instance of the app naming
// CARLOS's configuration set with no credential to go with it.
func appSenderEnvUnsets(sn *Sender) []string {
	if sn == nil || sn.Declared == nil {
		return nil
	}
	host, port, user, pass, from := EnvNames(sn.Declared.EnvPrefix)
	out := []string{host, port, user, pass, from}
	if sn.Source == SourceAddress {
		configSet, tenant := EnvNamesAddress(sn.Declared.EnvPrefix)
		out = append(out, configSet, tenant)
	}
	sort.Strings(out)
	return out
}

// EnterPerInstance moves an app from one shared credential to one per
// instance (design §6.3). The order is the whole of its correctness:
//
//  1. ONE CAS write on senders/app.json that sets PerInstance, Active =
//     false and PauseKind = PauseKindPerInstance together. A prefix listing
//     cannot decide this without racing an app-sender enable, and the three
//     fields must land together or a status read between them would report
//     a state that was never intended. PauseKindPerInstance is what stops
//     resumeStaleCapPause re-arming the credential every instance still
//     holds on the next UTC day roll.
//  2. ReapKeys(user, nil) on the app sender's principal and on every
//     standalone principal. LIST AND DELETE, never SetKeyActive on the ids
//     the record names: Enable mints and DELIVERS a key before its own CAS
//     Put, so an enable racing this transition leaves an active key in
//     every instance's bundle that no record names and nothing would ever
//     reap. Deleting whatever keys exist makes that delivery inert.
//  3. Unset the app sender's variables from the default bundle, so an
//     instance that keeps running is left with nothing rather than with a
//     credential that has been silently destroyed underneath it.
//
// Idempotent by construction: re-entering skips the CAS when the flag is
// already set and re-runs the reap and the unset, so a crash anywhere in
// the sequence is repaired by running it again. Instance Enable re-runs
// step 2 for the same reason.
func EnterPerInstance(ctx context.Context, s release.Store, p Provisioner, acct, app string,
	now time.Time) (*Sender, error) {

	sn, etag, err := GetSender(ctx, s, acct, app, SenderAppID)
	if err != nil {
		return nil, err
	}
	if sn == nil || sn.Declared == nil {
		return nil, ErrNotDeclared
	}
	if !sn.PerInstance {
		sn.PerInstance = true
		if sn.Provisioned != nil {
			sn.Provisioned.Active = false
			sn.Provisioned.PauseKind = PauseKindPerInstance
			sn.Provisioned.At = now
		}
		if _, err := PutSender(ctx, s, acct, app, sn, etag); err != nil {
			return nil, err
		}
	}

	names, err := senderNamesOf(acct, app, sn)
	if err != nil {
		return nil, err
	}
	users := []string{}
	if sn.Provisioned != nil {
		users = append(users, names.IAMUser)
	}
	for _, sa := range sn.Standalone {
		user, err := names.StandaloneUser(acct, app, sa.Name)
		if err != nil {
			return nil, err
		}
		users = append(users, user)
	}
	for _, user := range users {
		if err := p.ReapKeys(ctx, user, nil); err != nil {
			return nil, fmt.Errorf("mail: reap keys for %s: %w", user, err)
		}
	}

	if unsets := appSenderEnvUnsets(sn); len(unsets) > 0 {
		if _, err := appconfig.Apply(ctx, s, acct, app, nil, unsets); err != nil {
			return nil, err
		}
	}
	return sn, nil
}

// LeavePerInstance is the inverse, and it is ONLY the record half: it clears
// PerInstance, sets Active and clears PauseKind in one CAS, and the caller
// runs a normal Enable afterwards. Splitting it that way is deliberate —
// Enable carries Active forward from the record, so an enable under a false
// Active would mint a key that is disabled at IAM the moment it is made, and
// the delivered credential would fail SMTP auth with nothing to point at.
//
// It takes no Provisioner, because it destroys nothing and arms nothing.
func LeavePerInstance(ctx context.Context, s release.Store, acct, app string, now time.Time) (*Sender, error) {
	sn, etag, err := GetSender(ctx, s, acct, app, SenderAppID)
	if err != nil {
		return nil, err
	}
	if sn == nil || sn.Declared == nil {
		return nil, ErrNotDeclared
	}
	if !sn.PerInstance {
		return sn, nil // already out: idempotent, like entering
	}
	sn.PerInstance = false
	if sn.Provisioned != nil {
		sn.Provisioned.Active = true
		sn.Provisioned.PauseKind = ""
		sn.Provisioned.At = now
	}
	if _, err := PutSender(ctx, s, acct, app, sn, etag); err != nil {
		return nil, err
	}
	return sn, nil
}
```

`internal/mail/walk.go`, `Enable`: immediately after the `rec == nil || rec.Declared == nil` check, refuse under the flag.

```go
	// Refused UNDER THE ETAG THIS READ RETURNED (design §6.3): the record
	// this walk goes on to Put is the one it just read, so a transition
	// that lands after this check still loses the CAS and this enable never
	// delivers a credential the transition is busy destroying.
	if rec.PerInstance() {
		return nil, ErrPerInstance
	}
```

`Record` is the app sender's in-memory view (plan 2), so it needs a `PerInstance()` passthrough beside the `sender()` one plan 2 added, rather than a field: `Record`'s wire shape is `email.json`'s and must not grow.

**If plan 2's task 12 already added this refusal, leave it alone** and make this task's assertion the only change. Check `Enable` first; the plan-2 header says the flag's refusals were written there against a hand-set flag, and the `SetPaused`/`resumeStaleCapPause` pair certainly were.

- [ ] **Step 4: Run the test — expect success**

```bash
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1
```

- [ ] **Step 5: Gate and commit**

```bash
gofmt -w internal/mail/instance.go internal/mail/walk.go internal/mail/instance_test.go internal/mail/memprovisioner.go
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
gofmt -l .
git commit -am "mail: enter and leave per-instance mode (plan 5, task 4)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 5: `EnableInstance`, and the credential that only one instance can read

**Files:**
- Modify: `internal/mail/instance.go`, `internal/mail/instance_test.go`

**Interfaces:**
- Consumes: plan 4's `appconfig.ApplyInstance(ctx, s, acct, app, host, sets, unsets) (removed int, etag string, err error)` and `appconfig.LoadInstanceConfig(ctx, s, acct, app, host) (*Manifest, string, bool, error)`; plan 2's `ResolveSenderNames`, `SenderNameSet`, `GetSender`/`PutSender`, `ConfigSetARN`, `AssociateTenantResource`; `SMTPHost`/`SMTPPort`/`SMTPPassword`, `EnvNames`, `UserPath`, `enforceKeyActive` (`walk.go:848`), `appconfig.Seal`, `appconfig.ValidVarValue`, `Provisioner.{EnsureTenant,EnsureUser,ReapKeys,MintKey}`, task 2's `grantedDomains`, task 4's `ErrNotPerInstance`.
- Produces:
  ```go
  func EnableInstance(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey, d Deployment, acct, app, sender string, now time.Time) (*Sender, string, error) // sender, instance-overlay etag
  func EnvDeliveredInstance(ctx context.Context, s release.Store, acct, app string, sn *Sender, wantKeyID string) ([]string, error)
  func writeSMTPEnvInstance(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey, acct, app string, sn *Sender, region, keyID, secret string) (string, error) // unexported
  ```

**The policy carries no tenant condition, and that is plan 3's ruling, not a gap.** `SendPolicyJSON` renders the domain form with no `ses:TenantName` condition because nothing delivers `<P>_TENANT` to a domain sender, and a condition on a header the app never sets is 554 on every send. An instance sender on its own custom domain is isolated by the `ses:FromAddress` condition and by `grantedDomains` naming only that sender's domains — the app-wide credential is gone by then anyway (task 4). The tenant is still created and associated, because §8 counts and pauses per tenant and because an association is what lets a later release adopt `UpdateReputationEntityCustomerManagedStatus` as the pause verb. `SetKeyActive` stays the pause verb for this release, exactly as plan 2 ruled.

- [ ] **Step 1: Write the failing test**

`internal/mail/instance_test.go` (append):

```go
// The credential goes into the instance layer and NOWHERE else. This is
// the whole point of the feature: the default bundle is what every sibling
// renders, so a variable landing there is a credential handed to every
// customer of a multi-tenant app.
func TestEnableInstanceDeliversOnlyIntoTheInstanceLayer(t *testing.T) {
	ctx, s, p, pub, now := instanceEnableFixture(t)
	sn, etag, err := EnableInstance(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", "book.example.com", now)
	if err != nil {
		t.Fatal(err)
	}
	if etag == "" {
		t.Fatal("EnableInstance returned no overlay etag, so no door can ever confirm delivery")
	}
	if sn.Provisioned == nil || sn.Provisioned.KeyID == "" {
		t.Fatalf("no credential was recorded: %+v", sn.Provisioned)
	}
	over, gotEtag, found, err := appconfig.LoadInstanceConfig(ctx, s, "bag", "harbourmail", "book.example.com")
	if err != nil || !found {
		t.Fatalf("no instance overlay was written: %v %v", found, err)
	}
	if gotEtag != etag {
		t.Fatalf("overlay etag = %q, EnableInstance returned %q", gotEtag, etag)
	}
	host, port, user, pass, from := EnvNames("HARBOURMAIL_SMTP")
	for _, name := range []string{host, port, user, from} {
		if v, ok := over.Vars[name]; !ok || v.Kind != "env" {
			t.Errorf("%s is not delivered into the overlay as env: %+v", name, v)
		}
	}
	if v, ok := over.Vars[pass]; !ok || v.Kind != "secret" {
		t.Errorf("%s is not sealed into the overlay: %+v", pass, v)
	}
	if over.Vars[user].Value != sn.Provisioned.KeyID {
		t.Errorf("the delivered key id %q is not the one the record names %q", over.Vars[user].Value, sn.Provisioned.KeyID)
	}
	// The default bundle carries none of it.
	man, err := appconfig.Get(ctx, s, "bag", "harbourmail")
	if err != nil {
		t.Fatal(err)
	}
	for _, name := range []string{host, port, user, pass, from} {
		if _, ok := man.Vars[name]; ok {
			t.Errorf("%s reached the default bundle, so every sibling instance can read this customer's credential", name)
		}
	}
	// Idempotent: a second enable with delivery intact mints nothing new.
	again, _, err := EnableInstance(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", "book.example.com", now)
	if err != nil {
		t.Fatal(err)
	}
	if again.Provisioned.KeyID != sn.Provisioned.KeyID {
		t.Fatalf("a healthy re-enable minted a new key %q -> %q", sn.Provisioned.KeyID, again.Provisioned.KeyID)
	}
}

// An instance sender needs the app to be in per-instance mode first, and
// only the app's own doors can put it there. Enabling one without it would
// deliver a per-instance credential alongside the app-wide one every
// sibling still holds, which isolates nothing.
func TestEnableInstanceRequiresPerInstanceMode(t *testing.T) {
	ctx, s, p, pub, now := instanceEnableFixture(t)
	sn, etag, err := GetSender(ctx, s, "bag", "harbourmail", SenderAppID)
	if err != nil {
		t.Fatal(err)
	}
	sn.PerInstance = false
	if _, err := PutSender(ctx, s, "bag", "harbourmail", sn, etag); err != nil {
		t.Fatal(err)
	}
	if _, _, err := EnableInstance(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", "book.example.com", now); !errors.Is(err, ErrNotPerInstance) {
		t.Fatalf("EnableInstance outside per-instance mode = %v, want ErrNotPerInstance", err)
	}
}

// The From address must be at one of THIS sender's verified domains, not at
// any domain the app holds. Otherwise the ses:FromAddress condition refuses
// every send at runtime, which presents as "email silently does not work".
func TestEnableInstanceRefusesAFromOutsideItsOwnDomains(t *testing.T) {
	ctx, s, p, pub, now := instanceEnableFixture(t)
	sn, etag, err := GetSender(ctx, s, "bag", "harbourmail", "book.example.com")
	if err != nil {
		t.Fatal(err)
	}
	sn.Declared.From = "hello@shop.example.com"
	if _, err := PutSender(ctx, s, "bag", "harbourmail", sn, etag); err != nil {
		t.Fatal(err)
	}
	_, _, err = EnableInstance(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", "book.example.com", now)
	if err == nil || !strings.Contains(err.Error(), "is not at any verified domain") {
		t.Fatalf("EnableInstance with a foreign From = %v", err)
	}
}
```

`instanceEnableFixture` is the state an instance enable expects: an app sender declared, enabled and then moved into per-instance mode, plus one instance sender declared with a verified domain of its own attached through `EnsureDomainForSender`. Write it beside the tests, using `enableFixture` for the first half and task 3's `EnsureDomainForSender` plus `VerifyDomain` for the second.

- [ ] **Step 2: Run the test — expect failure**

```bash
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run EnableInstance -count=1
```

Expected: build failure, `undefined: EnableInstance`.

- [ ] **Step 3: Implement**

`internal/mail/instance.go`, append:

```go
// EnvDeliveredInstance is EnvDelivered for one instance sender, and it
// reads the OVERLAY ALONE, never the merged view.
//
// That is the whole difference and it is load-bearing. A merged read would
// let a value standing in the app's default bundle satisfy this check, so
// an instance whose overlay was never written would report delivered while
// running on a credential that belongs to the app, or to nobody. The
// overlay is the only layer this sender writes, so it is the only layer
// that can answer for it.
func EnvDeliveredInstance(ctx context.Context, s release.Store, acct, app string, sn *Sender, wantKeyID string) ([]string, error) {
	man, _, found, err := appconfig.LoadInstanceConfig(ctx, s, acct, app, sn.Host)
	if err != nil {
		return nil, err
	}
	host, port, user, pass, from := EnvNames(sn.Declared.EnvPrefix)
	if !found {
		out := []string{host, port, user, pass, from}
		sort.Strings(out)
		return out, nil
	}
	var missing []string
	check := func(name, wantKind string) {
		v, ok := man.Vars[name]
		if !ok || v.Kind != wantKind {
			missing = append(missing, name)
		}
	}
	check(host, "env")
	check(port, "env")
	check(pass, "secret")
	// _USER's VALUE is compared for EnvDelivered's own reason: it is the
	// one credential half delivered unsealed, so a failed final Put after a
	// successful mint and deliver is catchable here and nowhere else.
	if v, ok := man.Vars[user]; !ok || v.Kind != "env" || (wantKeyID != "" && v.Value != wantKeyID) {
		missing = append(missing, user)
	}
	if v, ok := man.Vars[from]; !ok || v.Kind != "env" || v.Value != sn.Declared.From {
		missing = append(missing, from)
	}
	sort.Strings(missing)
	return missing, nil
}

// writeSMTPEnvInstance delivers the five variables into the instance layer
// and returns that layer's new etag, which is what makes delivery
// confirmable: the console compares it against the etag the agent's ack
// says it rendered (design §6.5). Every validation writeSMTPEnv performs
// happens here too, and for the same reasons — a sealed value reaches
// appconfig as an opaque envelope, so an unrenderable byte would stall this
// instance's whole config render hours later and out of sight.
func writeSMTPEnvInstance(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey,
	acct, app string, sn *Sender, region, keyID, secret string) (string, error) {

	hostName, portName, userName, passName, fromName := EnvNames(sn.Declared.EnvPrefix)
	password := SMTPPassword(secret, region)
	if err := appconfig.ValidVarValue(keyID); err != nil {
		return "", fmt.Errorf("mail: minted credential for %s is not EnvironmentFile-safe: %w", userName, err)
	}
	if err := appconfig.ValidVarValue(password); err != nil {
		return "", fmt.Errorf("mail: minted credential for %s is not EnvironmentFile-safe: %w", passName, err)
	}
	env, err := appconfig.Seal(sealPub, []byte(password))
	if err != nil {
		return "", fmt.Errorf("mail: seal %s: %w", passName, err)
	}
	sets := map[string]appconfig.Var{
		hostName: {Kind: "env", Value: SMTPHost(region)},
		portName: {Kind: "env", Value: SMTPPort},
		userName: {Kind: "env", Value: keyID},
		passName: {Kind: "secret", Value: env},
		fromName: {Kind: "env", Value: sn.Declared.From},
	}
	_, etag, err := appconfig.ApplyInstance(ctx, s, acct, app, sn.Host, sets, nil)
	return etag, err
}

// EnableInstance provisions one instance sender's credential and delivers
// it into that instance's own config layer. It is Enable's counterpart and
// keeps every one of Enable's rules, with three additions:
//
//   - the app must be in per-instance mode, or this credential would be
//     delivered alongside the app-wide one every sibling still holds;
//   - the app sender's keys are reaped again, because an app enable that
//     raced the transition may have minted and delivered one since;
//   - the delivered variables go through the instance layer, and the
//     layer's new etag comes back so the caller can confirm delivery.
//
// The second return value is that etag, and it is "" only when nothing was
// written, which is the idempotent short-circuit.
func EnableInstance(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey,
	d Deployment, acct, app, sender string, now time.Time) (*Sender, string, error) {

	appSender, _, err := GetSender(ctx, s, acct, app, SenderAppID)
	if err != nil {
		return nil, "", err
	}
	if appSender == nil || appSender.Declared == nil {
		return nil, "", ErrNotDeclared
	}
	if !appSender.PerInstance {
		return nil, "", ErrNotPerInstance
	}

	sn, etag, err := GetSender(ctx, s, acct, app, sender)
	if err != nil {
		return nil, "", err
	}
	if sn == nil || sn.Declared == nil {
		return nil, "", ErrNotDeclared
	}
	if sn.Scope != ScopeInstance {
		return nil, "", ErrNotInstanceSender
	}

	domains, err := grantedDomains(ctx, s, acct, app, sender)
	if err != nil {
		return nil, "", err
	}
	if len(domains) == 0 {
		return nil, "", ErrNoDomains
	}
	var arns, configSets, names []string
	for _, dom := range domains {
		arns = append(arns, dom.VerifiedARNs()...)
		configSets = append(configSets, dom.VerifiedConfigSetARNs()...)
		names = append(names, dom.Domain)
	}
	sort.Strings(arns)
	sort.Strings(configSets)
	sort.Strings(names)

	at := strings.LastIndex(sn.Declared.From, "@")
	if !containsString(names, sn.Declared.From[at+1:]) {
		return nil, "", fmt.Errorf("mail: From %q is not at any verified domain (%s)",
			sn.Declared.From, strings.Join(names, ", "))
	}

	// The names and the caller index together: ResolveSenderNames writes
	// email/callers/<iamuser>.json right after this, which is what makes a
	// slugged caller resolvable back to this sender when SES stamps it on
	// an event. Nothing parses a slug back.
	nameSet, _, err := ResolveSenderNames(ctx, s, acct, app, sn, now)
	if err != nil {
		return nil, "", err
	}

	// This sender's own tenant, associated with the account's configuration
	// set and with each identity it may send from. The tenant is what makes
	// this sender's events and its future pause its own (design §8); the
	// policy carries no tenant condition, because a domain sender is never
	// delivered a tenant header to satisfy one with.
	if err := p.EnsureTenant(ctx, nameSet.Tenant); err != nil {
		return nil, "", fmt.Errorf("mail: ensure tenant %s: %w", nameSet.Tenant, err)
	}
	for _, arn := range append(append([]string{}, arns...), configSets...) {
		if err := p.AssociateTenantResource(ctx, nameSet.Tenant, arn); err != nil {
			return nil, "", fmt.Errorf("mail: associate %s with tenant %s: %w", arn, nameSet.Tenant, err)
		}
	}

	// EnsureUser runs unconditionally, before the short-circuit, for C2's
	// reason: it is the only place this sender's policy is written, and the
	// grant above is recomputed from the CURRENT domain set on every call.
	userARN, err := p.EnsureUser(ctx, nameSet.IAMUser, UserPath, arns, configSets, names)
	if err != nil {
		return nil, "", fmt.Errorf("mail: ensure user %s: %w", nameSet.IAMUser, err)
	}

	if sn.Provisioned != nil {
		missing, err := EnvDeliveredInstance(ctx, s, acct, app, sn, sn.Provisioned.KeyID)
		if err != nil {
			return nil, "", err
		}
		if len(missing) == 0 {
			return sn, "", nil
		}
	}

	// Re-run the app sender's reap (design §6.3). An app enable that raced
	// the transition mints and delivers before its own CAS Put, so a key
	// with no record naming it can appear after the transition has already
	// swept. This is the second sweep that makes such a key inert.
	if _, err := EnterPerInstance(ctx, s, p, acct, app, now); err != nil {
		return nil, "", err
	}

	active, prev, pauseKind := true, "", ""
	if sn.Provisioned != nil {
		prev, active, pauseKind = sn.Provisioned.PrevKeyID, sn.Provisioned.Active, sn.Provisioned.PauseKind
	}
	var keep []string
	if prev != "" {
		keep = append(keep, prev)
	}
	if err := p.ReapKeys(ctx, nameSet.IAMUser, keep); err != nil {
		return nil, "", fmt.Errorf("mail: reap keys for %s: %w", nameSet.IAMUser, err)
	}
	keyID, secret, err := p.MintKey(ctx, nameSet.IAMUser)
	if err != nil {
		return nil, "", fmt.Errorf("mail: mint key for %s: %w", nameSet.IAMUser, err)
	}
	if err := enforceKeyActive(ctx, p, nameSet.IAMUser, keyID, active); err != nil {
		return nil, "", err
	}

	overlayETag, err := writeSMTPEnvInstance(ctx, s, sealPub, acct, app, sn, d.Region, keyID, secret)
	if err != nil {
		return nil, "", err
	}

	sn.Provisioned = &Provisioned{
		IAMUser: nameSet.IAMUser, UserARN: userARN, KeyID: keyID, PrevKeyID: prev,
		Active: active, PauseKind: pauseKind, At: now,
	}
	if _, err := PutSender(ctx, s, acct, app, sn, etag); err != nil {
		return nil, "", err
	}
	return sn, overlayETag, nil
}
```

- [ ] **Step 4: Run the test — expect success**

```bash
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1
```

- [ ] **Step 5: Gate and commit**

```bash
gofmt -w internal/mail/instance.go internal/mail/instance_test.go
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
gofmt -l .
git commit -am "mail: enable an instance sender into its own config layer (plan 5, task 5)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 6: Rotating and tearing down one instance sender

**Files:**
- Modify: `internal/mail/instance.go`, `internal/mail/instance_test.go`

**Interfaces:**
- Consumes: plan 4's `appconfig.DeleteInstanceConfig(ctx, s, acct, app, host) error`; plan 2's `DeleteCallerIndex`, `DisassociateTenantResource`; task 3's `TeardownSender`; `Rotate`/`RotateFinish` (`walk.go:864`, `:918`) as the shape; `ErrNotProvisioned`.
- Produces:
  ```go
  func RotateInstance(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey, d Deployment, acct, app, sender string, now time.Time) (*Sender, string, error)
  func RotateFinishInstance(ctx context.Context, s release.Store, p Provisioner, acct, app, sender string) (*Sender, error)
  func TeardownInstance(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer, d Deployment, acct, app, sender string) error
  ```

- [ ] **Step 1: Write the failing test**

`internal/mail/instance_test.go` (append):

```go
// A rotation keeps the previous key live until it is finished, exactly as
// the app-level one does, and the new key reaches the instance layer alone.
func TestRotateInstanceOverlapsThenFinishes(t *testing.T) {
	ctx, s, p, pub, now := instanceEnableFixture(t)
	first, _, err := EnableInstance(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", "book.example.com", now)
	if err != nil {
		t.Fatal(err)
	}
	rotated, etag, err := RotateInstance(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", "book.example.com", now)
	if err != nil {
		t.Fatal(err)
	}
	if rotated.Provisioned.KeyID == first.Provisioned.KeyID {
		t.Fatal("the rotation did not mint a new key")
	}
	if rotated.Provisioned.PrevKeyID != first.Provisioned.KeyID {
		t.Fatalf("PrevKeyID = %q, want the key that was live %q", rotated.Provisioned.PrevKeyID, first.Provisioned.KeyID)
	}
	if etag == "" {
		t.Fatal("the rotation returned no overlay etag")
	}
	over, gotEtag, _, err := appconfig.LoadInstanceConfig(ctx, s, "bag", "harbourmail", "book.example.com")
	if err != nil {
		t.Fatal(err)
	}
	_, _, user, _, _ := EnvNames("HARBOURMAIL_SMTP")
	if gotEtag != etag || over.Vars[user].Value != rotated.Provisioned.KeyID {
		t.Fatalf("the rotated key did not reach the overlay: etag %q vs %q, user %q", gotEtag, etag, over.Vars[user].Value)
	}
	done, err := RotateFinishInstance(ctx, s, p, "bag", "harbourmail", "book.example.com")
	if err != nil {
		t.Fatal(err)
	}
	if done.Provisioned.PrevKeyID != "" {
		t.Fatalf("PrevKeyID survived the finish: %q", done.Provisioned.PrevKeyID)
	}
	if keys := p.Keys(done.Provisioned.IAMUser); len(keys) != 1 || keys[0] != done.Provisioned.KeyID {
		t.Fatalf("keys after finish = %q, want only the current one", keys)
	}
}

// Teardown removes the principal, the overlay, the caller index and this
// sender's references on every domain, and leaves a sibling instance's
// domain and credential untouched.
func TestTeardownInstanceLeavesASiblingAlone(t *testing.T) {
	ctx, s, p, pub, now := instanceEnableFixture(t)
	if _, _, err := EnableInstance(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", "book.example.com", now); err != nil {
		t.Fatal(err)
	}
	sibling := declareAndEnableInstance(t, ctx, s, p, pub, now, "shop.example.com")

	if err := TeardownInstance(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "bag", "harbourmail", "book.example.com"); err != nil {
		t.Fatal(err)
	}
	if _, _, found, err := appconfig.LoadInstanceConfig(ctx, s, "bag", "harbourmail", "book.example.com"); err != nil || found {
		t.Fatalf("the torn-down instance's overlay survived: %v %v", found, err)
	}
	gone, _, err := GetSender(ctx, s, "bag", "harbourmail", "book.example.com")
	if err != nil || gone != nil {
		t.Fatalf("the sender object survived its teardown: %+v %v", gone, err)
	}
	// The sibling is untouched: its sender, its overlay and its domain.
	still, _, err := GetSender(ctx, s, "bag", "harbourmail", "shop.example.com")
	if err != nil || still == nil || still.Provisioned.KeyID != sibling.Provisioned.KeyID {
		t.Fatalf("the sibling sender moved: %+v %v", still, err)
	}
	if _, _, found, err := appconfig.LoadInstanceConfig(ctx, s, "bag", "harbourmail", "shop.example.com"); err != nil || !found {
		t.Fatalf("the sibling's overlay was removed: %v %v", found, err)
	}
	if keys := p.Keys(still.Provisioned.IAMUser); len(keys) == 0 {
		t.Fatal("the sibling's credential was reaped by another instance's teardown")
	}
}
```

`declareAndEnableInstance` is a helper beside the fixture: declare a second instance sender, attach and verify its own domain through `EnsureDomainForSender`/`VerifyDomain`, enable it, return the sender.

- [ ] **Step 2: Run the test — expect failure**

```bash
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run 'RotateInstance|TeardownInstance' -count=1
```

Expected: build failure, `undefined: RotateInstance`, `undefined: RotateFinishInstance`, `undefined: TeardownInstance`.

- [ ] **Step 3: Implement**

`internal/mail/instance.go`, append. `RotateInstance` is `Rotate`'s body with three substitutions and nothing else: the sender is read with `GetSender`, the principal name comes from `senderNamesOf`, and delivery goes through `writeSMTPEnvInstance` so the overlay etag comes back. Keep `Rotate`'s two rules verbatim, with their comments: the previous key is KEPT (`ReapKeys(user, keep)` with the current id, so the overlap is deliberate and finishable), and `enforceKeyActive` is applied to the fresh key so a rotation cannot lift a pause. `RotateFinishInstance` is `RotateFinish`'s body with the same substitutions, and it deletes the previous key and clears `PrevKeyID`.

```go
// TeardownInstance dismantles one instance sender: its principal, its
// caller index, its tenant associations, its claims and domain references,
// its config overlay and its record.
//
// THE OVERLAY GOES FIRST, before TeardownSender destroys the principal.
// The order matters on a crash: an overlay left standing beside a deleted
// IAM user is a live instance failing SMTP auth with nothing to point at,
// whereas an overlay removed ahead of a principal that survives is an
// instance with no credential and an IAM user a re-run reaps. The second is
// repairable by running this again; the first is not repairable at all
// without a re-enable nobody knows to ask for.
//
// Leaving per-instance mode is NOT done here. This function does not know
// whether it removed the last instance sender, and finding out is a prefix
// listing that races another instance's enable. The DELETE door decides it,
// after this returns (design §6.3).
func TeardownInstance(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer,
	d Deployment, acct, app, sender string) error {

	sn, _, err := GetSender(ctx, s, acct, app, sender)
	if err != nil {
		return err
	}
	if sn == nil {
		return nil // already gone: idempotent, like every teardown here
	}
	if sn.Scope != ScopeInstance {
		return ErrNotInstanceSender
	}
	if err := appconfig.DeleteInstanceConfig(ctx, s, acct, app, sn.Host); err != nil {
		return err
	}
	return TeardownSender(ctx, s, p, w, d, acct, app, sender)
}
```

**`TeardownSender` is where the caller index and the tenant associations are undone**, because plan 2 put both in `Teardown`'s body and task 3 renamed it. Check while implementing this task that the renamed function still calls `DeleteCallerIndex` for the sender it is tearing down, and still disassociates before `DeleteIdentity`. If plan 2 wired either to the app sender's names specifically, widen it to `senderNamesOf`.

- [ ] **Step 4: Run the test — expect success**

```bash
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1
```

- [ ] **Step 5: Gate and commit**

```bash
gofmt -w internal/mail/instance.go internal/mail/instance_test.go
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
gofmt -l .
git commit -am "mail: rotate and tear down one instance sender (plan 5, task 6)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 7: The box says which uid it renders and which config schema it understands

**Files:**
- Modify: `internal/status/snapshot.go` (`Probes`, `BoxMetrics`, `CollectBox`), `internal/host/metrics_loop.go` (`routeUIDShape`), `internal/host/agent.go` (the status tick's `status.Probes` literal), `internal/status/snapshot_test.go`, `internal/host/metrics_loop_test.go`

**Interfaces:**
- Consumes: `registry.Route.{Backing,Unit,Kind,App,Platform,Caps}` (`internal/registry/registry.go`), `registry.BackingUnit` (`:294`), `caps.ParseGrants(col string) (Grants, error)` (`internal/caps/grants.go:92`), `caps.Grants.HostRole`, `appconfig.ConfigAckSchema` (plan 4), `release.Adoptable` (`internal/release/adopt.go:816`).
- Produces:
  ```go
  // internal/status
  const (
      UIDDynamic = "dynamic" // DynamicUser=yes: a fresh uid per start, shared with nobody
      UIDApp     = "app"     // User=carlos-<app>: one uid per app, shared by every instance of it
      UIDAgent   = "agent"   // an exec child of the agent: the agent's own uid, shared by everything
  )
  // Probes gains:  ConfigSchema int; RouteUIDs map[string]string
  // BoxMetrics gains: ConfigSchema int `json:"config_schema,omitempty"`; RouteUIDs map[string]string `json:"route_uids,omitempty"`
  // internal/host
  func routeUIDShape(rt registry.Route) string
  ```

**Why this lives on the box and not in the console.** Both facts the enable refusals need are box-local. `registry.Route.Backing` and the operator's `caps` grant column live in a box's own SQLite registry, and a console reads only the registry of the machine it runs on (`internal/console/view.go`'s `openRegistry` over `s.cfg.Registry`), so it cannot answer either question for an instance on any other box — and titogo's customers are not all on the console's box. `status/boxes/<label>.json` is the channel that already exists for "what this box knows about its own routes": `RouteVersions` is the precedent, hostname-keyed and bucket-private for exactly the reason these two are (`BuildFleet` copies `Box`'s fields one at a time by name and never names `Metrics`, so no hostname reaches the public feed).

**`routeUIDShape` reads the GRANT, not the manifest.** `RenderService` emits `User=carlos-<app>` only when the manifest declares `host_role` AND the operator has granted it (`internal/caps/render.go`, the `s.HostRole && g.HostRole` branch), so reading the grant alone over-reports `UIDApp` for a route granted host_role by an operator whose manifest never declared it. That is the safe direction — it refuses an enable rather than allowing one — and it matches what the operator's own grant says they intend. It also keeps this off the per-tick bucket read a manifest fetch would cost.

- [ ] **Step 1: Write the failing test**

`internal/host/metrics_loop_test.go` (append):

```go
// The three uid shapes, from the registry row alone. This is the fact the
// email enable door refuses on, so getting it wrong in the permissive
// direction hands one customer's SMTP credential to every sibling.
func TestRouteUIDShapeNamesWhoSharesTheUid(t *testing.T) {
	for _, tc := range []struct {
		name string
		rt   registry.Route
		want string
	}{
		{"unit-backed with no host_role grant", registry.Route{
			Host: "book.example.com", Kind: "instance", App: "harbourmail", Backing: registry.BackingUnit,
		}, status.UIDDynamic},
		{"unit-backed with a host_role grant", registry.Route{
			Host: "book.example.com", Kind: "instance", App: "harbourmail", Backing: registry.BackingUnit,
			Caps: "host-role",
		}, status.UIDApp},
		{"a plain exec child of the agent", registry.Route{
			Host: "book.example.com", Kind: "instance", App: "harbourmail",
		}, status.UIDAgent},
		{"a bespoke operator unit", registry.Route{
			Host: "console.tito.io", Kind: "instance", App: "console", Unit: "console.service",
		}, status.UIDAgent},
		{"an unparseable grant column", registry.Route{
			Host: "book.example.com", Kind: "instance", App: "harbourmail", Backing: registry.BackingUnit,
			Caps: "not-a-token",
		}, status.UIDApp},
	} {
		if got := routeUIDShape(tc.rt); got != tc.want {
			t.Errorf("%s: routeUIDShape = %q, want %q", tc.name, got, tc.want)
		}
	}
}
```

The nested isolated-exec case belongs here too, with the ruling written beside it:

```go
// A NESTED ROUTE IS STILL THE AGENT'S UID (ruling 4). The isolated-exec
// layout puts an instance in its own mount namespace, and
// internal/host/units/carlos-instance@.service runs it as User=carlos —
// one uid for every nested instance on the box and for the agent. A mount
// namespace hides a sibling's FILES, not a sibling's uid, so a
// per-instance SMTP credential buys nothing here. If this ever stops being
// true, the units change first and this test changes with them.
func TestANestedExecRouteStillSharesTheAgentsUid(t *testing.T) {
	rt := registry.Route{
		Host: "book.example.com", Kind: "instance", App: "harbourmail",
		DBPath: provision.NestedDBPath("/var/lib/carlos", "book.example.com"),
		Socket: provision.NestedSocket("book.example.com"),
	}
	if got := routeUIDShape(rt); got != status.UIDAgent {
		t.Fatalf("routeUIDShape for a nested route = %q, want %q", got, status.UIDAgent)
	}
}
```

`internal/status/snapshot_test.go` (append):

```go
// Both new fields reach Box.Metrics, and a box that knows neither still
// publishes no Metrics block at all — the "unknown stays absent, never a
// guess" rule the rest of that block follows.
func TestCollectBoxCarriesTheConfigSchemaAndRouteUIDs(t *testing.T) {
	box := CollectBox(Probes{
		ConfigSchema: 1,
		RouteUIDs:    map[string]string{"book.example.com": UIDDynamic},
	}, time.Now())
	if box.Metrics == nil || box.Metrics.ConfigSchema != 1 {
		t.Fatalf("ConfigSchema did not reach Metrics: %+v", box.Metrics)
	}
	if box.Metrics.RouteUIDs["book.example.com"] != UIDDynamic {
		t.Fatalf("RouteUIDs did not reach Metrics: %+v", box.Metrics)
	}
	if empty := CollectBox(Probes{}, time.Now()); empty.Metrics != nil {
		t.Fatalf("a box that knows nothing published a Metrics block: %+v", empty.Metrics)
	}
}
```

`CollectBox`'s real signature in this tree takes more than `Probes` and a time. Match the call the existing tests in that file make rather than the shape written above.

- [ ] **Step 2: Run the test — expect failure**

```bash
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/host/ ./internal/status/ -run 'RouteUID|ConfigSchema|NestedExec' -count=1
```

Expected: build failure, `undefined: routeUIDShape`, `undefined: status.UIDDynamic`, `unknown field ConfigSchema in struct literal`.

- [ ] **Step 3: Implement**

`internal/status/snapshot.go`, a const block beside `SystemState`:

```go
// The three shapes an instance's process can take a unix uid from, as the
// box that runs it reports them. They exist so a console can refuse to put
// a per-instance credential somewhere a sibling can read it, from a fact
// only the box holds: the route's backing and the operator's capability
// grants both live in the box's own registry.
const (
	// UIDDynamic is DynamicUser=yes: systemd mints a fresh, unpredictable
	// uid on every start, with no supplementary groups, so no sibling ever
	// shares it. This is the only shape a per-instance secret is safe in.
	UIDDynamic = "dynamic"
	// UIDApp is User=carlos-<app>, which a host_role grant renders
	// (caps.RenderService). One uid per APP, shared by every instance of
	// it, and ProtectProc hides other users' processes rather than a
	// sibling's at the same uid.
	UIDApp = "app"
	// UIDAgent is an exec child of the agent, nested layout included: the
	// agent's own uid, shared with every other exec route on the box.
	UIDAgent = "agent"
)
```

On `Probes`, beside `Release`:

```go
	// ConfigSchema is appconfig.ConfigAckSchema as this agent knows it: a
	// positive statement of which config-layer version it renders. It is
	// here rather than derived from Release because Release is a git short
	// sha, which nothing can compare, and from System.Version because that
	// is absent on every box rolled by hand. 0 means an agent older than
	// the instance config layer.
	ConfigSchema int
	// RouteUIDs is host -> one of UIDDynamic, UIDApp or UIDAgent for every
	// non-platform instance route this box carries. Hostname-keyed, which
	// is why it lives in the private Metrics block beside RouteVersions
	// and never reaches the public fleet feed.
	RouteUIDs map[string]string
```

On `BoxMetrics`, beside `Release`, the same two with `json:"config_schema,omitempty"` and `json:"route_uids,omitempty"` and a one-line comment pointing at `Probes`.

In `CollectBox`: add `p.ConfigSchema != 0` and `len(p.RouteUIDs) > 0` to the "something is known" condition, and carry both into the `BoxMetrics` literal. An empty (non-nil) `RouteUIDs` counts as nothing known, exactly as an empty `routeVersions` does.

`internal/host/metrics_loop.go`, beside `metricsBoxLabel`:

```go
// routeUIDShape is which uid this box's rendering gives one route's
// process, in the three words internal/status names (design §6.4).
//
// The grant column decides the host_role case rather than the manifest,
// deliberately. RenderService emits User=carlos-<app> only when the
// manifest declares host_role AND the operator granted it, so reading the
// grant alone can over-report UIDApp for a route whose manifest never
// declared it. Over-reporting refuses an email enable that might have been
// safe; under-reporting would hand a customer's SMTP credential to every
// sibling at the same uid. An unparseable grant column reports UIDApp for
// the same reason: it is the answer that refuses.
func routeUIDShape(rt registry.Route) string {
	if rt.Backing != registry.BackingUnit || rt.Unit != "" {
		// An exec child of the agent, nested layout included: nesting is a
		// mount namespace, and carlos-instance@.service runs as
		// User=carlos, so every nested instance shares the agent's uid.
		return status.UIDAgent
	}
	g, err := caps.ParseGrants(rt.Caps)
	if err != nil || g.HostRole {
		return status.UIDApp
	}
	return status.UIDDynamic
}
```

`internal/host/agent.go`, in the status tick's `status.Probes` literal, beside `Release`:

```go
			// What this agent can say about itself that no console can
			// work out: which config-layer schema it renders, and which
			// uid each of its routes runs under. Both are what the email
			// instance doors refuse on (design §6.4, §6.5).
			ConfigSchema: appconfig.ConfigAckSchema,
			RouteUIDs:    routeUIDShapes(routes),
```

with `routeUIDShapes(routes []registry.Route) map[string]string` beside `routeUIDShape`, skipping platform and non-instance routes and returning nil when it has nothing to say. `internal/host` already imports `internal/appconfig`, `internal/caps`, `internal/registry` and `internal/status`, so no new dependency edge appears; check `internal/status` does not import `internal/caps` (it must not — the constants are plain strings for that reason).

- [ ] **Step 4: Run the test — expect success**

```bash
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/host/ ./internal/status/ -count=1
```

- [ ] **Step 5: Gate and commit**

```bash
gofmt -w internal/status/snapshot.go internal/host/metrics_loop.go internal/host/agent.go internal/status/snapshot_test.go internal/host/metrics_loop_test.go
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
gofmt -l .
git commit -am "status: a box reports its config schema and each route's uid shape (plan 5, task 7)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 8: The two refusals at enable

**Files:**
- Create: `internal/console/apimailinstance.go`, `internal/console/apimailinstance_test.go`

**Interfaces:**
- Consumes: `loadEdgesJoin(ctx, s release.Store) map[string][]servedBy` and `servedBy{Box, Region}` (`internal/console/instancesplacement.go:66`, `:25`), `status.LoadBoxes(ctx, s release.Store) ([]Box, error)` (`internal/status/fleet.go:150`), `status.BoxStale(b, now, period) bool` (`:137`), `status.SnapshotPeriod` (`:119`), task 7's `status.UIDDynamic`/`UIDApp`/`UIDAgent` and `BoxMetrics.{ConfigSchema,RouteUIDs,Release}`, plan 4's `appconfig.ConfigAckSchema`, `jsonError` and `writeJSON` (`internal/console/api.go`).
- Produces:
  ```go
  type instanceBox struct {
      Label, Region, Release, UID string
      ConfigSchema                int
      Found, Stale                bool
  }
  func (s *Server) instanceBoxFacts(ctx context.Context, host string, now time.Time) (instanceBox, error)
  func (s *Server) refuseUnlessOwnUID(w http.ResponseWriter, host string, b instanceBox) bool
  func (s *Server) refuseUnlessLayerAgent(w http.ResponseWriter, host string, b instanceBox) bool
  ```

**Both refusals are 409, never 403 or 404.** The caller is already proven to hold this app (the gate in task 9 runs first), so there is no oracle to protect here and the honest status is "the state of this app conflicts with what you asked for" — the same code `handleAPIEmailEnable` gives for "no verified sending domain". A 403 would read as "your credential is wrong", which would send a hub operator to the wrong place entirely.

**A stale or unreported box refuses.** `status.BoxStale` with `status.SnapshotPeriod` is the same judgement the operator fleet page and `loadBoxJoin` make, and a box that has gone silent has stopped answering for its routes rather than gone on answering with its last known state. This is not a hazard being invented: an enable that trusted a silent box's five-hour-old `RouteUIDs` could deliver a credential into a route whose backing an operator changed in between.

- [ ] **Step 0: Run the copy-review gate**

Six member-facing strings land in this task. Run the `copy-review` skill on all six BEFORE writing them into the file, and apply the user's edits verbatim. The drafts below are drafts; do not ship them unreviewed. Count each string's occurrences at the base commit first — none of these exists today, so each is one new string, but `no deployment bucket on this host` and the 409 sentence shapes are shared with `apimail.go` and must not be re-reviewed or reworded here.

1. `this console cannot tell which box runs {host} yet — an instance sender needs the box to have reported since it was created, so wait a few minutes and run this again`
2. `the box running {host} ({box}) last reported {duration} ago, so this console cannot tell whether an email credential would stay private to that instance — check the box is up and run this again`
3. `{host} runs as one unix user per app (carlos-{app}), so every instance of this app could read this credential — an instance sender needs an instance with a user of its own: remove this app's host-role grant, or move it to a unit-backed instance without one`
4. `{host} runs as a child of the box agent, at the same unix user as every other instance on that box, so a credential delivered to it would be readable by all of them — an instance sender needs a unit-backed instance`
5. `the box running {host} ({box}, {release}) is older than per-instance config delivery, so a credential delivered to that instance would never reach it — roll that box and run this again`
6. `this console has no deployment bucket, so it cannot read what the box running {host} reports` (the 503; reuse `mailNoBucket`'s wording rather than a new sentence if the reviewer prefers)

- [ ] **Step 1: Write the failing test**

`internal/console/apimailinstance_test.go` (create):

```go
package console

import (
	"context"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/carlosframework/platform/internal/status"
)

// The permissive direction is the dangerous one: a credential delivered to
// a route whose uid a sibling shares is a credential every customer of a
// multi-tenant app can read. Each of these four states refuses.
func TestOnlyADynamicUserInstanceCanHoldItsOwnCredential(t *testing.T) {
	fx := newConsoleFixture(t)
	for _, tc := range []struct {
		name    string
		box     instanceBox
		refused bool
	}{
		{"its own uid per start", instanceBox{Found: true, Label: "annie-welding", UID: status.UIDDynamic}, false},
		{"one uid per app", instanceBox{Found: true, Label: "annie-welding", UID: status.UIDApp}, true},
		{"the agent's uid", instanceBox{Found: true, Label: "annie-welding", UID: status.UIDAgent}, true},
		{"no box has reported it", instanceBox{}, true},
		{"the box has gone quiet", instanceBox{Found: true, Label: "annie-welding", UID: status.UIDDynamic, Stale: true}, true},
	} {
		w := httptest.NewRecorder()
		got := fx.srv.refuseUnlessOwnUID(w, "book.example.com", tc.box)
		if got != tc.refused {
			t.Errorf("%s: refused = %v, want %v", tc.name, got, tc.refused)
		}
		if tc.refused && w.Code != 409 {
			t.Errorf("%s: status = %d, want 409", tc.name, w.Code)
		}
		if tc.refused && w.Body.Len() == 0 {
			t.Errorf("%s: refused with an empty body, so nothing tells the caller what to do", tc.name)
		}
	}
}

// The agent-version refusal reads the box record, never the absence of an
// ack: acks are written only when a delivery changes, so a fresh instance
// on a brand new agent has none (design §6.5).
func TestTheLayerRefusalReadsTheBoxRecordAndNotTheAck(t *testing.T) {
	fx := newConsoleFixture(t)
	old := instanceBox{Found: true, Label: "barbara-tinkering", Release: "16593de5", ConfigSchema: 0}
	w := httptest.NewRecorder()
	if !fx.srv.refuseUnlessLayerAgent(w, "book.example.com", old) {
		t.Fatal("an agent that predates the instance config layer was allowed to enable")
	}
	if w.Code != 409 {
		t.Fatalf("status = %d, want 409", w.Code)
	}
	body := w.Body.String()
	if !strings.Contains(body, "barbara-tinkering") || !strings.Contains(body, "16593de5") {
		t.Fatalf("the refusal names neither the box nor its version, so nobody knows which box to roll: %s", body)
	}
	// A current agent passes, with no ack written anywhere.
	w = httptest.NewRecorder()
	current := instanceBox{Found: true, Label: "annie-welding", Release: "c1d40ee", ConfigSchema: appconfig.ConfigAckSchema}
	if fx.srv.refuseUnlessLayerAgent(w, "book.example.com", current) {
		t.Fatalf("a current agent was refused because no ack existed yet: %s", w.Body.String())
	}
}

// instanceBoxFacts joins the two files that already exist: which box serves
// this host (control/edges/<box>.json) and what that box says about itself
// (status/boxes/<label>.json).
func TestInstanceBoxFactsJoinsTheEdgesFileToTheBoxRecord(t *testing.T) {
	fx := newConsoleFixture(t)
	ctx, now := context.Background(), time.Now()
	writeEdgesFile(t, fx.rel, "annie-welding", "eu-west-1", "book.example.com")
	if err := status.WriteBox(ctx, fx.rel, status.Box{
		GeneratedAt: now, Label: "annie-welding", Region: "eu-west-1",
		Metrics: &status.BoxMetrics{
			Release: "c1d40ee", ConfigSchema: 1,
			RouteUIDs: map[string]string{"book.example.com": status.UIDDynamic},
		},
	}); err != nil {
		t.Fatal(err)
	}
	got, err := fx.srv.instanceBoxFacts(ctx, "book.example.com", now)
	if err != nil {
		t.Fatal(err)
	}
	if !got.Found || got.Stale || got.Label != "annie-welding" || got.UID != status.UIDDynamic || got.ConfigSchema != 1 {
		t.Fatalf("instanceBoxFacts = %+v", got)
	}
	// A host no edges file names is not found, and that is not an error.
	if got, err := fx.srv.instanceBoxFacts(ctx, "shop.example.com", now); err != nil || got.Found {
		t.Fatalf("an unreported host = %+v %v, want not found and no error", got, err)
	}
	// A box that has stopped reporting is stale rather than absent, so the
	// refusal can say which box to go and look at.
	later := now.Add(4 * status.SnapshotPeriod)
	if got, err := fx.srv.instanceBoxFacts(ctx, "book.example.com", later); err != nil || !got.Stale || got.Label != "annie-welding" {
		t.Fatalf("a quiet box = %+v %v, want stale and still named", got, err)
	}
}
```

`writeEdgesFile` is a helper writing one `control/edges/<box>.json` with a `hosts` map naming the host; match `edgesFilePlacementView`'s fields (`instancesplacement.go:35`) exactly, or `readEdgesFiles` will skip it.

- [ ] **Step 2: Run the test — expect failure**

```bash
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run 'OwnCredential|LayerRefusal|BoxFacts' -count=1
```

Expected: build failure, `undefined: instanceBox`, `undefined: refuseUnlessOwnUID`.

- [ ] **Step 3: Implement**

`internal/console/apimailinstance.go` (create) with a file comment covering the family, then:

```go
// instanceBox is everything the enable door needs to know about the machine
// an instance runs on, joined from the two files that already carry it:
// control/edges/<box>.json says which box serves the host, and
// status/boxes/<label>.json is what that box says about itself.
//
// Found is false when no edges file names the host or no box record matches
// the label it named. That is a state, not an error: a freshly created
// instance has neither until its box next reports.
type instanceBox struct {
	Label, Region, Release, UID string
	ConfigSchema                int
	Found, Stale                bool
}

// instanceBoxFacts answers for one host. A stale box keeps its label, so
// the refusal can name the machine to go and look at rather than saying
// "somewhere".
func (s *Server) instanceBoxFacts(ctx context.Context, host string, now time.Time) (instanceBox, error) {
	served := loadEdgesJoin(ctx, s.cfg.Releases)[host]
	if len(served) == 0 {
		return instanceBox{}, nil
	}
	boxes, err := status.LoadBoxes(ctx, s.cfg.Releases)
	if err != nil {
		return instanceBox{}, err
	}
	byLabel := make(map[string]status.Box, len(boxes))
	for _, b := range boxes {
		byLabel[b.Label] = b
	}
	// A host can legitimately be served by more than one box across a move.
	// The FRESHEST box is the one describing what is running now, which is
	// the same rule LatestConfigAck applies to acks.
	var best instanceBox
	for _, sb := range served {
		b, ok := byLabel[sb.Box]
		if !ok {
			continue
		}
		cur := instanceBox{
			Label: b.Label, Region: b.Region, Found: true,
			Stale: status.BoxStale(b, now, status.SnapshotPeriod),
		}
		if b.Metrics != nil {
			cur.Release, cur.ConfigSchema = b.Metrics.Release, b.Metrics.ConfigSchema
			cur.UID = b.Metrics.RouteUIDs[host]
		}
		if !best.Found || (best.Stale && !cur.Stale) {
			best = cur
		}
	}
	return best, nil
}

// refuseUnlessOwnUID is §6.4's first check: this instance's process must
// get a unix uid no sibling shares, or the credential about to be delivered
// to it is readable by every other instance of the same app.
//
// The predicate is NOT "unit-backed". A host_role app runs its units as
// User=carlos-<app> with DynamicUser=no, one uid per app, and ProtectProc
// hides other users' processes rather than a sibling's at the same uid. An
// exec route, nested layout included, runs at the agent's own uid.
//
// Reports whether it refused; a refusal has already been written.
func (s *Server) refuseUnlessOwnUID(w http.ResponseWriter, host string, b instanceBox) bool {
	switch {
	case !b.Found:
		jsonError(w, http.StatusConflict, /* copy-review string 1 */)
	case b.Stale:
		jsonError(w, http.StatusConflict, /* copy-review string 2 */)
	case b.UID == status.UIDApp:
		jsonError(w, http.StatusConflict, /* copy-review string 3 */)
	case b.UID == status.UIDDynamic:
		return false
	default:
		// UIDAgent, and anything a newer agent reports that this console
		// does not recognise. An unknown word is refused rather than
		// guessed at: the permissive direction hands a credential to every
		// sibling.
		jsonError(w, http.StatusConflict, /* copy-review string 4 */)
	}
	return true
}

// refuseUnlessLayerAgent is §6.4's second check: the box must be running an
// agent that renders the instance config layer, or the credential this
// enable is about to write would sit in a bucket object nothing reads.
//
// It reads the box record and never the absence of an ack. Acks are written
// only when a pass changes a file, so a fresh instance, or one whose config
// has not moved since its agent was rolled, has no ack at all — absence
// means "not yet delivered", never "old agent" (design §6.5).
func (s *Server) refuseUnlessLayerAgent(w http.ResponseWriter, host string, b instanceBox) bool {
	switch {
	case !b.Found:
		jsonError(w, http.StatusConflict, /* copy-review string 1 */)
	case b.Stale:
		jsonError(w, http.StatusConflict, /* copy-review string 2 */)
	case b.ConfigSchema < appconfig.ConfigAckSchema:
		jsonError(w, http.StatusConflict, /* copy-review string 5 */)
	default:
		return false
	}
	return true
}
```

Both refusals need the bucket, so the door calls `s.mailAvailable(w)` before either, exactly as every other email door does, and neither of these functions checks `s.cfg.Releases` itself.

- [ ] **Step 4: Run the test — expect success**

```bash
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -count=1
```

- [ ] **Step 5: Gate and commit**

```bash
gofmt -w internal/console/apimailinstance.go internal/console/apimailinstance_test.go
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
gofmt -l .
git add internal/console/apimailinstance.go internal/console/apimailinstance_test.go
git commit -m "console: the two refusals an instance sender's enable makes (plan 5, task 8)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 9: The seven doors

**Files:**
- Modify: `internal/console/apimailinstance.go`, `internal/console/apimailinstance_test.go`, `internal/console/authcli.go` (one call to `registerEmailInstanceRoutes`), `internal/console/svcroutes_test.go` (`TestMailRoleReachesExactlyTheEmailDoors`'s `want`)

**Interfaces:**
- Consumes: `apiAccountApp` (`internal/console/apicli.go:40`), plan 3's `mailWriteGates(w, r) bool`, `mailAvailable(w) bool` (`apimail.go:125`), `mailSealKey(w)` (`:151`), `mailDeploymentFor()` (`:340`), `provision.ValidHostShape` (`provision.go:264`), `provision.GetRecord` (`:941`), `provision.InstanceID` (`:1073`), `cliRoute(pattern string, allow svcrole.Set, h http.HandlerFunc)` (`authcli.go:411`), `svcrole.Allow`/`svcrole.Mail` (plan 3), `s.audit`, `s.bumpSerial`, task 1-6's mail verbs, task 8's refusals.
- Produces:
  ```go
  func (s *Server) emailInstanceGate(w http.ResponseWriter, r *http.Request, write bool) (actor string, acct *Account, app, host, sender string, ok bool)
  func (s *Server) handleAPIEmailInstanceDeclare(w http.ResponseWriter, r *http.Request)
  func (s *Server) handleAPIEmailInstanceStatus(w http.ResponseWriter, r *http.Request)
  func (s *Server) handleAPIEmailInstanceDomainEnsure(w http.ResponseWriter, r *http.Request)
  func (s *Server) handleAPIEmailInstanceDomainVerify(w http.ResponseWriter, r *http.Request)
  func (s *Server) handleAPIEmailInstanceEnable(w http.ResponseWriter, r *http.Request)
  func (s *Server) handleAPIEmailInstanceRotate(w http.ResponseWriter, r *http.Request)
  func (s *Server) handleAPIEmailInstanceTeardown(w http.ResponseWriter, r *http.Request)
  func (s *Server) registerEmailInstanceRoutes()
  ```

**The gate spends the EMAIL budget, not the instance one.** `instanceGate` keys the instances limiter on the token and answers its 429 with "too many instance requests". These are email doors that happen to name a host: their caller is a hub bulk-onboarding customers, which is the workload plan 3 sized `mailWriteGates` for when it moved that gate onto the service budget. So the gate is `apiAccountApp` for the principal and the 404, then `ValidHostShape` and `provision.GetRecord` for the host, then `mailWriteGates(w, r)` (or `mailAvailable(w)` on the two reads). One consequence worth stating: the shared instances budget is untouched by email onboarding, so a hub cannot 429 a member's `carlos instances` by onboarding.

**`ValidHostShape` before `GetRecord`, always.** A `{host}` path value arrives percent-decoded, so `..%2f..%2fchannels` would otherwise reach `RecordKey`, which joins it happily. Both existing `{host}` doors comment on this and this family must not be the one that forgets.

**A stranger's 404 is byte-identical to a real instance in another account's.** `apiAccountAppBy` already makes "not your app" and "no such app" the same bare `http.NotFound`, and the host lookup adds "no such instance" to that set. Nothing in this family may 403 before the account is proven.

- [ ] **Step 0: Run the copy-review gate**

New member-facing strings in this task: the declare door's refusals, the enable door's "this app's email is not declared" and "no verified sending domain for this instance", the domain-attach refusal for `mail.ErrDomainHeldBySender` ("that domain is already attached to another app or instance in this account"), and the teardown's reply line. Run `copy-review` on all of them before they land. Where a sentence already exists in `apimail.go` for the app-level door (for example "no email is declared for this app — declare it first"), reuse it verbatim rather than writing a variant: a near-duplicate is a second string to review and a second thing to keep in step.

- [ ] **Step 1: Write the failing test**

`internal/console/apimailinstance_test.go` (append):

```go
// The whole flow through the doors, and the transition that the FIRST
// instance enable performs: the app moves into per-instance mode, its
// credential is reaped, and the variables leave the default bundle.
func TestTheFirstInstanceEnableMovesTheAppIntoPerInstanceMode(t *testing.T) {
	fx := newMailInstanceFixture(t) // app declared and enabled, one instance route reporting UIDDynamic
	ctx := context.Background()

	fx.do(t, "POST", "/api/cli/apps/"+OpsAccount+"/harbourmail/instances/book.example.com/email",
		`{"env_prefix":"HARBOURMAIL_SMTP","from":"hello@book.example.com"}`, 200)
	fx.do(t, "POST", "/api/cli/apps/"+OpsAccount+"/harbourmail/instances/book.example.com/email/domains",
		`{"domain":"book.example.com"}`, 200)
	fx.verifyDomain(t, "book.example.com")
	fx.do(t, "POST", "/api/cli/apps/"+OpsAccount+"/harbourmail/instances/book.example.com/email/enable", ``, 200)

	appSender, _, err := mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", mail.SenderAppID)
	if err != nil {
		t.Fatal(err)
	}
	if !appSender.PerInstance || appSender.Provisioned.Active {
		t.Fatalf("the first instance enable did not move the app: %+v", appSender)
	}
	man, err := appconfig.Get(ctx, fx.rel, OpsAccount, "harbourmail")
	if err != nil {
		t.Fatal(err)
	}
	if _, ok := man.Vars["HARBOURMAIL_SMTP_PASS"]; ok {
		t.Fatal("the app-wide credential is still delivered to every instance")
	}
	// And the last teardown takes the app back out, so a plain `carlos
	// email enable` works again afterwards.
	fx.do(t, "DELETE", "/api/cli/apps/"+OpsAccount+"/harbourmail/instances/book.example.com/email", ``, 200)
	appSender, _, err = mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", mail.SenderAppID)
	if err != nil {
		t.Fatal(err)
	}
	if appSender.PerInstance || appSender.Provisioned.PauseKind != "" {
		t.Fatalf("the last teardown left the app in per-instance mode: %+v", appSender)
	}
}

// A stranger, and an instance that exists in another account, must be
// byte-identical. Anything else makes this family an oracle for which
// hostnames exist.
func TestAStrangersRefusalIsByteIdenticalAcrossTheInstanceEmailDoors(t *testing.T) {
	fx := newMailInstanceFixture(t)
	mine := fx.dial(t, "GET", "/api/cli/apps/"+OpsAccount+"/harbourmail/instances/nosuchhost.example.com/email", ``, fx.strangerToken)
	theirs := fx.dial(t, "GET", "/api/cli/apps/"+OpsAccount+"/harbourmail/instances/book.example.com/email", ``, fx.strangerToken)
	if mine.Code != theirs.Code || mine.Body.String() != theirs.Body.String() {
		t.Fatalf("a real instance answers a stranger differently from an imaginary one:\n %d %q\n %d %q",
			mine.Code, mine.Body.String(), theirs.Code, theirs.Body.String())
	}
}

// A path traversal in {host} never reaches a bucket key.
func TestTheInstanceEmailDoorsRefuseAMalformedHost(t *testing.T) {
	fx := newMailInstanceFixture(t)
	fx.do(t, "GET", "/api/cli/apps/"+OpsAccount+"/harbourmail/instances/..%2f..%2fchannels/email", ``, 404)
}
```

`internal/console/svcroutes_test.go`, `TestMailRoleReachesExactlyTheEmailDoors`'s `want` gains the seven, with the sentence that says why they are Mail and not Instance:

```go
		// §6.4: the instance email family is the hub's, not the customer
		// instance's. Allow(svcrole.Mail) keeps the Instance role pin
		// untouched, which is what makes per-instance machine tokens a
		// later change to a role set and nothing else.
		"POST /api/cli/apps/{account}/{app}/instances/{host}/email",
		"GET /api/cli/apps/{account}/{app}/instances/{host}/email",
		"DELETE /api/cli/apps/{account}/{app}/instances/{host}/email",
		"POST /api/cli/apps/{account}/{app}/instances/{host}/email/domains",
		"POST /api/cli/apps/{account}/{app}/instances/{host}/email/domains/{domain}/verify",
		"POST /api/cli/apps/{account}/{app}/instances/{host}/email/enable",
		"POST /api/cli/apps/{account}/{app}/instances/{host}/email/rotate",
```

Nothing else in `svcroutes_test.go` moves: none of the seven contains a `mintDoors`, `fleetDoors`, `policyDoors` or `systemDoors` substring, none is `svcrole.Set{}` so `closedDoors` and its hard count of 31 are untouched, and none permits `svcrole.Instance` so `exemptFromLegacyPin` and `TestInstanceRoleMatchesTodaysAppTokenReach` are untouched. Confirm all four by running the file, not by reading it.

- [ ] **Step 2: Run the test — expect failure**

```bash
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run 'MailRoleReaches|PerInstanceMode|ByteIdentical|MalformedHost' -count=1
```

Expected: `TestMailRoleReachesExactlyTheEmailDoors` fails with seven missing doors, and the flow tests 404.

- [ ] **Step 3: Implement**

`internal/console/apimailinstance.go`, the gate:

```go
// emailInstanceGate is every door in this file's front half: the principal
// and the account/app 404 (apiAccountApp), the host's shape, the instance
// record's existence, and then the email family's own budget and backend
// gates. It returns the sender id as well as the host, because the id is
// the bounded form and every mail verb takes it.
//
// write picks the gate: the two reads take mailAvailable, the five writes
// take mailWriteGates, which is the email family's budget rather than the
// instances one. These are email doors that happen to name a host, and
// their caller is a hub onboarding customers in bulk — the workload plan 3
// sized that budget for.
func (s *Server) emailInstanceGate(w http.ResponseWriter, r *http.Request, write bool) (string, *Account, string, string, string, bool) {
	actor, acct, app, ok := s.apiAccountApp(w, r)
	if !ok {
		return "", nil, "", "", "", false
	}
	host := r.PathValue("host")
	// Before GetRecord, always: a {host} path value arrives
	// percent-decoded, and RecordKey joins whatever it is given.
	if !provision.ValidHostShape(host) {
		http.NotFound(w, r)
		return "", nil, "", "", "", false
	}
	if write {
		if !s.mailWriteGates(w, r) {
			return "", nil, "", "", "", false
		}
	} else if !s.mailAvailable(w) {
		return "", nil, "", "", "", false
	}
	ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
	defer cancel()
	rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
	if errors.Is(err, os.ErrNotExist) {
		http.NotFound(w, r)
		return "", nil, "", "", "", false
	}
	if err != nil {
		log.Printf("console: api email instance %s/%s/%s: %v", acct.Sqid, app, host, err)
		jsonError(w, http.StatusBadGateway, "could not read the instance")
		return "", nil, "", "", "", false
	}
	_ = rec
	return actor, acct, app, host, provision.InstanceID(host), true
}
```

The seven handlers, each the app-level door's body with the sender threaded through:

- **declare** reads `{"env_prefix":…,"from":…}` and calls `mail.DeclareInstance`. Changed-gating like `handleAPIEmailDeclare`: a re-declare of the same pair does not bury the audit log in no-ops.
- **domains** reads `{"domain":…}` and calls `mail.EnsureDomainForSender` with the sender. `mail.ErrDomainClaimed` keeps `handleAPIEmailDomainEnsure`'s neutral refusal verbatim; `mail.ErrDomainHeldBySender` gets its own, which may say "in this account" because the caller already holds the account.
- **verify** calls `mail.VerifyDomain` unchanged — verification is per (domain, region) and has no sender in it. The door still exists per sender so a hub never has to reach for the app-level one, and so the reply is this sender's.
- **enable** is the ordered one, and the order is the task:
  1. `s.instanceBoxFacts`, then `refuseUnlessOwnUID`, then `refuseUnlessLayerAgent`. **Both before anything durable**: a refusal after `EnterPerInstance` would have destroyed the app's credential on the way to saying no.
  2. `mail.ApplyGrant` for this sender, from the sender's own domains, so the cap is the tier's. (If plan 2 left `ApplyGrant` app-shaped, add the sender parameter here with the same delegation shape task 3 used for `EnsureDomain`.)
  3. `mail.EnterPerInstance` — idempotent, so the second instance's enable is a no-op and the first one's is the transition.
  4. `mail.EnableInstance`, keeping the returned overlay etag.
  5. `s.bumpSerial(ctx)`, because plan 4's `ApplyInstance` bumps the app generation and the boxes read config when the serial moves.
  6. the reply carries the delivery verdict: `appconfig.LatestConfigAck(ctx, s.cfg.Releases, host)` then `appconfig.InstanceConfigDelivery(ack, etag)`, which on a fresh enable is `InstanceConfigNotYetDelivered` and becomes `InstanceConfigDelivered` on the next status read once the box has converged.
- **rotate** mirrors `handleAPIEmailRotate`, `?finish=1` included, over `mail.RotateInstance`/`mail.RotateFinishInstance`.
- **status** reads the sender, resumes a stale cap pause for it (`s.resumeStaleCapPause` with the sender, plan 2's signature) and answers the single-sender reply task 11 builds.
- **teardown** calls `mail.TeardownInstance`, then `mail.InstanceSenders`, and calls `mail.LeavePerInstance` when that list is now empty. **In that order, and only on empty**: leaving while an instance sender survives would re-arm the app-wide credential beside a live per-instance one.

`registerEmailInstanceRoutes` registers all seven with `svcrole.Allow(svcrole.Mail)`, with the role table written above it the way `registerEmailRoutes`'s is, and `registerCLIRoutes` calls it beside `registerEmailRoutes`. The AST pass in `svcroutes_test.go` is package-wide, so a third registration site is covered without changing it.

- [ ] **Step 4: Run the test — expect success**

```bash
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -count=1
```

- [ ] **Step 5: Gate and commit**

```bash
gofmt -w internal/console/apimailinstance.go internal/console/apimailinstance_test.go internal/console/authcli.go internal/console/svcroutes_test.go
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
gofmt -l .
git commit -am "console: the seven instance email doors (plan 5, task 9)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 10: The pause door gains an `instance` field

**Files:**
- Modify: `internal/console/apimail.go` (`handleAPIEmailPause`), `internal/console/apimail_test.go`

**Interfaces:**
- Consumes: `mailOperator` (`apimail.go:190`), `mailPauseTarget` (`:214`), `store.IsOperator`, plan 2's `mail.SetSenderPaused` and `mail.ErrPerInstance`, `mail.ApplyGrant`, `provision.ValidHostShape`, `provision.InstanceID`, task 11's single-sender reply.
- Produces: no new exported name. The pause door's body gains `Instance string \`json:"instance"\``, and the door refuses an app-sender pause while the app is in per-instance mode.

**Pause, resume and the cap stay operator-only, and that is §6.4's ruling.** The seven doors in task 9 are `Allow(svcrole.Mail)` so a hub can onboard; none of them pauses anything. Pausing is the abuse brake and raising a cap is an authority grant, so both stay behind `IsOperator` and `CARLOS_MAIL_OPERATORS` on the one door that already has them. That is why this is a field on an existing door rather than an eighth door: a second registration would be a second place for the operator check to drift out of.

- [ ] **Step 0: Run the copy-review gate**

Two new strings: the refusal when `instance` names a host with no sender (`no email sender for that instance — declare one first`), and the refusal when the app sender is asked to resume while the app is in per-instance mode (`this app's email is delivered per instance — pause or resume the instance instead`). Review both before they land.

- [ ] **Step 1: Write the failing test**

`internal/console/apimail_test.go` (append):

```go
// An operator pauses ONE customer, not the app. This is the whole point of
// a sender: one tenant's bounce rate stops that tenant.
func TestThePauseDoorTakesAnInstanceAndPausesOnlyThatSender(t *testing.T) {
	fx := newMailInstanceFixture(t)
	ctx := context.Background()
	fx.enableTwoInstances(t, "book.example.com", "shop.example.com")

	fx.asOperator(t, "POST", "/api/cli/apps/"+OpsAccount+"/harbourmail/email/pause",
		`{"instance":"book.example.com","paused":true}`, 200)

	paused, _, err := mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", "book.example.com")
	if err != nil {
		t.Fatal(err)
	}
	if paused.Provisioned.Active {
		t.Fatal("the named instance was not paused")
	}
	sibling, _, err := mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", "shop.example.com")
	if err != nil {
		t.Fatal(err)
	}
	if !sibling.Provisioned.Active {
		t.Fatal("pausing one customer paused another")
	}
	// A cap raise is per sender too.
	fx.asOperator(t, "POST", "/api/cli/apps/"+OpsAccount+"/harbourmail/email/pause",
		`{"instance":"book.example.com","daily_cap":5000}`, 200)
	raised, _, err := mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", "book.example.com")
	if err != nil {
		t.Fatal(err)
	}
	if raised.Grant.DailyCap != 5000 || sibling.Grant.DailyCap == 5000 {
		t.Fatalf("the cap raise was not this sender's alone: %d", raised.Grant.DailyCap)
	}
	// And the app sender cannot be resumed while the app is per-instance:
	// resuming it would re-arm a credential nothing delivers any more.
	fx.asOperator(t, "POST", "/api/cli/apps/"+OpsAccount+"/harbourmail/email/pause",
		`{"paused":false}`, 409)
}

// The operator gate is unmoved by the new field: a member, and a fleet
// operator not on CARLOS_MAIL_OPERATORS, both still get the same silent
// 404 they get today, before any account is looked up.
func TestTheInstanceFieldDoesNotWidenThePauseDoor(t *testing.T) {
	fx := newMailInstanceFixture(t)
	got := fx.dial(t, "POST", "/api/cli/apps/"+OpsAccount+"/harbourmail/email/pause",
		`{"instance":"book.example.com","paused":true}`, fx.memberToken)
	if got.Code != 404 {
		t.Fatalf("a member reached the pause door with an instance field: %d %s", got.Code, got.Body.String())
	}
}
```

- [ ] **Step 2: Run the test — expect failure**

```bash
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run 'PauseDoorTakesAnInstance|DoesNotWidenThePauseDoor' -count=1
```

Expected: the instance field is ignored, so the app sender is paused and the assertions fail.

- [ ] **Step 3: Implement**

`internal/console/apimail.go`, `handleAPIEmailPause`. The request struct gains the field, with the comment saying what an empty one means:

```go
	var req struct {
		Paused *bool `json:"paused"`
		// Instance names ONE instance sender to act on. Empty means the
		// app sender, which is what every existing caller sends, so the
		// door's wire shape stays additive. It is a field rather than a
		// second door because the operator gate above it is the thing this
		// family must never grow a second copy of.
		Instance string `json:"instance"`
		DailyCap int    `json:"daily_cap"`
	}
```

After `readJSON` and the "this request would change nothing" check, resolve the sender once:

```go
	sender := mail.SenderAppID
	if req.Instance != "" {
		// The same shape check the instance doors make, and for the same
		// reason: this string becomes a bucket key segment.
		if !provision.ValidHostShape(req.Instance) {
			jsonError(w, http.StatusBadRequest, "that is not an instance hostname")
			return
		}
		sender = provision.InstanceID(req.Instance)
	}
```

Then: the cap branch computes its domain list from that sender's own domains (a new `mailSenderDomains(ctx, acct, app, sender)` beside `mailAppDomains`, filtering on `d.HasSender(app, sender)`; `mailAppDomains` becomes its `SenderAppID` caller) and passes the sender to `ApplyGrant`; the pause branch calls `mail.SetSenderPaused(ctx, …, sender, *req.Paused, "", time.Now())`; the read branch calls `mail.GetSender`. `mail.ErrPerInstance` becomes a 409 with the reviewed sentence, and a nil sender with a non-empty `req.Instance` becomes a 409 with the other one. The audit detail names the instance when there is one, so a tenant's log says which customer was paused.

The reply: an app-sender pause keeps `mailWalkReply` exactly as it is; an instance pause answers task 11's single-sender reply, so an operator sees the sender they acted on rather than the app.

- [ ] **Step 4: Run the test — expect success**

```bash
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -count=1
```

- [ ] **Step 5: Gate and commit**

```bash
gofmt -w internal/console/apimail.go internal/console/apimail_test.go
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
gofmt -l .
git commit -am "console: the pause door pauses one instance sender (plan 5, task 10)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 11: The reply carries every sender, its host and its delivery

**Files:**
- Modify: `internal/console/apimail.go` (`emailReply`, `handleAPIEmailStatus`), `internal/console/apimailinstance.go` (`emailInstanceReply`), `internal/console/apimail_test.go`

**Interfaces:**
- Consumes: plan 2's `emailReply` `senders` array and its per-sender shape, `mail.ListSenders`, `store.MailCounts(ctx, acct, app, sender, day)`, `resumeStaleCapPause(ctx, acct, app, sender, sn, now)`; task 1's `mail.InstanceSenders`; plan 4's `appconfig.LatestConfigAck`, `appconfig.InstanceConfigDelivery` and the four state constants; task 5's `mail.EnvDeliveredInstance`.
- Produces:
  ```go
  func (s *Server) emailInstanceReply(ctx context.Context, acct *Account, app string, sn *mail.Sender) (map[string]any, error)
  // emailReply's per-sender objects gain: "host", "delivery", "missing_vars"
  ```

**The top-level fields keep describing the app sender.** Plan 2's constraint holds unchanged here: an older CLI reads `active`, `daily_cap`, `counts` and `provisioned` and must go on getting the app's answer, even when the app is in per-instance mode and that answer is "paused, per-instance". The instance senders are only ever in the `senders` array.

**Delivery is read per sender and only for instance senders.** `InstanceConfigDelivery(ack, want)` needs the etag of the overlay the console last wrote, which is not stored on the sender — so the status path compares against the etag `LoadInstanceConfig` reports for the overlay as it stands now. That is the right comparison for a read: "is the agent running the overlay that is in the bucket", which is exactly the question, and it is decidable without keeping a second copy of an etag that the next write would invalidate anyway.

- [ ] **Step 1: Write the failing test**

`internal/console/apimail_test.go` (append):

```go
// An instance sender appears in the senders array with its host, its own
// counts and its delivery state, and the top-level fields still describe
// the app sender.
func TestEmailReplyCarriesAnInstanceSendersHostAndDelivery(t *testing.T) {
	fx := newMailInstanceFixture(t)
	ctx := context.Background()
	fx.enableTwoInstances(t, "book.example.com", "shop.example.com")
	day := time.Now().UTC().Format("2006-01-02")
	if err := fx.st.RecordMailEvent(ctx, OpsAccount, "harbourmail", "book.example.com", day, mail.EventSend); err != nil {
		t.Fatal(err)
	}

	acct, err := fx.st.AccountBySqid(OpsAccount)
	if err != nil || acct == nil {
		t.Fatalf("AccountBySqid = %v %v", acct, err)
	}
	rec, _, err := mail.Get(ctx, fx.rel, OpsAccount, "harbourmail")
	if err != nil {
		t.Fatal(err)
	}
	out, err := fx.srv.emailReply(ctx, acct, "harbourmail", rec)
	if err != nil {
		t.Fatal(err)
	}
	senders, _ := out["senders"].([]map[string]any)
	if len(senders) != 3 {
		t.Fatalf("senders = %d, want the app sender and two instances", len(senders))
	}
	var book map[string]any
	for _, sn := range senders {
		if sn["id"] == "book.example.com" {
			book = sn
		}
	}
	if book == nil {
		t.Fatal("the instance sender is missing from the reply")
	}
	if book["host"] != "book.example.com" || book["scope"] != mail.ScopeInstance {
		t.Fatalf("the instance sender does not describe itself: %+v", book)
	}
	if book["delivery"] != appconfig.InstanceConfigNotYetDelivered {
		t.Fatalf("delivery = %v, want %q before any box has acked", book["delivery"], appconfig.InstanceConfigNotYetDelivered)
	}
	counts, _ := book["counts"].(map[string]any)
	if counts["sends"] != 1 {
		t.Fatalf("this sender's counts are not its own: %+v", counts)
	}
	// Unchanged for an older CLI: the top-level fields are the app's.
	if out["active"] != false {
		t.Fatalf("the top-level active moved off the app sender: %v", out["active"])
	}
}

// An ack carrying the overlay's current etag reads as delivered, and one
// carrying an older etag reads as stale. Both are decidable; the absence of
// an ack is neither.
func TestDeliveryIsDecidedByTheAcksOverlayEtag(t *testing.T) {
	fx := newMailInstanceFixture(t)
	ctx := context.Background()
	fx.enableTwoInstances(t, "book.example.com", "shop.example.com")
	_, etag, _, err := appconfig.LoadInstanceConfig(ctx, fx.rel, OpsAccount, "harbourmail", "book.example.com")
	if err != nil {
		t.Fatal(err)
	}
	fx.writeAck(t, "annie-welding", "book.example.com", appconfig.ConfigAckSchema, etag)
	sn, _, err := mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", "book.example.com")
	if err != nil {
		t.Fatal(err)
	}
	acct, _ := fx.st.AccountBySqid(OpsAccount)
	out, err := fx.srv.emailInstanceReply(ctx, acct, "harbourmail", sn)
	if err != nil {
		t.Fatal(err)
	}
	if out["delivery"] != appconfig.InstanceConfigDelivered {
		t.Fatalf("delivery = %v, want %q", out["delivery"], appconfig.InstanceConfigDelivered)
	}
	fx.writeAck(t, "annie-welding", "book.example.com", appconfig.ConfigAckSchema, "an-older-etag")
	out, err = fx.srv.emailInstanceReply(ctx, acct, "harbourmail", sn)
	if err != nil {
		t.Fatal(err)
	}
	if out["delivery"] != appconfig.InstanceConfigStale {
		t.Fatalf("delivery = %v, want %q", out["delivery"], appconfig.InstanceConfigStale)
	}
}

// The status read resumes a stale cap pause for EVERY sender, not just the
// app's. A paused sender sends nothing, so no SES event ever arrives to run
// the event-path check, and running `carlos email status` is what actually
// clears a rolled-over daily budget.
func TestTheStatusReadResumesEverySendersStaleCapPause(t *testing.T) {
	fx := newMailInstanceFixture(t)
	ctx, now := context.Background(), time.Now()
	fx.enableTwoInstances(t, "book.example.com", "shop.example.com")
	fx.pauseSenderDirectly(t, "book.example.com", mail.PauseKindCap, now.AddDate(0, 0, -1))

	fx.do(t, "GET", "/api/cli/apps/"+OpsAccount+"/harbourmail/email", ``, 200)

	got, _, err := mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", "book.example.com")
	if err != nil {
		t.Fatal(err)
	}
	if !got.Provisioned.Active || got.Provisioned.PauseKind != "" {
		t.Fatalf("the instance sender's yesterday cap pause was not cleared by the status read: %+v", got.Provisioned)
	}
}
```

- [ ] **Step 2: Run the test — expect failure**

```bash
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run 'HostAndDelivery|OverlayEtag|ResumesEverySender' -count=1
```

Expected: `host`, `delivery` and `missing_vars` are absent from the per-sender objects, `emailInstanceReply` is undefined, and only the app sender resumes.

- [ ] **Step 3: Implement**

`internal/console/apimail.go`, `emailReply`'s per-sender loop: for a sender whose `Scope == mail.ScopeInstance`, add `"host"`, `"missing_vars"` from `mail.EnvDeliveredInstance` and `"delivery"` from `appconfig.LatestConfigAck` compared against the overlay's current etag. Keep the app sender's object exactly as plan 2 wrote it, with `host` omitted: an app sender has no host, and an empty string there would read as one.

Pull the per-sender object into one function so the array and the single-sender reply can never drift:

```go
// senderOut is the one description of a sender this package gives, used by
// the senders array and by the single-sender reply the instance doors and
// the pause door answer with. One function, for storeReply's reason: two
// descriptions of the same record drift, and the first anyone notices is a
// member reading two different answers to one question.
func (s *Server) senderOut(ctx context.Context, acct *Account, app string, sn *mail.Sender, day string) (map[string]any, error)
```

`internal/console/apimail.go`, `handleAPIEmailStatus`: after the app sender's `resumeStaleCapPause`, walk `mail.InstanceSenders` and resume each. A failure to resume ONE sender must not fail the read — log it and carry on with the rest, because the status read is how a member sees what is happening and a 502 there would hide every other sender's state too. That is a deliberate departure from the app sender's branch, which does fail the read: the app sender is the top-level answer, so a wrong one there is a lie rather than a gap.

`internal/console/apimailinstance.go`:

```go
// emailInstanceReply describes ONE instance sender, for the instance status
// door, the instance walk doors and an operator's instance pause. It is
// senderOut plus the app-level context a caller acting on one instance
// still needs: which app, and which env names it is delivered under.
func (s *Server) emailInstanceReply(ctx context.Context, acct *Account, app string, sn *mail.Sender) (map[string]any, error)
```

- [ ] **Step 4: Run the test — expect success**

```bash
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -count=1
```

- [ ] **Step 5: Gate and commit**

```bash
gofmt -w internal/console/apimail.go internal/console/apimailinstance.go internal/console/apimail_test.go
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
gofmt -l .
git commit -am "console: every sender's host, counts and delivery in the reply (plan 5, task 11)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 12: `carlos email status --instance`, and a line per instance sender

**Files:**
- Modify: `mail_cli.go` (`apiEmailSender`, `emailStatus`, `runEmailStatus`, `printEmailStatus`), `mail_cli_test.go`

**Interfaces:**
- Consumes: plan 2's `apiEmailSender` and `apiEmailReply.Senders`, `printEmailStatus` (`mail_cli.go:1129`), `requireMailAPISession` (`:166`), `appFlag`/`accountFlags`/`consoleOption` (`cli.go`), `pathEscape` (`:517`), plan 4's four delivery words.
- Produces:
  ```go
  // apiEmailSender gains: Host string `json:"host,omitempty"`; Delivery string `json:"delivery,omitempty"`; MissingVars []string `json:"missing_vars,omitempty"`
  func (s *apiSession) emailInstanceStatus(ctx context.Context, a apiAcct, app, host string) (apiEmailSender, error)
  func printEmailSenderLine(w io.Writer, res apiEmailReply, sn apiEmailSender)
  func printEmailInstanceStatus(w io.Writer, app string, sn apiEmailSender)
  ```

- [ ] **Step 0: Run the copy-review gate**

Every line this task prints is member-facing. Extract them all and run `copy-review` before writing any of them into `mail_cli.go`. The set, as drafts:

- the per-sender line: `  instance {host}: {active|PAUSED} (key {keyid}) — {n}/{cap} sends today`
- the four delivery words, in the sentences that carry them: `delivered`, `waiting for the box to pick it up`, `the box is running an older delivery`, `the box is running an agent that cannot deliver per-instance config`
- the per-instance drift line: `  {names} not yet delivered to this instance`
- the app line when the app is in per-instance mode: `  this app's email is delivered per instance — its own credential is switched off`
- `--instance` with no sender: `no email sender for {host} — carlos email instance enable --app {app} --instance {host}`

Note that `printEmailStatus`'s existing paused block already carries three reviewed sentences (`daily cap reached…`, `bounce/complaint rate over threshold…`, `paused by a deployment operator…`). Reuse them for a paused instance sender verbatim rather than writing variants: check their occurrence count at the base commit first, because they are about to be printed from two places.

- [ ] **Step 1: Write the failing test**

`mail_cli_test.go` (append):

```go
// A member with instance senders sees one line each, with the host, the
// pause state and the delivery state. A member with only the app sender
// sees exactly what they saw before, which is what keeps this additive.
func TestPrintEmailStatusRendersInstanceSendersWithTheirDelivery(t *testing.T) {
	var buf bytes.Buffer
	printEmailStatus(&buf, apiEmailReply{
		App: "harbourmail", DailyCap: 50, Active: false,
		Provisioned: &apiEmailProvisioned{KeyID: "AKIA1", PauseKind: "per-instance"},
		Counts:      apiEmailCounts{Sends: 12},
		Senders: []apiEmailSender{
			{ID: "app", Scope: "app", Source: "domain", Active: false, PauseKind: "per-instance", DailyCap: 50, KeyID: "AKIA1"},
			{ID: "book.example.com", Scope: "instance", Source: "domain", Host: "book.example.com",
				Active: true, DailyCap: 1000, KeyID: "AKIA9", Delivery: "delivered",
				Counts: apiEmailCounts{Sends: 8, Bounces: 1}},
			{ID: "shop.example.com", Scope: "instance", Source: "domain", Host: "shop.example.com",
				Active: false, PauseKind: "cap", DailyCap: 1000, KeyID: "AKIA7", Delivery: "stale",
				Counts: apiEmailCounts{Sends: 1000}},
		},
	})
	out := buf.String()
	for _, want := range []string{"book.example.com", "shop.example.com", "AKIA9", "8/1000"} {
		if !strings.Contains(out, want) {
			t.Errorf("status is missing %q:\n%s", want, out)
		}
	}
	// A paused instance says why, using the same sentence the app sender
	// has always used for a cap pause.
	if !strings.Contains(out, "daily cap reached") {
		t.Errorf("a cap-paused instance does not say it will clear itself:\n%s", out)
	}
	// And the app sender says it is per-instance rather than reading as an
	// unexplained pause, which is what it would look like otherwise.
	if !strings.Contains(out, "delivered per instance") {
		t.Errorf("the app sender in per-instance mode reads as a mystery pause:\n%s", out)
	}
}

// --instance prints one sender and nothing else.
func TestPrintEmailInstanceStatusPrintsOneSender(t *testing.T) {
	var buf bytes.Buffer
	printEmailInstanceStatus(&buf, "harbourmail", apiEmailSender{
		ID: "book.example.com", Scope: "instance", Source: "domain", Host: "book.example.com",
		Active: true, DailyCap: 1000, KeyID: "AKIA9", Delivery: "not-yet-delivered",
		MissingVars: []string{"HARBOURMAIL_SMTP_PASS"},
		Counts:      apiEmailCounts{Sends: 3},
	})
	out := buf.String()
	if strings.Contains(out, "shop.example.com") {
		t.Errorf("--instance printed more than one sender:\n%s", out)
	}
	if !strings.Contains(out, "HARBOURMAIL_SMTP_PASS") {
		t.Errorf("--instance does not name what has not been delivered:\n%s", out)
	}
}
```

- [ ] **Step 2: Run the test — expect failure**

```bash
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'PrintEmailStatusRendersInstance|PrintEmailInstanceStatus' -count=1
```

Expected: build failure, `unknown field Host in struct literal of type apiEmailSender`, `undefined: printEmailInstanceStatus`.

- [ ] **Step 3: Implement**

`mail_cli.go`: `apiEmailSender` gains the three fields, all `omitempty`, so a console that predates them decodes into the zero values and an app sender carries none of them.

`printEmailStatus` gains a per-sender block after the existing app-sender block, printing only senders whose `Scope == "instance"`. The app-sender block gains one branch: when `res.Provisioned.PauseKind == "per-instance"`, print the reviewed per-instance sentence instead of the existing `paused by a deployment operator` line, which would be wrong and would send a member to an operator who can do nothing about it.

`printEmailSenderLine` is the one renderer for an instance sender's line, called from both `printEmailStatus` and `printEmailInstanceStatus`, so the two can never print the same sender differently.

`runEmailStatus` gains `--instance`, mutually exclusive with nothing: `--app` is still required, because an instance is named inside an app. With `--instance` set it calls `emailInstanceStatus`, which is a `GET` on `/api/cli/apps/{account}/{app}/instances/{host}/email` with the host `pathEscape`d, and prints `printEmailInstanceStatus`. Without it, nothing about today's path changes.

`emailStatus`'s existing 404 handling (`isEmailNotDeclared`) is reused for the instance form, so `--instance` against a host with no sender prints the reviewed sentence and exits 1 rather than logging a fatal.

- [ ] **Step 4: Run the test — expect success**

```bash
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -count=1
```

- [ ] **Step 5: Gate and commit**

```bash
gofmt -w mail_cli.go mail_cli_test.go
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
gofmt -l .
git commit -am "cli: a line per instance sender, and email status --instance (plan 5, task 12)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 13: `carlos email instance`, the wrapper over the same doors

**Files:**
- Modify: `mail_cli.go` (`runEmail`, `emailUsage`, the session methods), `mail_cli_test.go`

**Interfaces:**
- Consumes: `runEmailEnableFlow` (`mail_cli.go:652`) as the shape, `waitForDomainVerified` (`:585`), `printPendingRecords` (`:782`), `domainStatus` (`:549`), `mailAPIError`/`apiMailError` (`:325`, `:312`), `requireMailAPISession`, `resolvedAccount`/`resolvedApp`, task 12's `printEmailInstanceStatus`.
- Produces:
  ```go
  func runEmailInstance(args []string) // enable|domains|rotate|teardown|status
  func (s *apiSession) emailInstanceDeclare(ctx context.Context, a apiAcct, app, host, envPrefix, from string) (apiEmailSender, error)
  func (s *apiSession) emailInstanceDomainEnsure(ctx context.Context, a apiAcct, app, host, domain string) (apiEmailDomainEnsureReply, error)
  func (s *apiSession) emailInstanceDomainVerify(ctx context.Context, a apiAcct, app, host, domain string) (apiEmailDomainVerifyReply, error)
  func (s *apiSession) emailInstanceEnable(ctx context.Context, a apiAcct, app, host string) (apiEmailSender, error)
  func (s *apiSession) emailInstanceRotate(ctx context.Context, a apiAcct, app, host string, finish bool) (apiEmailSender, error)
  func (s *apiSession) emailInstanceTeardown(ctx context.Context, a apiAcct, app, host string) error
  func runEmailInstanceEnableFlow(ctx context.Context, w io.Writer, sess *apiSession, acct apiAcct, app, host, envPrefix, from, domain string, timeout time.Duration) error
  ```

**Decision 3 of the spec is why this exists**: titogo's backend drives onboarding through the console API, "with the CLI as a wrapper over the same doors". Without this task the feature is reachable only by a program, and the operator debugging a customer's mail at two in the morning has curl. The wrapper adds no door and no authority: every verb here is one call to a door task 9 registered.

**`carlos email instance enable` is the one-command flow, shaped exactly like `runEmailEnableFlow`**: declare, attach the domain, print the DNS records the member must publish, poll until SES says verified, then enable. The two refusals from task 8 surface as the door's 409 body printed verbatim and exit 1 — they are sentences a person acts on, not failures to wrap in "could not enable email".

- [ ] **Step 0: Run the copy-review gate**

New strings: `emailUsage`'s new block, the flow's progress lines, and the teardown's confirmation. Reuse `runEmailEnableFlow`'s existing reviewed lines wherever the step is the same (the pending-records preamble, the "waiting for DNS" line), counting their occurrences at the base commit first.

- [ ] **Step 1: Write the failing test**

`mail_cli_test.go` (append):

```go
// The flow calls the instance doors in order and stops at the first
// refusal, printing the console's own sentence rather than a wrapper's.
func TestEmailInstanceEnableFlowStopsAtTheDoorsRefusal(t *testing.T) {
	var got []string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		got = append(got, r.Method+" "+r.URL.Path)
		switch {
		case strings.HasSuffix(r.URL.Path, "/email/enable"):
			w.WriteHeader(http.StatusConflict)
			fmt.Fprint(w, `{"error":"book.example.com runs as a child of the box agent"}`)
		default:
			writeTestJSON(t, w, map[string]any{"domain": map[string]any{"domain": "book.example.com", "kind": "custom",
				"regions": map[string]any{"eu-west-1": map[string]any{"status": "verified"}}}})
		}
	}))
	defer srv.Close()

	var buf bytes.Buffer
	err := runEmailInstanceEnableFlow(context.Background(), &buf, testSession(t, srv.URL), testAcct,
		"harbourmail", "book.example.com", "HARBOURMAIL_SMTP", "hello@book.example.com", "book.example.com", time.Second)
	if err == nil {
		t.Fatal("the flow reported success after the door refused")
	}
	if !strings.Contains(err.Error(), "child of the box agent") {
		t.Fatalf("the door's own sentence was swallowed: %v", err)
	}
	want := []string{
		"POST /api/cli/apps/bag/harbourmail/instances/book.example.com/email",
		"POST /api/cli/apps/bag/harbourmail/instances/book.example.com/email/domains",
		"POST /api/cli/apps/bag/harbourmail/instances/book.example.com/email/domains/book.example.com/verify",
		"POST /api/cli/apps/bag/harbourmail/instances/book.example.com/email/enable",
	}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("door order:\n got %q\nwant %q", got, want)
	}
}

// A host with a slash, or one that is not a hostname at all, never reaches
// a URL: the CLI refuses before it dials, the same discipline
// validAppName applies to --app.
func TestEmailInstanceRefusesAHostThatIsNotAHostname(t *testing.T) {
	for _, bad := range []string{"", "../channels", "a/b", strings.Repeat("x", 254)} {
		if err := validInstanceHostArg(bad); err == nil {
			t.Errorf("validInstanceHostArg(%q) = nil", bad)
		}
	}
}
```

- [ ] **Step 2: Run the test — expect failure**

```bash
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run EmailInstance -count=1
```

Expected: build failure, `undefined: runEmailInstanceEnableFlow`, `undefined: validInstanceHostArg`.

- [ ] **Step 3: Implement**

`mail_cli.go`: `runEmail`'s switch gains `case "instance": runEmailInstance(args[1:])`, and `emailUsage` gains the block describing the five sub-verbs. `runEmailInstance` dispatches `enable`, `domains`, `rotate`, `teardown` and `status` the way `runEmailCredentials` (`:1449`) dispatches its three, with `--app` and `--instance` on every one.

`validInstanceHostArg(host string) error` refuses before any URL is built: a hostname shape only, at most 253 bytes. The console refuses the same thing with `provision.ValidHostShape`, and having the CLI refuse first is what turns a typo into a sentence instead of a 404.

Each session method is one call, with `pathEscape` on the host and the domain, and `mailAPIError` on a non-2xx so `apiMailError`'s existing permanence logic still decides whether the flow retries.

`runEmailInstanceEnableFlow` mirrors `runEmailEnableFlow` step for step: declare, ensure the domain, print pending records when there are any, `waitForDomainVerified`, enable, then `printEmailInstanceStatus` of the sender the enable returned. The one difference is that a 409 from the enable door is returned to the caller as-is rather than retried: both of task 8's refusals are states a person has to change, and retrying either would just poll a machine that is not going to move on its own.

`carlos email pause` and `carlos email resume` (`:1632`) gain `--instance`, passing it straight into the pause door's new field.

- [ ] **Step 4: Run the test — expect success**

```bash
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -count=1
```

- [ ] **Step 5: Gate and commit**

```bash
gofmt -w mail_cli.go mail_cli_test.go
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
gofmt -l .
git commit -am "cli: carlos email instance, the wrapper over the instance doors (plan 5, task 13)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 14: One customer end to end, and the roll note

**Files:**
- Modify: `internal/console/apimailinstance_test.go` (the end-to-end test), `README.md` (the roll note)

**Interfaces:**
- Consumes: everything the previous thirteen tasks produced.
- Produces: no new name. One test that exercises the whole of §6 through the doors, and the operator-facing note that the roll needs.

**This task exists because every previous test proves one seam.** The failure this plan is built to prevent is not in any one of them: it is one customer's instance reading another customer's credential, which only shows up when two senders, two domains, two overlays and one app sender are all in play at once.

- [ ] **Step 1: Write the failing test**

`internal/console/apimailinstance_test.go` (append):

```go
// TWO CUSTOMERS, ONE APP, END TO END. Everything in §6 at once, through
// the doors and nothing else, asserting the one property the whole design
// exists for: what customer A's instance can read is not customer B's.
func TestTwoCustomersOnOneAppNeverShareACredential(t *testing.T) {
	fx := newMailInstanceFixture(t)
	ctx := context.Background()

	for _, host := range []string{"book.example.com", "shop.example.com"} {
		fx.do(t, "POST", fx.path(host, ""), `{"env_prefix":"HARBOURMAIL_SMTP","from":"hello@`+host+`"}`, 200)
		fx.do(t, "POST", fx.path(host, "/domains"), `{"domain":"`+host+`"}`, 200)
		fx.verifyDomain(t, host)
		fx.do(t, "POST", fx.path(host, "/enable"), ``, 200)
	}

	// 1. Each instance's overlay carries its own key and nobody else's.
	book := fx.overlay(t, "book.example.com")
	shop := fx.overlay(t, "shop.example.com")
	if book["HARBOURMAIL_SMTP_USER"] == shop["HARBOURMAIL_SMTP_USER"] {
		t.Fatal("both customers were delivered the same credential")
	}
	// 2. The default bundle carries none of it, so no sibling reads either.
	man, err := appconfig.Get(ctx, fx.rel, OpsAccount, "harbourmail")
	if err != nil {
		t.Fatal(err)
	}
	for name := range book {
		if _, ok := man.Vars[name]; ok {
			t.Fatalf("%s is in the app-wide bundle every instance renders", name)
		}
	}
	// 3. Each principal's policy names its own domain and not the other's.
	for _, tc := range []struct{ host, other string }{
		{"book.example.com", "shop.example.com"},
		{"shop.example.com", "book.example.com"},
	} {
		sn, _, err := mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", tc.host)
		if err != nil {
			t.Fatal(err)
		}
		pol, ok := fx.prov.UserPolicy(sn.Provisioned.IAMUser)
		if !ok {
			t.Fatalf("no policy for %s", sn.Provisioned.IAMUser)
		}
		if !strings.Contains(pol, tc.host) || strings.Contains(pol, tc.other) {
			t.Fatalf("%s's policy is not its own:\n%s", tc.host, pol)
		}
	}
	// 4. The app sender is off, per-instance, and its keys are gone.
	appSender, _, err := mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", mail.SenderAppID)
	if err != nil {
		t.Fatal(err)
	}
	if appSender.Provisioned.Active || appSender.Provisioned.PauseKind != mail.PauseKindPerInstance {
		t.Fatalf("the app sender is still armed: %+v", appSender.Provisioned)
	}
	if keys := fx.prov.Keys(appSender.Provisioned.IAMUser); len(keys) != 0 {
		t.Fatalf("the app-wide credential still has %d live keys", len(keys))
	}
	// 5. A cap pause on one customer leaves the other sending.
	fx.asOperator(t, "POST", "/api/cli/apps/"+OpsAccount+"/harbourmail/email/pause",
		`{"instance":"book.example.com","paused":true}`, 200)
	other, _, err := mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", "shop.example.com")
	if err != nil {
		t.Fatal(err)
	}
	if !other.Provisioned.Active {
		t.Fatal("pausing one customer stopped the other")
	}
	// 6. Tearing the first one down leaves the second whole, and the app
	//    stays in per-instance mode while any instance sender survives.
	fx.do(t, "DELETE", fx.path("book.example.com", ""), ``, 200)
	appSender, _, err = mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", mail.SenderAppID)
	if err != nil {
		t.Fatal(err)
	}
	if !appSender.PerInstance {
		t.Fatal("the app left per-instance mode while an instance sender was still live")
	}
	if _, _, found, err := appconfig.LoadInstanceConfig(ctx, fx.rel, OpsAccount, "harbourmail", "shop.example.com"); err != nil || !found {
		t.Fatalf("the surviving customer's config went with the other one's teardown: %v %v", found, err)
	}
}
```

- [ ] **Step 2: Run the test — expect failure**

```bash
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TwoCustomers -count=1
```

Expected: it passes on the first run if tasks 1 to 13 are right, and that is the point of writing it last. If it fails, the failure names which seam is wrong; fix that seam's own task rather than patching this test.

- [ ] **Step 3: Implement**

Nothing in the tree, unless step 2 found something. Then `README.md`, in the platform roll notes:

```markdown
### Rolling tenant email step 5 (instance senders)

Three things change for people who are not asking for instance senders,
and each is visible before anyone asks.

1. **A domain can no longer be attached to two apps in one account.**
   `carlos email domains add` now refuses a domain another app or
   instance in the same account already holds. Domains already attached
   keep working; this fires only on a new attach. An account that needs
   two apps on one domain needs a second domain.
2. **The first `carlos email instance enable` on an app switches that
   app's own credential off.** Its keys are deleted, not deactivated, and
   its `*_SMTP_*` variables are removed from the app's config bundle. Any
   instance still running on the app-wide credential stops sending at its
   next config render. This is per-instance mode, it is what the feature
   is, and `carlos email status --app` says so on the app's line.
3. **Every box republishes its snapshot with two new fields** (the config
   schema it renders and each route's uid shape) within one heartbeat,
   five minutes. Until a box has republished, `carlos email instance
   enable` for an instance on it refuses and names the box. That is the
   expected state for the first five minutes after the roll, not a fault.

**A box behind the fleet door cannot confirm delivery.**
`control/config-ack/` is outside that door's read and write scopes, so an
instance sender on such a box enables and sends, and its delivery reads as
not yet delivered forever. Granting the door that prefix is its own piece
of work.

**Roll order:** boxes first, then the console. A console rolled ahead of
the boxes refuses every instance enable with the version refusal, which is
correct but reads as a broken feature.
```

Run the `copy-review` gate on this section before it lands: it is operator-facing prose in the README, which is the same gate every user-facing string in this repo goes through.

- [ ] **Step 4: Run the full gate**

```bash
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
gofmt -l .
```

Cross-compile, because CI does:

```bash
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off GOOS=darwin GOARCH=arm64 go build ./... && \
GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off GOOS=windows GOARCH=amd64 go build ./...
```

- [ ] **Step 5: Commit and land**

```bash
gofmt -w internal/console/apimailinstance_test.go README.md 2>/dev/null; gofmt -l .
git commit -am "console: two customers on one app never share a credential, and the roll note (plan 5, task 14)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
git push amadan HEAD:worktree-tenant-email-senders
```

Then land it the way this repo lands work: `amadan branch merge`, never a GitHub PR, and never `gh issue create` for the follow-ups below — they are amadan discussions on `carlos/platform`.

---

## What this plan does not do

Each of these is a decision with a reason, not an omission.

- **No per-instance machine token.** §11 step 6, and deliberately after this. The instance doors are `Allow(svcrole.Mail)` and their caller is titogo's hub, so no customer instance ever calls them. Because `Allow` is the only thing that decides who gets in, making these doors reachable by a token bound to (account, app, instance) is later a change to a role set and nothing else. Filed with it, and not fixed here: the app-wide `CARLOS_INSTANCE_TOKEN` every customer instance already holds reaches the instance create, patch and delete doors, so one customer's code can already delete a sibling's instance.
- **No per-instance uid.** Ruling 4. The uid verdict this plan builds has three values and only `dynamic` passes, so an app that wants instance senders has to run unit-backed without a host_role grant. Making the nested isolated-exec layout pass needs a uid allocator and a root chown per migration, which the isolated-exec design already scoped and deferred. That is the follow-up discussion, and until it lands the refusal is honest rather than conservative.
- **No per-instance sending domain under the platform suffix.** §13 puts it out of scope, and nothing here works around it: an instance sender needs a custom domain it controls, or a registered address under §7's external identity. `platformNameOwnedBy` checks the label against the ACCOUNT, and an instance is not an account.
- **No address-sourced instance sender.** The record can carry one — `Source`, `Address` and `Identity` are on `Sender` from plans 2 and 3 — and `EnableInstance` refuses anything but `SourceDomain`. Plan 3's address branch delivers seven variables through `writeSMTPEnv` into the default bundle, so making it per-instance is a second delivery path through `ApplyInstance` and its own set of tests. It is a small piece of work and it is not this one.
- **No tenant pause verb.** §8 wants `UpdateReputationEntityCustomerManagedStatus` with the tenant ARN, and `GetReputationEntity` keeping `CustomerManagedStatus` and `AwsSesManagedStatus` apart so an SES-imposed pause is never lifted by the cap timer. Plan 2 deferred it to step 3, plan 3 deferred it again, and this plan does not pick it up: `SetKeyActive` is still the pause verb for every sender, which is what it already is. Instance senders make it more valuable, not more urgent — they do not change what it does.
- **No fleet-door grant for `control/config-ack/`.** An instance sender on a fleet-door box works and its delivery reads as unconfirmed. Widening that door is a change to a security boundary and belongs in its own review.
- **No `slices.Compact` on the policy ARN lists.** §12 records that every granted domain contributes the same account configuration-set ARN and nothing dedupes it, so a twelve-domain app is already over IAM's 2,048-character inline policy bound. Instance senders make it less likely to bite, not more: each sender's policy names its own domains only. It stays §12's work.
- **No console page.** Everything here is API and CLI. The app page has no per-instance email section, and adding one is a design job rather than a port of these doors.

## Self-review

**Spec coverage.** §6.3's one CAS write setting `PerInstance`, `Active = false` and `PauseKindPerInstance` together, the `ReapKeys(user, nil)` on the app sender and every standalone rather than `SetKeyActive` on recorded ids, the unset from the default bundle, the idempotent re-entry, `Enable`'s refusal under the flag and the leave-then-enable split: task 4, with the racing-enable test §9 asks for. §6.3's `grantedDomains` filtering on `Senders`, an instance claim never joining `Apps`, and everything that walked `rec.Apps` walking senders (`RefreshPolicies` from `EnsureDomain` and `VerifyDomain`, and `mailAppDomains`): task 2, with the #271 test. §6.3's transition doors — the first instance enable performs it, the last teardown leaves it, nothing else sets the flag: task 9. §6.4's seven doors at `Allow(svcrole.Mail)` with the `Instance` role pin untouched: task 9, pinned in `svcroutes_test.go`. §6.4's uid check with its three shapes and its refusal: tasks 7 and 8. §6.4's delivery check reading the box record: tasks 7 and 8. §6.4's pause, resume and cap staying operator-only through the existing door with an `instance` field: task 10. §6.4's `claimDomain` sender and the same-account refusal, and the orphan sweep matching on sender: task 3. §6.5's delivery comparison against the etag `ApplyInstance` returned: tasks 5, 9 and 11. §8's caps, pauses, counts and status per instance sender: tasks 10, 11, 12. §5's slug, names and caller index for an instance sender: tasks 1 and 5, over plan 2's `SenderNames` and `ResolveSenderNames`.

**Two changes since the spec was last revised, both absorbed.** The every-pass ack is gone (§6.5), so nothing in this plan asks the agent to rewrite an ack and the `InstanceConfigNotYetDelivered` constant is the one this plan names. And the capability question moved onto the box record, which ruling 2 builds as `ConfigSchema` beside `RouteUIDs` in task 7.

**Rulings, restated in one place.** (1) `claimDomain` refuses a same-account re-claim by a different `(app, sender)`, which ends cross-app domain sharing inside an account and is a roll note. (2) Both enable refusals read the box record, and task 7 adds the two fields that make them answerable, because a console reads only its own box's registry. (3) `EnsureDomain`, `Teardown` and `Enable` keep their signatures and delegate; `grantedDomains` and `RefreshPolicies` change shape because every caller is in this repo and a sender-blind version of either is the #271 failure. (4) The isolated-exec layout gives no per-instance uid, so only `UIDDynamic` passes and no nested branch may be added.

**Placeholder scan.** No `TODO`, no `...`, no invented API. Six things are named as checks rather than as code, and each says what to check and where: whether plan 2's task 12 already added `Enable`'s `PerInstance` refusal (task 4), whether `MemProvisioner` already has `UserPolicy` and `Keys` (tasks 2 and 4), whether plan 2 wired `DeleteCallerIndex` and the disassociate to the app sender's names specifically (task 6), whether `ApplyGrant` is still app-shaped (task 9), `CollectBox`'s real signature (task 7), and the four `svcroutes_test.go` tables that must stay untouched (task 9). All six are "read this function before you edit it", not gaps.

**Type consistency.** `InstanceSenderID`, `DeclareInstance`, `InstanceSenders`, `ErrNotInstanceSender`, `ErrInstanceHostMoved`, `ErrNotPerInstance`, `EnterPerInstance`, `LeavePerInstance`, `appSenderEnvUnsets`, `EnableInstance`, `EnvDeliveredInstance`, `writeSMTPEnvInstance`, `RotateInstance`, `RotateFinishInstance`, `TeardownInstance`, `senderNamesOf`, `SenderRef`, `SplitSenderRef`, `SenderRefs`, `HasSender`, `AddSenderRef`, `RemoveSenderRef`, `ErrDomainHeldBySender`, `EnsureDomainForSender`, `TeardownSender`, `routeUIDShape`, `routeUIDShapes`, `UIDDynamic`, `UIDApp`, `UIDAgent`, `instanceBox`, `instanceBoxFacts`, `refuseUnlessOwnUID`, `refuseUnlessLayerAgent`, `emailInstanceGate`, `emailInstanceReply`, `senderOut`, `mailSenderDomains`, `registerEmailInstanceRoutes`, `printEmailSenderLine`, `printEmailInstanceStatus`, `runEmailInstance`, `runEmailInstanceEnableFlow`, `validInstanceHostArg` are spelled identically in every task that produces or consumes them. Consumed from earlier plans and spelled as they are there: `Sender`, `SenderAppID`, `ScopeInstance`, `SourceDomain`, `SourceAddress`, `PauseKindPerInstance`, `ErrPerInstance`, `ValidSenderID`, `SenderNames`, `SenderNameSet`, `ResolveSenderNames`, `GetSender`, `PutSender`, `ListSenders`, `SetSenderPaused`, `ConfigSetARN`, `AssociateTenantResource`, `DisassociateTenantResource`, `DeleteCallerIndex`, `EnvNamesAddress`, `svcrole.Mail`, `mailWriteGates`, `ApplyInstance`, `DeleteInstanceConfig`, `LoadInstanceConfig`, `LoadMergedHost`, `ConfigAckSchema`, `LatestConfigAck`, `InstanceConfigDelivery`, `InstanceConfigDelivered`, `InstanceConfigStale`, `InstanceConfigNotYetDelivered`, `InstanceConfigUnknownAgent`.

**Every task leaves the tree green and shippable.** Tasks 1 to 7 add capability nobody can reach: there is no door onto any of it until task 9, and the two behaviour changes that do land earlier (task 3's claim refusal, task 2's sender-shaped policy walks) are exercised by the existing app-level tests unchanged, because an app sender's reference is exactly what a legacy record reads as. Task 7 changes what a box publishes and nothing that reads it. Tasks 8 to 14 build the door, and the last one proves the property the whole design exists for.
