carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Tenant Email, Plan 5 of 5: Instance senders
2
3> **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.
4
5**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.
6
7**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`.
8
9**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`.
10
11**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.
12
13**Four rulings, taken before the tasks and not to be reopened during them.**
14
151. **`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.
16
172. **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.
18
19 **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.
20
213. **`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.
22
234. **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.
24
25## Global Constraints
26
27- **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.
28- **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.
29- **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`.
30- **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`.
31- **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.
32- **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.
33- **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.
34- **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.
35- **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.
36- **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.
37- Commit after every task with the trailer `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`.
38
39---
40
41## File Structure
42
43| File | Responsibility |
44|---|---|
45| `internal/mail/instance.go` (create) | `InstanceSenderID`, `DeclareInstance`, `EnterPerInstance`, `LeavePerInstance`, `EnableInstance`, `RotateInstance`, `TeardownInstance`, `EnvDeliveredInstance`, `writeSMTPEnvInstance` |
46| `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 |
47| `internal/mail/domain.go` (modify) | `DomainRecord.Senders`, `SenderRef`, `SenderRefs`, `AddSenderRef`, `RemoveSenderRef` |
48| `internal/mail/domainclaim.go` (modify) | `DomainClaim.Sender`; `claimDomain` takes a sender and refuses a different `(app, sender)`; `ErrDomainHeldBySender` |
49| `internal/mail/walk.go` (modify) | `grantedDomains` and `RefreshPolicies` walk sender refs; `EnsureDomainForSender`; `TeardownSender`; `Enable` refuses under `PerInstance` |
50| `internal/status/snapshot.go` (modify) | `Probes.ConfigSchema`/`RouteUIDs`, `BoxMetrics.ConfigSchema`/`RouteUIDs`, both carried through `CollectBox` |
51| `internal/host/agent.go` (modify) | the status tick stamps `ConfigSchema` from `appconfig.ConfigAckSchema` and `RouteUIDs` from `routeUIDShape` |
52| `internal/host/metrics_loop.go` (modify) | `routeUIDShape(rt registry.Route) string`, the one place the uid verdict is computed |
53| `internal/console/apimailinstance.go` (create) | `emailInstanceGate`, `instanceBoxFacts`, `instanceOwnUID`, `instanceLayerAgent`, the seven handlers, `registerEmailInstanceRoutes` |
54| `internal/console/apimailinstance_test.go` (create) | the two refusals, the door list, a stranger's 404, the transition through the door |
55| `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 |
56| `internal/console/svcroutes_test.go` (modify) | the seven doors join `TestMailRoleReachesExactlyTheEmailDoors`'s `want` |
57| `mail_cli.go` (modify) | `apiEmailSender` gains its instance fields; `carlos email status --instance`; a line per instance sender |
58| `README.md` (modify) | the roll note: the claim refusal, the app credential reap, the ConfigSchema heartbeat |
59
60---
61
62### Task 1: The instance sender's record
63
64**Files:**
65- Create: `internal/mail/instance.go`, `internal/mail/instance_test.go`
66
67**Interfaces:**
68- 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`.
69- Produces:
70 ```go
71 func InstanceSenderID(host string) string // == provision.InstanceID(host), spelled here so internal/mail never imports internal/provision
72 func DeclareInstance(ctx context.Context, s release.Store, acct, app, senderID, host, envPrefix, from, actor string, now time.Time) (*Sender, error)
73 func InstanceSenders(ctx context.Context, s release.Store, acct, app string) ([]*Sender, error)
74 var ErrNotInstanceSender = errors.New("mail: that sender is not an instance sender")
75 var ErrInstanceHostMoved = errors.New("mail: an instance sender's host cannot change")
76 ```
77
78- [ ] **Step 1: Write the failing test**
79
80`internal/mail/instance_test.go` (create):
81
82```go
83package mail
84
85import (
86 "context"
87 "errors"
88 "testing"
89 "time"
90
91 "github.com/carlosframework/platform/internal/release"
92)
93
94// An instance sender is declared the way an app sender is, and carries the
95// three things an app sender never has: a scope, a host and a slug. The slug
96// is computed once, here, because nothing can parse a hash back and the
97// caller index (plan 2) is keyed on the name the slug produces.
98func TestDeclareInstanceWritesItsOwnSenderWithASlug(t *testing.T) {
99 ctx := context.Background()
100 s := release.NewDirStore(t.TempDir())
101 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
102 const host = "book.example.com"
103
104 sn, err := DeclareInstance(ctx, s, "bag", "harbourmail", InstanceSenderID(host), host,
105 "HARBOURMAIL_SMTP", "hello@book.example.com", "owner@example.test", now)
106 if err != nil {
107 t.Fatal(err)
108 }
109 if sn.Scope != ScopeInstance || sn.Source != SourceDomain || sn.Host != host {
110 t.Fatalf("declared sender is not an instance sender: %+v", sn)
111 }
112 if sn.ID != host {
113 t.Fatalf("sender id = %q, want the instance id of %q", sn.ID, host)
114 }
115 if sn.Slug == "" {
116 t.Fatal("an instance sender was declared with no slug, so nothing can name its IAM user")
117 }
118 // It lives under its own key, so two instances declaring at once never
119 // contend on one etag.
120 back, _, err := GetSender(ctx, s, "bag", "harbourmail", sn.ID)
121 if err != nil {
122 t.Fatal(err)
123 }
124 if back == nil || back.Slug != sn.Slug || back.Declared.From != "hello@book.example.com" {
125 t.Fatalf("round trip lost the sender: %+v", back)
126 }
127 // The app sender is untouched by an instance declaration.
128 app, _, err := GetSender(ctx, s, "bag", "harbourmail", SenderAppID)
129 if err != nil {
130 t.Fatal(err)
131 }
132 if app != nil {
133 t.Fatalf("declaring an instance sender wrote the app sender: %+v", app)
134 }
135}
136
137// The slug is stable across a re-declare, because it is the IAM user name
138// and the tenant name. Recomputing it on every declare would strand a
139// principal under the old name on every From change.
140func TestDeclareInstanceKeepsTheSlugAndRefusesAHostMove(t *testing.T) {
141 ctx := context.Background()
142 s := release.NewDirStore(t.TempDir())
143 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
144 const host = "book.example.com"
145 id := InstanceSenderID(host)
146
147 first, err := DeclareInstance(ctx, s, "bag", "harbourmail", id, host,
148 "HARBOURMAIL_SMTP", "hello@book.example.com", "owner@example.test", now)
149 if err != nil {
150 t.Fatal(err)
151 }
152 second, err := DeclareInstance(ctx, s, "bag", "harbourmail", id, host,
153 "HARBOURMAIL_SMTP", "sales@book.example.com", "owner@example.test", now)
154 if err != nil {
155 t.Fatal(err)
156 }
157 if second.Slug != first.Slug {
158 t.Fatalf("re-declare moved the slug %q -> %q, stranding the IAM user", first.Slug, second.Slug)
159 }
160 if _, err := DeclareInstance(ctx, s, "bag", "harbourmail", id, "other.example.com",
161 "HARBOURMAIL_SMTP", "hello@other.example.com", "owner@example.test", now); !errors.Is(err, ErrInstanceHostMoved) {
162 t.Fatalf("re-declare under a different host = %v, want ErrInstanceHostMoved", err)
163 }
164}
165
166// InstanceSenders is every instance sender of an app and never the app
167// sender, which is what the status reply and the teardown door both walk.
168func TestInstanceSendersExcludesTheAppSender(t *testing.T) {
169 ctx := context.Background()
170 s := release.NewDirStore(t.TempDir())
171 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
172 if _, err := PutSender(ctx, s, "bag", "harbourmail", &Sender{
173 ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain,
174 Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "x@y.com", At: now, By: "o"},
175 }, ""); err != nil {
176 t.Fatal(err)
177 }
178 for _, host := range []string{"book.example.com", "shop.example.com"} {
179 if _, err := DeclareInstance(ctx, s, "bag", "harbourmail", InstanceSenderID(host), host,
180 "HARBOURMAIL_SMTP", "hello@"+host, "owner@example.test", now); err != nil {
181 t.Fatal(err)
182 }
183 }
184 got, err := InstanceSenders(ctx, s, "bag", "harbourmail")
185 if err != nil {
186 t.Fatal(err)
187 }
188 if len(got) != 2 || got[0].ID != "book.example.com" || got[1].ID != "shop.example.com" {
189 t.Fatalf("InstanceSenders = %+v, want the two instance senders sorted by id", got)
190 }
191}
192```
193
194- [ ] **Step 2: Run the test — expect failure**
195
196```bash
197GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run TestDeclareInstance -count=1
198```
199
200Expected: build failure, `undefined: InstanceSenderID`, `undefined: DeclareInstance`, `undefined: InstanceSenders`, `undefined: ErrInstanceHostMoved`.
201
202- [ ] **Step 3: Implement**
203
204`internal/mail/instance.go` (create):
205
206```go
207// Instance senders (design §6.3, §6.4): one instance of an app with its own
208// From address, its own IAM principal, its own tenant, its own cap and its
209// own counts, delivered through the per-instance config layer and never
210// through the bundle every sibling of that instance reads.
211package mail
212
213import (
214 "context"
215 "crypto/sha256"
216 "encoding/hex"
217 "errors"
218 "fmt"
219 "sort"
220 "strings"
221 "time"
222
223 "github.com/carlosframework/platform/internal/appconfig"
224 "github.com/carlosframework/platform/internal/release"
225)
226
227var (
228 // ErrNotInstanceSender is an instance verb aimed at the app sender, or
229 // at a sender some other scope owns. The verbs in this file edit one
230 // instance's estate and must never reach the app's.
231 ErrNotInstanceSender = errors.New("mail: that sender is not an instance sender")
232 // ErrInstanceHostMoved is a re-declare naming a different host for an
233 // id that already exists. The id is derived from the host, so this can
234 // only mean a hash collision between two long hosts, and treating it as
235 // the same sender would deliver one instance's credential to another.
236 ErrInstanceHostMoved = errors.New("mail: an instance sender's host cannot change")
237)
238
239// maxInstanceID mirrors provision.MaxInstanceID. InstanceSenderID is spelled
240// here rather than delegating so internal/mail keeps its three imports:
241// internal/provision pulls in internal/registry and internal/controlname,
242// and a mail package that imported the provisioning layer would be a
243// dependency edge in the wrong direction (the console composes the two, and
244// nothing in internal/mail knows what an instance record is).
245//
246// The two implementations are pinned against each other by a test in
247// internal/console, where both packages are already in scope.
248const maxInstanceID = 40
249
250// InstanceSenderID is the sender id for a host: the host itself when it is
251// short enough to be its own identifier, and the same bounded hash form
252// provision.InstanceID produces otherwise. The underscore prefix is what
253// keeps the two forms in one namespace: no valid host contains one.
254func InstanceSenderID(host string) string {
255 if len(host) <= maxInstanceID {
256 return host
257 }
258 sum := sha256.Sum256([]byte(host))
259 return "_" + host[:22] + "-" + hex.EncodeToString(sum[:8])
260}
261
262// DeclareInstance records one instance's half: the env prefix it wants its
263// variables under and the From address it sends as. It is Declare's
264// counterpart, and it refuses the same things Declare refuses, for the same
265// reasons: the prefix is locked once delivery has happened, and the From
266// address is validated before anything is spent because it is delivered
267// verbatim and an unrenderable byte caught later would strand a key.
268//
269// The SLUG is computed once, here, and never again. It is the second half of
270// this sender's IAM user name and the whole of its tenant name, so
271// recomputing it on a later declare would leave a live principal under a
272// name no record points at. Plan 2's ResolveSenderNames owns the collision
273// case, at enable, where the caller index it writes can see one.
274func DeclareInstance(ctx context.Context, s release.Store, acct, app, senderID, host, envPrefix, from, actor string,
275 now time.Time) (*Sender, error) {
276
277 if !release.ValidAccount(acct) {
278 return nil, fmt.Errorf("mail: invalid account %q", acct)
279 }
280 if !release.ValidApp(app) {
281 return nil, fmt.Errorf("mail: invalid app %q", app)
282 }
283 if !ValidSenderID(senderID) || senderID == SenderAppID {
284 return nil, fmt.Errorf("mail: invalid instance sender id %q", senderID)
285 }
286 if host == "" || InstanceSenderID(host) != senderID {
287 return nil, fmt.Errorf("mail: sender id %q does not name host %q", senderID, host)
288 }
289 if envPrefix == "" {
290 envPrefix = DefaultEnvPrefix
291 }
292 if !ValidEnvPrefix(envPrefix) {
293 return nil, fmt.Errorf("mail: invalid env prefix %q — want ^[A-Z][A-Z0-9_]{0,31}$", envPrefix)
294 }
295 if !strings.Contains(from, "@") {
296 return nil, fmt.Errorf("mail: From %q is not an address", from)
297 }
298 if err := appconfig.ValidVarValue(from); err != nil {
299 return nil, fmt.Errorf("mail: From %q is not EnvironmentFile-safe: %w", from, err)
300 }
301
302 sn, etag, err := GetSender(ctx, s, acct, app, senderID)
303 if err != nil {
304 return nil, err
305 }
306 if sn == nil {
307 names, err := SenderNames(acct, app, senderID, "")
308 if err != nil {
309 return nil, err
310 }
311 sn = &Sender{ID: senderID, Slug: names.Slug, Scope: ScopeInstance, Source: SourceDomain, Host: host}
312 }
313 if sn.Scope != ScopeInstance {
314 return nil, ErrNotInstanceSender
315 }
316 if sn.Host != host {
317 return nil, fmt.Errorf("mail: sender %q is host %q: %w", senderID, sn.Host, ErrInstanceHostMoved)
318 }
319 if sn.Declared != nil && sn.Provisioned != nil && sn.Declared.EnvPrefix != envPrefix {
320 return nil, fmt.Errorf("mail: env prefix is %q and cannot move after delivery", sn.Declared.EnvPrefix)
321 }
322 sn.Declared = &Declared{EnvPrefix: envPrefix, From: from, At: now, By: actor}
323 if _, err := PutSender(ctx, s, acct, app, sn, etag); err != nil {
324 return nil, err
325 }
326 return sn, nil
327}
328
329// InstanceSenders is every instance sender an app has, sorted by id, and
330// never the app sender. The teardown door walks it to decide whether the
331// one it just removed was the last, and the status reply walks it to print
332// a line each.
333func InstanceSenders(ctx context.Context, s release.Store, acct, app string) ([]*Sender, error) {
334 all, err := ListSenders(ctx, s, acct, app)
335 if err != nil {
336 return nil, err
337 }
338 var out []*Sender
339 for _, sn := range all {
340 if sn != nil && sn.Scope == ScopeInstance {
341 out = append(out, sn)
342 }
343 }
344 sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
345 return out, nil
346}
347```
348
349- [ ] **Step 4: Run the test — expect success**
350
351```bash
352GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1
353```
354
355- [ ] **Step 5: Gate and commit**
356
357```bash
358gofmt -w internal/mail/instance.go internal/mail/instance_test.go
359GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
360GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
361GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
362gofmt -l .
363git add internal/mail/instance.go internal/mail/instance_test.go
364git commit -m "mail: declare an instance sender, with its own slug and key (plan 5, task 1)
365
366Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
367```
368
369---
370
371### Task 2: A domain record names senders, and the two policy walks follow them
372
373**Files:**
374- 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`
375
376**Interfaces:**
377- Consumes: plan 2's `SenderNames`, `SenderNameSet`, `GetSender`, `Sender.Salt`, `SenderAppID`; `UserName`/`StandaloneUserName`/`UserPath` (`policy.go`), `VerifiedARNs`/`VerifiedConfigSetARNs` (`domain.go:144`, `:164`), `Provisioner.EnsureUser`.
378- Produces:
379 ```go
380 // internal/mail/domain.go
381 // DomainRecord gains: Senders []string `json:"senders,omitempty"`
382 func SenderRef(app, sender string) string // "<app>/<sender>"
383 func SplitSenderRef(ref string) (app, sender string, ok bool)
384 func (r *DomainRecord) SenderRefs() []string // Senders, plus one app-sender ref per legacy Apps entry
385 func (r *DomainRecord) HasSender(app, sender string) bool
386 func (r *DomainRecord) AddSenderRef(app, sender string) bool
387 func (r *DomainRecord) RemoveSenderRef(app, sender string) bool
388 // internal/mail/walk.go
389 func grantedDomains(ctx context.Context, s release.Store, acct, app, sender string) ([]*DomainRecord, error) // unexported, gains sender
390 func RefreshPolicies(ctx context.Context, s release.Store, p Provisioner, acct string, refs []string) error // was apps []string
391 // internal/mail/instance.go
392 func senderNamesOf(acct, app string, sn *Sender) (SenderNameSet, error) // unexported
393 // internal/console/apimail.go
394 func (s *Server) mailAppDomains(ctx context.Context, acct *Account, app string) ([]*mail.DomainRecord, error) // now filters on the APP sender's ref
395 ```
396
397- [ ] **Step 1: Write the failing test**
398
399`internal/mail/domain_test.go` (append; create in package `mail` if it does not exist):
400
401```go
402// A record written before senders existed carries Apps and no Senders, and
403// must read as one app-sender reference per app. Without that, the first
404// RefreshPolicies after this release would find no principals for every
405// domain in the fleet and silently refresh nothing.
406func TestSenderRefsReadsALegacyRecordsAppsAsAppSenders(t *testing.T) {
407 rec := &DomainRecord{Domain: "x.com", Apps: []string{"harbourmail", "keymail"}}
408 want := []string{"harbourmail/app", "keymail/app"}
409 if got := rec.SenderRefs(); !reflect.DeepEqual(got, want) {
410 t.Fatalf("SenderRefs() = %q, want %q", got, want)
411 }
412 if !rec.HasSender("harbourmail", SenderAppID) || rec.HasSender("harbourmail", "book.example.com") {
413 t.Fatal("a legacy record answered HasSender for something other than its app senders")
414 }
415}
416
417// An instance sender joins Senders and NEVER joins Apps. Apps is what the
418// app sender's own policy is computed from (grantedDomains), so a customer
419// domain reaching it would put that customer's identity in the app-wide
420// credential every sibling instance holds.
421func TestAddSenderRefForAnInstanceNeverJoinsApps(t *testing.T) {
422 rec := &DomainRecord{Domain: "book.example.com"}
423 if !rec.AddSenderRef("harbourmail", "book.example.com") {
424 t.Fatal("AddSenderRef reported no change on a fresh record")
425 }
426 if len(rec.Apps) != 0 {
427 t.Fatalf("an instance sender's attach added to Apps: %q", rec.Apps)
428 }
429 if !rec.HasSender("harbourmail", "book.example.com") || rec.HasSender("harbourmail", SenderAppID) {
430 t.Fatalf("HasSender is wrong after the attach: %+v", rec)
431 }
432 // Idempotent, and the union with a legacy Apps entry is deduplicated.
433 rec.Apps = []string{"harbourmail"}
434 if rec.AddSenderRef("harbourmail", "book.example.com") {
435 t.Fatal("a second AddSenderRef reported a change")
436 }
437 want := []string{"harbourmail/app", "harbourmail/book.example.com"}
438 if got := rec.SenderRefs(); !reflect.DeepEqual(got, want) {
439 t.Fatalf("SenderRefs() = %q, want %q", got, want)
440 }
441 if !rec.RemoveSenderRef("harbourmail", "book.example.com") || rec.HasSender("harbourmail", "book.example.com") {
442 t.Fatalf("RemoveSenderRef left the reference behind: %+v", rec)
443 }
444}
445```
446
447`internal/mail/walk_lifecycle_test.go` (append):
448
449```go
450// THE #271 FAILURE, REACHED THROUGH THE NEW DOOR. VerifyDomain rebinds an
451// identity's configuration set and then refreshes every policy that names
452// the domain, from the record. Walking rec.Apps would find nothing for a
453// domain only an instance sender holds, and the instance's policy would go
454// on naming no configuration set: a 554 on every send it makes.
455func TestRefreshPoliciesReachesASenderThatIsNotInApps(t *testing.T) {
456 ctx, s, p, _, now := enableFixture(t)
457 rec := &DomainRecord{
458 Domain: "book.example.com", Kind: KindCustom,
459 Regions: map[string]*RegionState{"eu-west-1": {IdentityARN: "arn:aws:ses:eu-west-1:1:identity/book.example.com", ConfigSet: "carlos-bag", Status: StatusVerified}},
460 CreatedAt: now,
461 }
462 rec.AddSenderRef("harbourmail", "book.example.com")
463 if _, err := PutDomain(ctx, s, "bag", rec, ""); err != nil {
464 t.Fatal(err)
465 }
466 if _, err := DeclareInstance(ctx, s, "bag", "harbourmail", "book.example.com", "book.example.com",
467 "HARBOURMAIL_SMTP", "hello@book.example.com", "owner@example.test", now); err != nil {
468 t.Fatal(err)
469 }
470 sn, etag, err := GetSender(ctx, s, "bag", "harbourmail", "book.example.com")
471 if err != nil {
472 t.Fatal(err)
473 }
474 names, err := senderNamesOf("bag", "harbourmail", sn)
475 if err != nil {
476 t.Fatal(err)
477 }
478 sn.Provisioned = &Provisioned{IAMUser: names.IAMUser, KeyID: "AKIA9", Active: true, At: now}
479 if _, err := PutSender(ctx, s, "bag", "harbourmail", sn, etag); err != nil {
480 t.Fatal(err)
481 }
482
483 if err := RefreshPolicies(ctx, s, p, "bag", rec.SenderRefs()); err != nil {
484 t.Fatal(err)
485 }
486 pol, ok := p.UserPolicy(names.IAMUser)
487 if !ok {
488 t.Fatalf("no policy written for %s; the instance sender was never refreshed", names.IAMUser)
489 }
490 if !strings.Contains(pol, "configuration-set/carlos-bag") {
491 t.Fatalf("policy for %s names no configuration set, which is a 554 on every send:\n%s", names.IAMUser, pol)
492 }
493}
494
495// The app sender's policy can never name a domain only an instance holds.
496// grantedDomains is the one function that decides this, and it now filters
497// on the sender rather than on the app.
498func TestGrantedDomainsKeepsAnInstancesDomainOutOfTheAppSendersPolicy(t *testing.T) {
499 ctx, s, _, _, now := enableFixture(t)
500 rec := &DomainRecord{
501 Domain: "book.example.com", Kind: KindCustom,
502 Regions: map[string]*RegionState{"eu-west-1": {IdentityARN: "arn:aws:ses:eu-west-1:1:identity/book.example.com", Status: StatusVerified}},
503 CreatedAt: now,
504 }
505 rec.AddSenderRef("harbourmail", "book.example.com")
506 if _, err := PutDomain(ctx, s, "bag", rec, ""); err != nil {
507 t.Fatal(err)
508 }
509 got, err := grantedDomains(ctx, s, "bag", "harbourmail", SenderAppID)
510 if err != nil {
511 t.Fatal(err)
512 }
513 for _, d := range got {
514 if d.Domain == "book.example.com" {
515 t.Fatal("the app sender was granted a domain only an instance sender holds")
516 }
517 }
518 got, err = grantedDomains(ctx, s, "bag", "harbourmail", "book.example.com")
519 if err != nil || len(got) != 1 || got[0].Domain != "book.example.com" {
520 t.Fatalf("the instance sender was not granted its own domain: %+v %v", got, err)
521 }
522}
523```
524
525`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.
526
527- [ ] **Step 2: Run the test — expect failure**
528
529```bash
530GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run 'TestSenderRefs|TestAddSenderRef|TestRefreshPoliciesReaches|TestGrantedDomainsKeeps' -count=1
531```
532
533Expected: build failure, `undefined: SenderRef`, `rec.SenderRefs undefined`, and `too many arguments in call to grantedDomains`.
534
535- [ ] **Step 3: Implement**
536
537`internal/mail/domain.go`, on `DomainRecord`, beside `Apps`:
538
539```go
540 // Senders is every sender authorised to send from this domain, as
541 // "<app>/<sender>" references (design §6.3). It exists because Apps
542 // cannot express one: an instance sender is one of many inside an app,
543 // and an app-shaped grant would hand the app-wide credential every
544 // sibling instance holds an identity that belongs to one customer.
545 //
546 // omitempty, and absent on every record written before this release —
547 // SenderRefs reads such a record's Apps as one app-sender reference
548 // each, so nothing needs backfilling and a rolled-back console reads
549 // what it always read.
550 Senders []string `json:"senders,omitempty"`
551```
552
553and, after `RemoveApp`:
554
555```go
556// SenderRef is how a sender is named inside a domain record: the app, a
557// slash, the sender id. The slash is safe as a separator because ValidApp
558// forbids one and ValidSenderID forbids one.
559func SenderRef(app, sender string) string { return app + "/" + sender }
560
561// SplitSenderRef is SenderRef's inverse. ok is false for anything that is
562// not exactly one slash with a non-empty side each — a malformed reference
563// is skipped by every caller rather than guessed at, because guessing would
564// write a policy for a principal nobody named.
565func SplitSenderRef(ref string) (string, string, bool) {
566 app, sender, found := strings.Cut(ref, "/")
567 if !found || app == "" || sender == "" || strings.Contains(sender, "/") {
568 return "", "", false
569 }
570 return app, sender, true
571}
572
573// SenderRefs is every sender this domain grants, sorted and deduplicated,
574// with each legacy Apps entry read as that app's APP sender. That fallback
575// is the whole migration: nothing rewrites a domain record, and the first
576// RefreshPolicies after this release must still find every principal it
577// found before it.
578func (r *DomainRecord) SenderRefs() []string {
579 seen := map[string]bool{}
580 var out []string
581 add := func(ref string) {
582 if _, _, ok := SplitSenderRef(ref); !ok || seen[ref] {
583 return
584 }
585 seen[ref] = true
586 out = append(out, ref)
587 }
588 for _, a := range r.Apps {
589 add(SenderRef(a, SenderAppID))
590 }
591 for _, ref := range r.Senders {
592 add(ref)
593 }
594 sort.Strings(out)
595 return out
596}
597
598// HasSender reports whether this domain grants one sender.
599func (r *DomainRecord) HasSender(app, sender string) bool {
600 want := SenderRef(app, sender)
601 for _, ref := range r.SenderRefs() {
602 if ref == want {
603 return true
604 }
605 }
606 return false
607}
608
609// AddSenderRef records a sender as a referrer. Reports whether it changed
610// anything. It NEVER touches Apps: an app sender's attach calls AddApp too
611// (EnsureDomainForSender does both), and an instance sender's attach must
612// not, or grantedDomains would hand the app-wide credential a customer's
613// identity.
614func (r *DomainRecord) AddSenderRef(app, sender string) bool {
615 if r.HasSender(app, sender) {
616 return false
617 }
618 r.Senders = append(r.Senders, SenderRef(app, sender))
619 sort.Strings(r.Senders)
620 return true
621}
622
623// RemoveSenderRef drops a referrer from Senders. An app sender recorded only
624// in the legacy Apps list is removed by RemoveApp, which is what
625// TeardownSender calls for it.
626func (r *DomainRecord) RemoveSenderRef(app, sender string) bool {
627 want := SenderRef(app, sender)
628 for i, ref := range r.Senders {
629 if ref == want {
630 r.Senders = append(r.Senders[:i], r.Senders[i+1:]...)
631 return true
632 }
633 }
634 return false
635}
636```
637
638`internal/mail/instance.go`, append:
639
640```go
641// senderNamesOf recomputes one sender's AWS names from the record, and
642// refuses when the computed slug disagrees with the stored one.
643//
644// Recomputing rather than reading Provisioned.IAMUser is the discipline
645// RefreshPolicies already follows for standalone credentials, and the
646// refusal is what makes it safe: if the naming rule ever moved, the old
647// behaviour would quietly write a policy for a brand new principal and
648// leave the live one stale, which presents as "this sender stopped working
649// and nothing changed". Failing loudly here turns that into one error with
650// the two names in it.
651func senderNamesOf(acct, app string, sn *Sender) (SenderNameSet, error) {
652 names, err := SenderNames(acct, app, sn.ID, sn.Salt)
653 if err != nil {
654 return SenderNameSet{}, err
655 }
656 if names.Slug != sn.Slug {
657 return SenderNameSet{}, fmt.Errorf(
658 "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",
659 app, sn.ID, sn.Slug, names.Slug)
660 }
661 return names, nil
662}
663```
664
665`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.
666
667`internal/mail/walk.go`, `RefreshPolicies`: the parameter becomes `refs []string` and the body iterates sender references rather than apps.
668
669```go
670func RefreshPolicies(ctx context.Context, s release.Store, p Provisioner, acct string, refs []string) error {
671 for _, ref := range refs {
672 app, sender, ok := SplitSenderRef(ref)
673 if !ok {
674 // A malformed reference names no principal. Skipping is the
675 // only safe direction: inventing an app or a sender from it
676 // would write a policy for somebody who never asked for one.
677 log.Printf("mail: refresh policies: skipping malformed sender reference %q", ref)
678 continue
679 }
680 sn, _, err := GetSender(ctx, s, acct, app, sender)
681 if err != nil {
682 return err
683 }
684 if sn == nil || sn.Declared == nil {
685 continue
686 }
687 if sn.Provisioned == nil && len(sn.Standalone) == 0 {
688 continue
689 }
690 domains, err := grantedDomains(ctx, s, acct, app, sender)
691 if err != nil {
692 return err
693 }
694 if len(domains) == 0 {
695 continue
696 }
697 var arns, configSets, names []string
698 for _, dom := range domains {
699 arns = append(arns, dom.VerifiedARNs()...)
700 configSets = append(configSets, dom.VerifiedConfigSetARNs()...)
701 names = append(names, dom.Domain)
702 }
703 sort.Strings(arns)
704 sort.Strings(configSets)
705 sort.Strings(names)
706 if len(arns) == 0 {
707 // Granted domains exist but none is verified in any region, so
708 // SendPolicyJSON would refuse anyway. Nothing to write.
709 continue
710 }
711 nameSet, err := senderNamesOf(acct, app, sn)
712 if err != nil {
713 return err
714 }
715 if sn.Provisioned != nil {
716 if _, err := p.EnsureUser(ctx, nameSet.IAMUser, UserPath, arns, configSets, names); err != nil {
717 return fmt.Errorf("mail: refresh policy for %s: %w", nameSet.IAMUser, err)
718 }
719 }
720 for _, sa := range sn.Standalone {
721 user, err := nameSet.StandaloneUser(acct, app, sa.Name)
722 if err != nil {
723 return err
724 }
725 if _, err := p.EnsureUser(ctx, user, UserPath, arns, configSets, names); err != nil {
726 return fmt.Errorf("mail: refresh policy for %s: %w", user, err)
727 }
728 }
729 }
730 return nil
731}
732```
733
734Add `"log"` to `walk.go`'s imports. `EnsureDomain`'s call becomes `RefreshPolicies(ctx, s, p, acct, rec.SenderRefs())` and `VerifyDomain`'s rebound branch the same.
735
736**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.
737
738`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.
739
740- [ ] **Step 4: Run the test — expect success**
741
742```bash
743GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ ./internal/console/ -count=1
744```
745
746- [ ] **Step 5: Gate and commit**
747
748```bash
749gofmt -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
750GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
751GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
752GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
753gofmt -l .
754git commit -am "mail: a domain record names senders, and both policy walks follow them (plan 5, task 2)
755
756Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
757```
758
759---
760
761### Task 3: The claim carries the sender, and an instance never attaches a sibling's domain
762
763**Files:**
764- Modify: `internal/mail/domainclaim.go` (`DomainClaim`, `claimDomain`, `releaseDomainClaim`), `internal/mail/walk.go` (`EnsureDomain` -> `EnsureDomainForSender`, `Teardown` -> `TeardownSender`), `internal/mail/walk_domainclaim_test.go`
765
766**Interfaces:**
767- 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`.
768- Produces:
769 ```go
770 // DomainClaim gains: Sender string `json:"sender,omitempty"`
771 var ErrDomainHeldBySender = errors.New("mail: domain is held by another sender in this account")
772 func claimDomain(ctx context.Context, s release.Store, domain, acct, app, sender string, now time.Time) error // unexported, gains sender
773 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)
774 func EnsureDomain(...) // unchanged signature, delegates with SenderAppID
775 func TeardownSender(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer, d Deployment, acct, app, sender string) error
776 func Teardown(...) // unchanged signature, delegates with SenderAppID
777 ```
778
779- [ ] **Step 1: Write the failing test**
780
781`internal/mail/walk_domainclaim_test.go` (append):
782
783```go
784// THE HOLE §6.4 CLOSES. Two instances of one app, in one account. The
785// second attaches the first's already-verified domain, and today the claim
786// says yes (same account, so the re-claim is a silent no-op) and the second
787// instance inherits an identity it proved nothing about.
788func TestClaimDomainRefusesAnotherSendersDomainInsideOneAccount(t *testing.T) {
789 ctx := context.Background()
790 s := release.NewDirStore(t.TempDir())
791 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
792
793 if err := claimDomain(ctx, s, "book.example.com", "bag", "harbourmail", "book.example.com", now); err != nil {
794 t.Fatal(err)
795 }
796 // The same sender re-claiming is idempotent success: EnsureDomain is
797 // re-run on every attach and must never be refused by the gate it holds.
798 if err := claimDomain(ctx, s, "book.example.com", "bag", "harbourmail", "book.example.com", now); err != nil {
799 t.Fatalf("a re-claim by the holder was refused: %v", err)
800 }
801 // A sibling instance of the same app, in the same account, is refused.
802 if err := claimDomain(ctx, s, "book.example.com", "bag", "harbourmail", "shop.example.com", now); !errors.Is(err, ErrDomainHeldBySender) {
803 t.Fatalf("a sibling instance's claim = %v, want ErrDomainHeldBySender", err)
804 }
805 // So is the app sender of the same app, and a second app in the account.
806 if err := claimDomain(ctx, s, "book.example.com", "bag", "harbourmail", SenderAppID, now); !errors.Is(err, ErrDomainHeldBySender) {
807 t.Fatalf("the app sender's claim = %v, want ErrDomainHeldBySender", err)
808 }
809 if err := claimDomain(ctx, s, "book.example.com", "bag", "keymail", SenderAppID, now); !errors.Is(err, ErrDomainHeldBySender) {
810 t.Fatalf("a second app's claim = %v, want ErrDomainHeldBySender", err)
811 }
812 // Another account is still the neutral refusal that never names a holder.
813 if err := claimDomain(ctx, s, "book.example.com", "jam", "jelly", SenderAppID, now); !errors.Is(err, ErrDomainClaimed) {
814 t.Fatalf("a stranger's claim = %v, want ErrDomainClaimed", err)
815 }
816}
817
818// A claim written before this release carries no Sender. It belongs to that
819// app's APP sender, which is the only sender that existed, so a re-attach by
820// that app keeps working and nothing in the fleet has to be backfilled.
821func TestALegacyClaimBelongsToTheAppSender(t *testing.T) {
822 ctx := context.Background()
823 s := release.NewDirStore(t.TempDir())
824 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
825
826 body, err := json.Marshal(DomainClaim{Schema: 1, Domain: "x.com", Account: "bag", App: "harbourmail", CreatedAt: now})
827 if err != nil {
828 t.Fatal(err)
829 }
830 if _, err := s.Put(ctx, domainClaimKey("x.com"), body, ""); err != nil {
831 t.Fatal(err)
832 }
833 if err := claimDomain(ctx, s, "x.com", "bag", "harbourmail", SenderAppID, now); err != nil {
834 t.Fatalf("a legacy claim refused its own app sender: %v", err)
835 }
836 if err := claimDomain(ctx, s, "x.com", "bag", "harbourmail", "book.example.com", now); !errors.Is(err, ErrDomainHeldBySender) {
837 t.Fatalf("an instance sender took a legacy app claim: %v", err)
838 }
839}
840
841// EnsureDomainForSender records an instance sender in Senders and never in
842// Apps, and the orphan-claim sweep in TeardownSender only ever releases a
843// claim THIS sender created.
844func TestEnsureDomainForSenderRecordsTheSenderAndSweepsOnlyItsOwnOrphan(t *testing.T) {
845 ctx, s, p, _, now := enableFixture(t)
846 rec, _, err := EnsureDomainForSender(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(),
847 "bag", "harbourmail", "book.example.com", "book.example.com", now)
848 if err != nil {
849 t.Fatal(err)
850 }
851 if len(rec.Apps) != 0 {
852 t.Fatalf("an instance sender's attach joined Apps: %q", rec.Apps)
853 }
854 if !rec.HasSender("harbourmail", "book.example.com") {
855 t.Fatalf("the sender was not recorded: %+v", rec)
856 }
857
858 // An orphan this sender never created survives its teardown.
859 if err := claimDomain(ctx, s, "shop.example.com", "bag", "harbourmail", "shop.example.com", now); err != nil {
860 t.Fatal(err)
861 }
862 if err := TeardownSender(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "bag", "harbourmail", "book.example.com"); err != nil {
863 t.Fatal(err)
864 }
865 got, err := getDomainClaim(ctx, s, "shop.example.com")
866 if err != nil || got == nil {
867 t.Fatalf("a sibling sender's orphan claim was swept: %+v %v", got, err)
868 }
869 if got, err := getDomainClaim(ctx, s, "book.example.com"); err != nil || got != nil {
870 t.Fatalf("this sender's own claim survived its teardown: %+v %v", got, err)
871 }
872}
873```
874
875- [ ] **Step 2: Run the test — expect failure**
876
877```bash
878GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run 'TestClaimDomainRefuses|TestALegacyClaim|TestEnsureDomainForSender' -count=1
879```
880
881Expected: build failure, `not enough arguments in call to claimDomain`, `undefined: ErrDomainHeldBySender`, `undefined: EnsureDomainForSender`, `undefined: TeardownSender`.
882
883- [ ] **Step 3: Implement**
884
885`internal/mail/domainclaim.go`, on `DomainClaim`, after `App`:
886
887```go
888 // Sender is the sender that holds the claim, inside App. omitempty,
889 // and absent on every claim written before this release: an empty
890 // Sender reads as SenderAppID, because the app sender is the only
891 // sender that existed when such a claim was written. Nothing is
892 // backfilled and a rolled-back console reads what it always read.
893 Sender string `json:"sender,omitempty"`
894```
895
896and a new error beside `ErrDomainClaimed`:
897
898```go
899// ErrDomainHeldBySender is a claim on a domain this ACCOUNT already holds,
900// through a different sender. It names no holder either, for the same
901// reason ErrDomainClaimed names none, and it is deliberately a different
902// error so the console can say "another app or instance in this account"
903// rather than the cross-account sentence, which would be a lie.
904//
905// This is a behaviour change (design §6.4): before it, any re-claim by the
906// holder's own account was a silent no-op, which is how two apps in one
907// account came to share a domain, and is also how one instance sender would
908// have attached a sibling's already-verified identity with no proof of its
909// own. Domains already attached keep working — this only ever fires on a new
910// attach.
911var ErrDomainHeldBySender = errors.New("mail: domain is held by another sender in this account")
912```
913
914`claimDomain` takes `sender string` after `app` and writes it into the body. Its judging tail becomes:
915
916```go
917 if cur.Account != acct {
918 return ErrDomainClaimed
919 }
920 // An empty Sender on a stored claim is the app sender: that is the
921 // only sender that existed when it was written.
922 held := cur.Sender
923 if held == "" {
924 held = SenderAppID
925 }
926 if cur.App != app || held != sender {
927 return ErrDomainHeldBySender
928 }
929 return nil // already ours: idempotent no-op
930```
931
932`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:
933
934```go
935// EnsureDomain is EnsureDomainForSender for the app sender. Every caller
936// outside this package still calls this, so the whole console and CLI are
937// untouched by senders existing.
938func EnsureDomain(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer,
939 d Deployment, acct, app, domain string, now time.Time) (*DomainRecord, []dnsrec.Record, error) {
940 return EnsureDomainForSender(ctx, s, p, w, d, acct, app, SenderAppID, domain, now)
941}
942```
943
944- 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;
945- `rec.AddApp(app)` becomes:
946
947```go
948 // The APP sender joins Apps as well as Senders, because Apps is what a
949 // console rolled back to the previous release reads, and because
950 // RemoveApp is still how an app sender leaves. An INSTANCE sender joins
951 // Senders alone: Apps is what grantedDomains computes the app-wide
952 // credential's policy from, and a customer's identity reaching it would
953 // be delivered to every sibling instance (design §6.3).
954 if sender == SenderAppID {
955 rec.AddApp(app)
956 }
957 rec.AddSenderRef(app, sender)
958```
959
960- `RefreshPolicies(ctx, s, p, acct, rec.Apps)` is already `rec.SenderRefs()` from task 2;
961- the platform-name ownership check and plan 3's external-identity refusal are untouched, and both still run before the claim.
962
963`internal/mail/walk.go`, `Teardown` -> `TeardownSender` with `sender string`, and `Teardown` as the app-sender wrapper. Four changes inside:
964
965- 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`;
966- 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;
967- 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;
968- 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.
969
970**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*.
971
972- [ ] **Step 4: Run the test — expect success**
973
974```bash
975GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ ./internal/console/ -count=1
976```
977
978- [ ] **Step 5: Gate and commit**
979
980```bash
981gofmt -w internal/mail/domainclaim.go internal/mail/walk.go internal/mail/walk_domainclaim_test.go
982GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
983GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
984GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
985gofmt -l .
986git commit -am "mail: the domain claim carries its sender, and refuses a sibling's re-claim (plan 5, task 3)
987
988Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
989```
990
991---
992
993### Task 4: Entering and leaving per-instance mode
994
995**Files:**
996- Modify: `internal/mail/instance.go`, `internal/mail/walk.go` (`Enable`'s refusal), `internal/mail/instance_test.go`
997
998**Interfaces:**
999- 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).
1000- Produces:
1001 ```go
1002 func EnterPerInstance(ctx context.Context, s release.Store, p Provisioner, acct, app string, now time.Time) (*Sender, error)
1003 func LeavePerInstance(ctx context.Context, s release.Store, acct, app string, now time.Time) (*Sender, error)
1004 var ErrNotPerInstance = errors.New("mail: this app is not in per-instance mode")
1005 func appSenderEnvUnsets(sn *Sender) []string // unexported
1006 ```
1007
1008- [ ] **Step 1: Write the failing test**
1009
1010`internal/mail/instance_test.go` (append):
1011
1012```go
1013// The transition is one CAS write, then a reap, then the unset. Order
1014// matters: the flag has to be durable before any key is destroyed, or a
1015// crash between the two leaves an app with no credential and no record
1016// saying why.
1017func TestEnterPerInstanceStampsTheFlagReapsAndUnsets(t *testing.T) {
1018 ctx, s, p, pub, now := enableFixture(t)
1019 if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
1020 t.Fatal(err)
1021 }
1022 if _, err := CreateStandalone(ctx, s, p, testDeployment(), "bag", "harbourmail", "laptop", "o@example.test", now); err != nil {
1023 t.Fatal(err)
1024 }
1025
1026 sn, err := EnterPerInstance(ctx, s, p, "bag", "harbourmail", now)
1027 if err != nil {
1028 t.Fatal(err)
1029 }
1030 if !sn.PerInstance || sn.Provisioned.Active || sn.Provisioned.PauseKind != PauseKindPerInstance {
1031 t.Fatalf("the transition did not stamp the app sender: %+v", sn.Provisioned)
1032 }
1033 // DELETED, not deactivated. An enable racing the transition mints and
1034 // DELIVERS before its own CAS Put, so deactivating the ids the record
1035 // names would leave an active key nothing ever reaps in every
1036 // instance's bundle (design §6.3).
1037 names, err := senderNamesOf("bag", "harbourmail", sn)
1038 if err != nil {
1039 t.Fatal(err)
1040 }
1041 if keys := p.Keys(names.IAMUser); len(keys) != 0 {
1042 t.Fatalf("the app sender still holds %d keys after the transition", len(keys))
1043 }
1044 saUser, err := names.StandaloneUser("bag", "harbourmail", "laptop")
1045 if err != nil {
1046 t.Fatal(err)
1047 }
1048 if keys := p.Keys(saUser); len(keys) != 0 {
1049 t.Fatalf("the standalone credential still holds %d keys after the transition", len(keys))
1050 }
1051 // And the app's own bundle no longer carries a credential for anyone.
1052 man, err := appconfig.Get(ctx, s, "bag", "harbourmail")
1053 if err != nil {
1054 t.Fatal(err)
1055 }
1056 for _, name := range appSenderEnvUnsets(sn) {
1057 if _, ok := man.Vars[name]; ok {
1058 t.Errorf("%s is still delivered to every instance of this app", name)
1059 }
1060 }
1061}
1062
1063// THE RACE §6.3 NAMES. An enable that has already minted and delivered,
1064// interleaved with the transition, must leave no usable credential: the
1065// reap deletes whatever keys exist, so the delivered variables are inert
1066// even though they landed.
1067func TestAnEnableRacingTheTransitionLeavesNoUsableAppCredential(t *testing.T) {
1068 ctx, s, p, pub, now := enableFixture(t)
1069 if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
1070 t.Fatal(err)
1071 }
1072 sn, _, err := GetSender(ctx, s, "bag", "harbourmail", SenderAppID)
1073 if err != nil {
1074 t.Fatal(err)
1075 }
1076 names, err := senderNamesOf("bag", "harbourmail", sn)
1077 if err != nil {
1078 t.Fatal(err)
1079 }
1080 // The racing enable's half: a key minted and delivered, with no record
1081 // naming it yet, exactly as walk.go mints and writes before its Put.
1082 keyID, secret, err := p.MintKey(ctx, names.IAMUser)
1083 if err != nil {
1084 t.Fatal(err)
1085 }
1086 if err := writeSMTPEnv(ctx, s, pub, "bag", "harbourmail", "HARBOURMAIL_SMTP",
1087 testDeployment().Region, keyID, secret, sn.Declared.From, sn); err != nil {
1088 t.Fatal(err)
1089 }
1090 if _, err := EnterPerInstance(ctx, s, p, "bag", "harbourmail", now); err != nil {
1091 t.Fatal(err)
1092 }
1093 if keys := p.Keys(names.IAMUser); len(keys) != 0 {
1094 t.Fatalf("the unrecorded key the racing enable minted survived: %q", keys)
1095 }
1096}
1097
1098// Re-entering converges rather than refusing: a crash between the CAS and
1099// the reap has to be repairable by running the transition again.
1100func TestEnterPerInstanceIsIdempotent(t *testing.T) {
1101 ctx, s, p, pub, now := enableFixture(t)
1102 if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
1103 t.Fatal(err)
1104 }
1105 if _, err := EnterPerInstance(ctx, s, p, "bag", "harbourmail", now); err != nil {
1106 t.Fatal(err)
1107 }
1108 if _, err := EnterPerInstance(ctx, s, p, "bag", "harbourmail", now); err != nil {
1109 t.Fatalf("a second transition was refused: %v", err)
1110 }
1111 // The app sender's own enable is refused under the flag: carrying the
1112 // false Active forward would mint a key that is dead on arrival.
1113 if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); !errors.Is(err, ErrPerInstance) {
1114 t.Fatalf("Enable in per-instance mode = %v, want ErrPerInstance", err)
1115 }
1116 // Leaving clears all three, and only then does Enable work again.
1117 sn, err := LeavePerInstance(ctx, s, "bag", "harbourmail", now)
1118 if err != nil {
1119 t.Fatal(err)
1120 }
1121 if sn.PerInstance || !sn.Provisioned.Active || sn.Provisioned.PauseKind != "" {
1122 t.Fatalf("leaving left the app sender in %+v", sn.Provisioned)
1123 }
1124 if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
1125 t.Fatalf("Enable after leaving per-instance mode: %v", err)
1126 }
1127}
1128```
1129
1130`MemProvisioner` needs `Keys(user string) []string`, the live key ids for a principal. Add it if plan 2's reap tests did not.
1131
1132- [ ] **Step 2: Run the test — expect failure**
1133
1134```bash
1135GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run 'PerInstance' -count=1
1136```
1137
1138Expected: build failure, `undefined: EnterPerInstance`, `undefined: LeavePerInstance`, `undefined: appSenderEnvUnsets`.
1139
1140- [ ] **Step 3: Implement**
1141
1142`internal/mail/instance.go`, append:
1143
1144```go
1145// ErrNotPerInstance is an instance verb against an app that never entered
1146// per-instance mode. Instance Enable requires the flag, because until it is
1147// set every instance of this app is still holding the app-wide credential,
1148// and a per-instance credential delivered alongside it would isolate
1149// nothing.
1150var ErrNotPerInstance = errors.New("mail: this app is not in per-instance mode")
1151
1152// appSenderEnvUnsets is every variable the APP sender delivers into the
1153// default bundle: the five SMTP names, plus plan 3's configuration-set and
1154// tenant names when this sender sends under a borrowed identity. Computed
1155// from the sender rather than assumed, because an address sender that kept
1156// two variables standing would leave every instance of the app naming
1157// CARLOS's configuration set with no credential to go with it.
1158func appSenderEnvUnsets(sn *Sender) []string {
1159 if sn == nil || sn.Declared == nil {
1160 return nil
1161 }
1162 host, port, user, pass, from := EnvNames(sn.Declared.EnvPrefix)
1163 out := []string{host, port, user, pass, from}
1164 if sn.Source == SourceAddress {
1165 configSet, tenant := EnvNamesAddress(sn.Declared.EnvPrefix)
1166 out = append(out, configSet, tenant)
1167 }
1168 sort.Strings(out)
1169 return out
1170}
1171
1172// EnterPerInstance moves an app from one shared credential to one per
1173// instance (design §6.3). The order is the whole of its correctness:
1174//
1175// 1. ONE CAS write on senders/app.json that sets PerInstance, Active =
1176// false and PauseKind = PauseKindPerInstance together. A prefix listing
1177// cannot decide this without racing an app-sender enable, and the three
1178// fields must land together or a status read between them would report
1179// a state that was never intended. PauseKindPerInstance is what stops
1180// resumeStaleCapPause re-arming the credential every instance still
1181// holds on the next UTC day roll.
1182// 2. ReapKeys(user, nil) on the app sender's principal and on every
1183// standalone principal. LIST AND DELETE, never SetKeyActive on the ids
1184// the record names: Enable mints and DELIVERS a key before its own CAS
1185// Put, so an enable racing this transition leaves an active key in
1186// every instance's bundle that no record names and nothing would ever
1187// reap. Deleting whatever keys exist makes that delivery inert.
1188// 3. Unset the app sender's variables from the default bundle, so an
1189// instance that keeps running is left with nothing rather than with a
1190// credential that has been silently destroyed underneath it.
1191//
1192// Idempotent by construction: re-entering skips the CAS when the flag is
1193// already set and re-runs the reap and the unset, so a crash anywhere in
1194// the sequence is repaired by running it again. Instance Enable re-runs
1195// step 2 for the same reason.
1196func EnterPerInstance(ctx context.Context, s release.Store, p Provisioner, acct, app string,
1197 now time.Time) (*Sender, error) {
1198
1199 sn, etag, err := GetSender(ctx, s, acct, app, SenderAppID)
1200 if err != nil {
1201 return nil, err
1202 }
1203 if sn == nil || sn.Declared == nil {
1204 return nil, ErrNotDeclared
1205 }
1206 if !sn.PerInstance {
1207 sn.PerInstance = true
1208 if sn.Provisioned != nil {
1209 sn.Provisioned.Active = false
1210 sn.Provisioned.PauseKind = PauseKindPerInstance
1211 sn.Provisioned.At = now
1212 }
1213 if _, err := PutSender(ctx, s, acct, app, sn, etag); err != nil {
1214 return nil, err
1215 }
1216 }
1217
1218 names, err := senderNamesOf(acct, app, sn)
1219 if err != nil {
1220 return nil, err
1221 }
1222 users := []string{}
1223 if sn.Provisioned != nil {
1224 users = append(users, names.IAMUser)
1225 }
1226 for _, sa := range sn.Standalone {
1227 user, err := names.StandaloneUser(acct, app, sa.Name)
1228 if err != nil {
1229 return nil, err
1230 }
1231 users = append(users, user)
1232 }
1233 for _, user := range users {
1234 if err := p.ReapKeys(ctx, user, nil); err != nil {
1235 return nil, fmt.Errorf("mail: reap keys for %s: %w", user, err)
1236 }
1237 }
1238
1239 if unsets := appSenderEnvUnsets(sn); len(unsets) > 0 {
1240 if _, err := appconfig.Apply(ctx, s, acct, app, nil, unsets); err != nil {
1241 return nil, err
1242 }
1243 }
1244 return sn, nil
1245}
1246
1247// LeavePerInstance is the inverse, and it is ONLY the record half: it clears
1248// PerInstance, sets Active and clears PauseKind in one CAS, and the caller
1249// runs a normal Enable afterwards. Splitting it that way is deliberate —
1250// Enable carries Active forward from the record, so an enable under a false
1251// Active would mint a key that is disabled at IAM the moment it is made, and
1252// the delivered credential would fail SMTP auth with nothing to point at.
1253//
1254// It takes no Provisioner, because it destroys nothing and arms nothing.
1255func LeavePerInstance(ctx context.Context, s release.Store, acct, app string, now time.Time) (*Sender, error) {
1256 sn, etag, err := GetSender(ctx, s, acct, app, SenderAppID)
1257 if err != nil {
1258 return nil, err
1259 }
1260 if sn == nil || sn.Declared == nil {
1261 return nil, ErrNotDeclared
1262 }
1263 if !sn.PerInstance {
1264 return sn, nil // already out: idempotent, like entering
1265 }
1266 sn.PerInstance = false
1267 if sn.Provisioned != nil {
1268 sn.Provisioned.Active = true
1269 sn.Provisioned.PauseKind = ""
1270 sn.Provisioned.At = now
1271 }
1272 if _, err := PutSender(ctx, s, acct, app, sn, etag); err != nil {
1273 return nil, err
1274 }
1275 return sn, nil
1276}
1277```
1278
1279`internal/mail/walk.go`, `Enable`: immediately after the `rec == nil || rec.Declared == nil` check, refuse under the flag.
1280
1281```go
1282 // Refused UNDER THE ETAG THIS READ RETURNED (design §6.3): the record
1283 // this walk goes on to Put is the one it just read, so a transition
1284 // that lands after this check still loses the CAS and this enable never
1285 // delivers a credential the transition is busy destroying.
1286 if rec.PerInstance() {
1287 return nil, ErrPerInstance
1288 }
1289```
1290
1291`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.
1292
1293**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.
1294
1295- [ ] **Step 4: Run the test — expect success**
1296
1297```bash
1298GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1
1299```
1300
1301- [ ] **Step 5: Gate and commit**
1302
1303```bash
1304gofmt -w internal/mail/instance.go internal/mail/walk.go internal/mail/instance_test.go internal/mail/memprovisioner.go
1305GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
1306GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
1307GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
1308gofmt -l .
1309git commit -am "mail: enter and leave per-instance mode (plan 5, task 4)
1310
1311Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
1312```
1313
1314---
1315
1316### Task 5: `EnableInstance`, and the credential that only one instance can read
1317
1318**Files:**
1319- Modify: `internal/mail/instance.go`, `internal/mail/instance_test.go`
1320
1321**Interfaces:**
1322- 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`.
1323- Produces:
1324 ```go
1325 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
1326 func EnvDeliveredInstance(ctx context.Context, s release.Store, acct, app string, sn *Sender, wantKeyID string) ([]string, error)
1327 func writeSMTPEnvInstance(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey, acct, app string, sn *Sender, region, keyID, secret string) (string, error) // unexported
1328 ```
1329
1330**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.
1331
1332- [ ] **Step 1: Write the failing test**
1333
1334`internal/mail/instance_test.go` (append):
1335
1336```go
1337// The credential goes into the instance layer and NOWHERE else. This is
1338// the whole point of the feature: the default bundle is what every sibling
1339// renders, so a variable landing there is a credential handed to every
1340// customer of a multi-tenant app.
1341func TestEnableInstanceDeliversOnlyIntoTheInstanceLayer(t *testing.T) {
1342 ctx, s, p, pub, now := instanceEnableFixture(t)
1343 sn, etag, err := EnableInstance(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", "book.example.com", now)
1344 if err != nil {
1345 t.Fatal(err)
1346 }
1347 if etag == "" {
1348 t.Fatal("EnableInstance returned no overlay etag, so no door can ever confirm delivery")
1349 }
1350 if sn.Provisioned == nil || sn.Provisioned.KeyID == "" {
1351 t.Fatalf("no credential was recorded: %+v", sn.Provisioned)
1352 }
1353 over, gotEtag, found, err := appconfig.LoadInstanceConfig(ctx, s, "bag", "harbourmail", "book.example.com")
1354 if err != nil || !found {
1355 t.Fatalf("no instance overlay was written: %v %v", found, err)
1356 }
1357 if gotEtag != etag {
1358 t.Fatalf("overlay etag = %q, EnableInstance returned %q", gotEtag, etag)
1359 }
1360 host, port, user, pass, from := EnvNames("HARBOURMAIL_SMTP")
1361 for _, name := range []string{host, port, user, from} {
1362 if v, ok := over.Vars[name]; !ok || v.Kind != "env" {
1363 t.Errorf("%s is not delivered into the overlay as env: %+v", name, v)
1364 }
1365 }
1366 if v, ok := over.Vars[pass]; !ok || v.Kind != "secret" {
1367 t.Errorf("%s is not sealed into the overlay: %+v", pass, v)
1368 }
1369 if over.Vars[user].Value != sn.Provisioned.KeyID {
1370 t.Errorf("the delivered key id %q is not the one the record names %q", over.Vars[user].Value, sn.Provisioned.KeyID)
1371 }
1372 // The default bundle carries none of it.
1373 man, err := appconfig.Get(ctx, s, "bag", "harbourmail")
1374 if err != nil {
1375 t.Fatal(err)
1376 }
1377 for _, name := range []string{host, port, user, pass, from} {
1378 if _, ok := man.Vars[name]; ok {
1379 t.Errorf("%s reached the default bundle, so every sibling instance can read this customer's credential", name)
1380 }
1381 }
1382 // Idempotent: a second enable with delivery intact mints nothing new.
1383 again, _, err := EnableInstance(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", "book.example.com", now)
1384 if err != nil {
1385 t.Fatal(err)
1386 }
1387 if again.Provisioned.KeyID != sn.Provisioned.KeyID {
1388 t.Fatalf("a healthy re-enable minted a new key %q -> %q", sn.Provisioned.KeyID, again.Provisioned.KeyID)
1389 }
1390}
1391
1392// An instance sender needs the app to be in per-instance mode first, and
1393// only the app's own doors can put it there. Enabling one without it would
1394// deliver a per-instance credential alongside the app-wide one every
1395// sibling still holds, which isolates nothing.
1396func TestEnableInstanceRequiresPerInstanceMode(t *testing.T) {
1397 ctx, s, p, pub, now := instanceEnableFixture(t)
1398 sn, etag, err := GetSender(ctx, s, "bag", "harbourmail", SenderAppID)
1399 if err != nil {
1400 t.Fatal(err)
1401 }
1402 sn.PerInstance = false
1403 if _, err := PutSender(ctx, s, "bag", "harbourmail", sn, etag); err != nil {
1404 t.Fatal(err)
1405 }
1406 if _, _, err := EnableInstance(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", "book.example.com", now); !errors.Is(err, ErrNotPerInstance) {
1407 t.Fatalf("EnableInstance outside per-instance mode = %v, want ErrNotPerInstance", err)
1408 }
1409}
1410
1411// The From address must be at one of THIS sender's verified domains, not at
1412// any domain the app holds. Otherwise the ses:FromAddress condition refuses
1413// every send at runtime, which presents as "email silently does not work".
1414func TestEnableInstanceRefusesAFromOutsideItsOwnDomains(t *testing.T) {
1415 ctx, s, p, pub, now := instanceEnableFixture(t)
1416 sn, etag, err := GetSender(ctx, s, "bag", "harbourmail", "book.example.com")
1417 if err != nil {
1418 t.Fatal(err)
1419 }
1420 sn.Declared.From = "hello@shop.example.com"
1421 if _, err := PutSender(ctx, s, "bag", "harbourmail", sn, etag); err != nil {
1422 t.Fatal(err)
1423 }
1424 _, _, err = EnableInstance(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", "book.example.com", now)
1425 if err == nil || !strings.Contains(err.Error(), "is not at any verified domain") {
1426 t.Fatalf("EnableInstance with a foreign From = %v", err)
1427 }
1428}
1429```
1430
1431`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.
1432
1433- [ ] **Step 2: Run the test — expect failure**
1434
1435```bash
1436GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run EnableInstance -count=1
1437```
1438
1439Expected: build failure, `undefined: EnableInstance`.
1440
1441- [ ] **Step 3: Implement**
1442
1443`internal/mail/instance.go`, append:
1444
1445```go
1446// EnvDeliveredInstance is EnvDelivered for one instance sender, and it
1447// reads the OVERLAY ALONE, never the merged view.
1448//
1449// That is the whole difference and it is load-bearing. A merged read would
1450// let a value standing in the app's default bundle satisfy this check, so
1451// an instance whose overlay was never written would report delivered while
1452// running on a credential that belongs to the app, or to nobody. The
1453// overlay is the only layer this sender writes, so it is the only layer
1454// that can answer for it.
1455func EnvDeliveredInstance(ctx context.Context, s release.Store, acct, app string, sn *Sender, wantKeyID string) ([]string, error) {
1456 man, _, found, err := appconfig.LoadInstanceConfig(ctx, s, acct, app, sn.Host)
1457 if err != nil {
1458 return nil, err
1459 }
1460 host, port, user, pass, from := EnvNames(sn.Declared.EnvPrefix)
1461 if !found {
1462 out := []string{host, port, user, pass, from}
1463 sort.Strings(out)
1464 return out, nil
1465 }
1466 var missing []string
1467 check := func(name, wantKind string) {
1468 v, ok := man.Vars[name]
1469 if !ok || v.Kind != wantKind {
1470 missing = append(missing, name)
1471 }
1472 }
1473 check(host, "env")
1474 check(port, "env")
1475 check(pass, "secret")
1476 // _USER's VALUE is compared for EnvDelivered's own reason: it is the
1477 // one credential half delivered unsealed, so a failed final Put after a
1478 // successful mint and deliver is catchable here and nowhere else.
1479 if v, ok := man.Vars[user]; !ok || v.Kind != "env" || (wantKeyID != "" && v.Value != wantKeyID) {
1480 missing = append(missing, user)
1481 }
1482 if v, ok := man.Vars[from]; !ok || v.Kind != "env" || v.Value != sn.Declared.From {
1483 missing = append(missing, from)
1484 }
1485 sort.Strings(missing)
1486 return missing, nil
1487}
1488
1489// writeSMTPEnvInstance delivers the five variables into the instance layer
1490// and returns that layer's new etag, which is what makes delivery
1491// confirmable: the console compares it against the etag the agent's ack
1492// says it rendered (design §6.5). Every validation writeSMTPEnv performs
1493// happens here too, and for the same reasons — a sealed value reaches
1494// appconfig as an opaque envelope, so an unrenderable byte would stall this
1495// instance's whole config render hours later and out of sight.
1496func writeSMTPEnvInstance(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey,
1497 acct, app string, sn *Sender, region, keyID, secret string) (string, error) {
1498
1499 hostName, portName, userName, passName, fromName := EnvNames(sn.Declared.EnvPrefix)
1500 password := SMTPPassword(secret, region)
1501 if err := appconfig.ValidVarValue(keyID); err != nil {
1502 return "", fmt.Errorf("mail: minted credential for %s is not EnvironmentFile-safe: %w", userName, err)
1503 }
1504 if err := appconfig.ValidVarValue(password); err != nil {
1505 return "", fmt.Errorf("mail: minted credential for %s is not EnvironmentFile-safe: %w", passName, err)
1506 }
1507 env, err := appconfig.Seal(sealPub, []byte(password))
1508 if err != nil {
1509 return "", fmt.Errorf("mail: seal %s: %w", passName, err)
1510 }
1511 sets := map[string]appconfig.Var{
1512 hostName: {Kind: "env", Value: SMTPHost(region)},
1513 portName: {Kind: "env", Value: SMTPPort},
1514 userName: {Kind: "env", Value: keyID},
1515 passName: {Kind: "secret", Value: env},
1516 fromName: {Kind: "env", Value: sn.Declared.From},
1517 }
1518 _, etag, err := appconfig.ApplyInstance(ctx, s, acct, app, sn.Host, sets, nil)
1519 return etag, err
1520}
1521
1522// EnableInstance provisions one instance sender's credential and delivers
1523// it into that instance's own config layer. It is Enable's counterpart and
1524// keeps every one of Enable's rules, with three additions:
1525//
1526// - the app must be in per-instance mode, or this credential would be
1527// delivered alongside the app-wide one every sibling still holds;
1528// - the app sender's keys are reaped again, because an app enable that
1529// raced the transition may have minted and delivered one since;
1530// - the delivered variables go through the instance layer, and the
1531// layer's new etag comes back so the caller can confirm delivery.
1532//
1533// The second return value is that etag, and it is "" only when nothing was
1534// written, which is the idempotent short-circuit.
1535func EnableInstance(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey,
1536 d Deployment, acct, app, sender string, now time.Time) (*Sender, string, error) {
1537
1538 appSender, _, err := GetSender(ctx, s, acct, app, SenderAppID)
1539 if err != nil {
1540 return nil, "", err
1541 }
1542 if appSender == nil || appSender.Declared == nil {
1543 return nil, "", ErrNotDeclared
1544 }
1545 if !appSender.PerInstance {
1546 return nil, "", ErrNotPerInstance
1547 }
1548
1549 sn, etag, err := GetSender(ctx, s, acct, app, sender)
1550 if err != nil {
1551 return nil, "", err
1552 }
1553 if sn == nil || sn.Declared == nil {
1554 return nil, "", ErrNotDeclared
1555 }
1556 if sn.Scope != ScopeInstance {
1557 return nil, "", ErrNotInstanceSender
1558 }
1559
1560 domains, err := grantedDomains(ctx, s, acct, app, sender)
1561 if err != nil {
1562 return nil, "", err
1563 }
1564 if len(domains) == 0 {
1565 return nil, "", ErrNoDomains
1566 }
1567 var arns, configSets, names []string
1568 for _, dom := range domains {
1569 arns = append(arns, dom.VerifiedARNs()...)
1570 configSets = append(configSets, dom.VerifiedConfigSetARNs()...)
1571 names = append(names, dom.Domain)
1572 }
1573 sort.Strings(arns)
1574 sort.Strings(configSets)
1575 sort.Strings(names)
1576
1577 at := strings.LastIndex(sn.Declared.From, "@")
1578 if !containsString(names, sn.Declared.From[at+1:]) {
1579 return nil, "", fmt.Errorf("mail: From %q is not at any verified domain (%s)",
1580 sn.Declared.From, strings.Join(names, ", "))
1581 }
1582
1583 // The names and the caller index together: ResolveSenderNames writes
1584 // email/callers/<iamuser>.json right after this, which is what makes a
1585 // slugged caller resolvable back to this sender when SES stamps it on
1586 // an event. Nothing parses a slug back.
1587 nameSet, _, err := ResolveSenderNames(ctx, s, acct, app, sn, now)
1588 if err != nil {
1589 return nil, "", err
1590 }
1591
1592 // This sender's own tenant, associated with the account's configuration
1593 // set and with each identity it may send from. The tenant is what makes
1594 // this sender's events and its future pause its own (design §8); the
1595 // policy carries no tenant condition, because a domain sender is never
1596 // delivered a tenant header to satisfy one with.
1597 if err := p.EnsureTenant(ctx, nameSet.Tenant); err != nil {
1598 return nil, "", fmt.Errorf("mail: ensure tenant %s: %w", nameSet.Tenant, err)
1599 }
1600 for _, arn := range append(append([]string{}, arns...), configSets...) {
1601 if err := p.AssociateTenantResource(ctx, nameSet.Tenant, arn); err != nil {
1602 return nil, "", fmt.Errorf("mail: associate %s with tenant %s: %w", arn, nameSet.Tenant, err)
1603 }
1604 }
1605
1606 // EnsureUser runs unconditionally, before the short-circuit, for C2's
1607 // reason: it is the only place this sender's policy is written, and the
1608 // grant above is recomputed from the CURRENT domain set on every call.
1609 userARN, err := p.EnsureUser(ctx, nameSet.IAMUser, UserPath, arns, configSets, names)
1610 if err != nil {
1611 return nil, "", fmt.Errorf("mail: ensure user %s: %w", nameSet.IAMUser, err)
1612 }
1613
1614 if sn.Provisioned != nil {
1615 missing, err := EnvDeliveredInstance(ctx, s, acct, app, sn, sn.Provisioned.KeyID)
1616 if err != nil {
1617 return nil, "", err
1618 }
1619 if len(missing) == 0 {
1620 return sn, "", nil
1621 }
1622 }
1623
1624 // Re-run the app sender's reap (design §6.3). An app enable that raced
1625 // the transition mints and delivers before its own CAS Put, so a key
1626 // with no record naming it can appear after the transition has already
1627 // swept. This is the second sweep that makes such a key inert.
1628 if _, err := EnterPerInstance(ctx, s, p, acct, app, now); err != nil {
1629 return nil, "", err
1630 }
1631
1632 active, prev, pauseKind := true, "", ""
1633 if sn.Provisioned != nil {
1634 prev, active, pauseKind = sn.Provisioned.PrevKeyID, sn.Provisioned.Active, sn.Provisioned.PauseKind
1635 }
1636 var keep []string
1637 if prev != "" {
1638 keep = append(keep, prev)
1639 }
1640 if err := p.ReapKeys(ctx, nameSet.IAMUser, keep); err != nil {
1641 return nil, "", fmt.Errorf("mail: reap keys for %s: %w", nameSet.IAMUser, err)
1642 }
1643 keyID, secret, err := p.MintKey(ctx, nameSet.IAMUser)
1644 if err != nil {
1645 return nil, "", fmt.Errorf("mail: mint key for %s: %w", nameSet.IAMUser, err)
1646 }
1647 if err := enforceKeyActive(ctx, p, nameSet.IAMUser, keyID, active); err != nil {
1648 return nil, "", err
1649 }
1650
1651 overlayETag, err := writeSMTPEnvInstance(ctx, s, sealPub, acct, app, sn, d.Region, keyID, secret)
1652 if err != nil {
1653 return nil, "", err
1654 }
1655
1656 sn.Provisioned = &Provisioned{
1657 IAMUser: nameSet.IAMUser, UserARN: userARN, KeyID: keyID, PrevKeyID: prev,
1658 Active: active, PauseKind: pauseKind, At: now,
1659 }
1660 if _, err := PutSender(ctx, s, acct, app, sn, etag); err != nil {
1661 return nil, "", err
1662 }
1663 return sn, overlayETag, nil
1664}
1665```
1666
1667- [ ] **Step 4: Run the test — expect success**
1668
1669```bash
1670GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1
1671```
1672
1673- [ ] **Step 5: Gate and commit**
1674
1675```bash
1676gofmt -w internal/mail/instance.go internal/mail/instance_test.go
1677GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
1678GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
1679GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
1680gofmt -l .
1681git commit -am "mail: enable an instance sender into its own config layer (plan 5, task 5)
1682
1683Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
1684```
1685
1686---
1687
1688### Task 6: Rotating and tearing down one instance sender
1689
1690**Files:**
1691- Modify: `internal/mail/instance.go`, `internal/mail/instance_test.go`
1692
1693**Interfaces:**
1694- 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`.
1695- Produces:
1696 ```go
1697 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)
1698 func RotateFinishInstance(ctx context.Context, s release.Store, p Provisioner, acct, app, sender string) (*Sender, error)
1699 func TeardownInstance(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer, d Deployment, acct, app, sender string) error
1700 ```
1701
1702- [ ] **Step 1: Write the failing test**
1703
1704`internal/mail/instance_test.go` (append):
1705
1706```go
1707// A rotation keeps the previous key live until it is finished, exactly as
1708// the app-level one does, and the new key reaches the instance layer alone.
1709func TestRotateInstanceOverlapsThenFinishes(t *testing.T) {
1710 ctx, s, p, pub, now := instanceEnableFixture(t)
1711 first, _, err := EnableInstance(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", "book.example.com", now)
1712 if err != nil {
1713 t.Fatal(err)
1714 }
1715 rotated, etag, err := RotateInstance(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", "book.example.com", now)
1716 if err != nil {
1717 t.Fatal(err)
1718 }
1719 if rotated.Provisioned.KeyID == first.Provisioned.KeyID {
1720 t.Fatal("the rotation did not mint a new key")
1721 }
1722 if rotated.Provisioned.PrevKeyID != first.Provisioned.KeyID {
1723 t.Fatalf("PrevKeyID = %q, want the key that was live %q", rotated.Provisioned.PrevKeyID, first.Provisioned.KeyID)
1724 }
1725 if etag == "" {
1726 t.Fatal("the rotation returned no overlay etag")
1727 }
1728 over, gotEtag, _, err := appconfig.LoadInstanceConfig(ctx, s, "bag", "harbourmail", "book.example.com")
1729 if err != nil {
1730 t.Fatal(err)
1731 }
1732 _, _, user, _, _ := EnvNames("HARBOURMAIL_SMTP")
1733 if gotEtag != etag || over.Vars[user].Value != rotated.Provisioned.KeyID {
1734 t.Fatalf("the rotated key did not reach the overlay: etag %q vs %q, user %q", gotEtag, etag, over.Vars[user].Value)
1735 }
1736 done, err := RotateFinishInstance(ctx, s, p, "bag", "harbourmail", "book.example.com")
1737 if err != nil {
1738 t.Fatal(err)
1739 }
1740 if done.Provisioned.PrevKeyID != "" {
1741 t.Fatalf("PrevKeyID survived the finish: %q", done.Provisioned.PrevKeyID)
1742 }
1743 if keys := p.Keys(done.Provisioned.IAMUser); len(keys) != 1 || keys[0] != done.Provisioned.KeyID {
1744 t.Fatalf("keys after finish = %q, want only the current one", keys)
1745 }
1746}
1747
1748// Teardown removes the principal, the overlay, the caller index and this
1749// sender's references on every domain, and leaves a sibling instance's
1750// domain and credential untouched.
1751func TestTeardownInstanceLeavesASiblingAlone(t *testing.T) {
1752 ctx, s, p, pub, now := instanceEnableFixture(t)
1753 if _, _, err := EnableInstance(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", "book.example.com", now); err != nil {
1754 t.Fatal(err)
1755 }
1756 sibling := declareAndEnableInstance(t, ctx, s, p, pub, now, "shop.example.com")
1757
1758 if err := TeardownInstance(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "bag", "harbourmail", "book.example.com"); err != nil {
1759 t.Fatal(err)
1760 }
1761 if _, _, found, err := appconfig.LoadInstanceConfig(ctx, s, "bag", "harbourmail", "book.example.com"); err != nil || found {
1762 t.Fatalf("the torn-down instance's overlay survived: %v %v", found, err)
1763 }
1764 gone, _, err := GetSender(ctx, s, "bag", "harbourmail", "book.example.com")
1765 if err != nil || gone != nil {
1766 t.Fatalf("the sender object survived its teardown: %+v %v", gone, err)
1767 }
1768 // The sibling is untouched: its sender, its overlay and its domain.
1769 still, _, err := GetSender(ctx, s, "bag", "harbourmail", "shop.example.com")
1770 if err != nil || still == nil || still.Provisioned.KeyID != sibling.Provisioned.KeyID {
1771 t.Fatalf("the sibling sender moved: %+v %v", still, err)
1772 }
1773 if _, _, found, err := appconfig.LoadInstanceConfig(ctx, s, "bag", "harbourmail", "shop.example.com"); err != nil || !found {
1774 t.Fatalf("the sibling's overlay was removed: %v %v", found, err)
1775 }
1776 if keys := p.Keys(still.Provisioned.IAMUser); len(keys) == 0 {
1777 t.Fatal("the sibling's credential was reaped by another instance's teardown")
1778 }
1779}
1780```
1781
1782`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.
1783
1784- [ ] **Step 2: Run the test — expect failure**
1785
1786```bash
1787GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run 'RotateInstance|TeardownInstance' -count=1
1788```
1789
1790Expected: build failure, `undefined: RotateInstance`, `undefined: RotateFinishInstance`, `undefined: TeardownInstance`.
1791
1792- [ ] **Step 3: Implement**
1793
1794`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`.
1795
1796```go
1797// TeardownInstance dismantles one instance sender: its principal, its
1798// caller index, its tenant associations, its claims and domain references,
1799// its config overlay and its record.
1800//
1801// THE OVERLAY GOES FIRST, before TeardownSender destroys the principal.
1802// The order matters on a crash: an overlay left standing beside a deleted
1803// IAM user is a live instance failing SMTP auth with nothing to point at,
1804// whereas an overlay removed ahead of a principal that survives is an
1805// instance with no credential and an IAM user a re-run reaps. The second is
1806// repairable by running this again; the first is not repairable at all
1807// without a re-enable nobody knows to ask for.
1808//
1809// Leaving per-instance mode is NOT done here. This function does not know
1810// whether it removed the last instance sender, and finding out is a prefix
1811// listing that races another instance's enable. The DELETE door decides it,
1812// after this returns (design §6.3).
1813func TeardownInstance(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer,
1814 d Deployment, acct, app, sender string) error {
1815
1816 sn, _, err := GetSender(ctx, s, acct, app, sender)
1817 if err != nil {
1818 return err
1819 }
1820 if sn == nil {
1821 return nil // already gone: idempotent, like every teardown here
1822 }
1823 if sn.Scope != ScopeInstance {
1824 return ErrNotInstanceSender
1825 }
1826 if err := appconfig.DeleteInstanceConfig(ctx, s, acct, app, sn.Host); err != nil {
1827 return err
1828 }
1829 return TeardownSender(ctx, s, p, w, d, acct, app, sender)
1830}
1831```
1832
1833**`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`.
1834
1835- [ ] **Step 4: Run the test — expect success**
1836
1837```bash
1838GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1
1839```
1840
1841- [ ] **Step 5: Gate and commit**
1842
1843```bash
1844gofmt -w internal/mail/instance.go internal/mail/instance_test.go
1845GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
1846GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
1847GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
1848gofmt -l .
1849git commit -am "mail: rotate and tear down one instance sender (plan 5, task 6)
1850
1851Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
1852```
1853
1854---
1855
1856### Task 7: The box says which uid it renders and which config schema it understands
1857
1858**Files:**
1859- 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`
1860
1861**Interfaces:**
1862- 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`).
1863- Produces:
1864 ```go
1865 // internal/status
1866 const (
1867 UIDDynamic = "dynamic" // DynamicUser=yes: a fresh uid per start, shared with nobody
1868 UIDApp = "app" // User=carlos-<app>: one uid per app, shared by every instance of it
1869 UIDAgent = "agent" // an exec child of the agent: the agent's own uid, shared by everything
1870 )
1871 // Probes gains: ConfigSchema int; RouteUIDs map[string]string
1872 // BoxMetrics gains: ConfigSchema int `json:"config_schema,omitempty"`; RouteUIDs map[string]string `json:"route_uids,omitempty"`
1873 // internal/host
1874 func routeUIDShape(rt registry.Route) string
1875 ```
1876
1877**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).
1878
1879**`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.
1880
1881- [ ] **Step 1: Write the failing test**
1882
1883`internal/host/metrics_loop_test.go` (append):
1884
1885```go
1886// The three uid shapes, from the registry row alone. This is the fact the
1887// email enable door refuses on, so getting it wrong in the permissive
1888// direction hands one customer's SMTP credential to every sibling.
1889func TestRouteUIDShapeNamesWhoSharesTheUid(t *testing.T) {
1890 for _, tc := range []struct {
1891 name string
1892 rt registry.Route
1893 want string
1894 }{
1895 {"unit-backed with no host_role grant", registry.Route{
1896 Host: "book.example.com", Kind: "instance", App: "harbourmail", Backing: registry.BackingUnit,
1897 }, status.UIDDynamic},
1898 {"unit-backed with a host_role grant", registry.Route{
1899 Host: "book.example.com", Kind: "instance", App: "harbourmail", Backing: registry.BackingUnit,
1900 Caps: "host-role",
1901 }, status.UIDApp},
1902 {"a plain exec child of the agent", registry.Route{
1903 Host: "book.example.com", Kind: "instance", App: "harbourmail",
1904 }, status.UIDAgent},
1905 {"a bespoke operator unit", registry.Route{
1906 Host: "console.tito.io", Kind: "instance", App: "console", Unit: "console.service",
1907 }, status.UIDAgent},
1908 {"an unparseable grant column", registry.Route{
1909 Host: "book.example.com", Kind: "instance", App: "harbourmail", Backing: registry.BackingUnit,
1910 Caps: "not-a-token",
1911 }, status.UIDApp},
1912 } {
1913 if got := routeUIDShape(tc.rt); got != tc.want {
1914 t.Errorf("%s: routeUIDShape = %q, want %q", tc.name, got, tc.want)
1915 }
1916 }
1917}
1918```
1919
1920The nested isolated-exec case belongs here too, with the ruling written beside it:
1921
1922```go
1923// A NESTED ROUTE IS STILL THE AGENT'S UID (ruling 4). The isolated-exec
1924// layout puts an instance in its own mount namespace, and
1925// internal/host/units/carlos-instance@.service runs it as User=carlos —
1926// one uid for every nested instance on the box and for the agent. A mount
1927// namespace hides a sibling's FILES, not a sibling's uid, so a
1928// per-instance SMTP credential buys nothing here. If this ever stops being
1929// true, the units change first and this test changes with them.
1930func TestANestedExecRouteStillSharesTheAgentsUid(t *testing.T) {
1931 rt := registry.Route{
1932 Host: "book.example.com", Kind: "instance", App: "harbourmail",
1933 DBPath: provision.NestedDBPath("/var/lib/carlos", "book.example.com"),
1934 Socket: provision.NestedSocket("book.example.com"),
1935 }
1936 if got := routeUIDShape(rt); got != status.UIDAgent {
1937 t.Fatalf("routeUIDShape for a nested route = %q, want %q", got, status.UIDAgent)
1938 }
1939}
1940```
1941
1942`internal/status/snapshot_test.go` (append):
1943
1944```go
1945// Both new fields reach Box.Metrics, and a box that knows neither still
1946// publishes no Metrics block at all — the "unknown stays absent, never a
1947// guess" rule the rest of that block follows.
1948func TestCollectBoxCarriesTheConfigSchemaAndRouteUIDs(t *testing.T) {
1949 box := CollectBox(Probes{
1950 ConfigSchema: 1,
1951 RouteUIDs: map[string]string{"book.example.com": UIDDynamic},
1952 }, time.Now())
1953 if box.Metrics == nil || box.Metrics.ConfigSchema != 1 {
1954 t.Fatalf("ConfigSchema did not reach Metrics: %+v", box.Metrics)
1955 }
1956 if box.Metrics.RouteUIDs["book.example.com"] != UIDDynamic {
1957 t.Fatalf("RouteUIDs did not reach Metrics: %+v", box.Metrics)
1958 }
1959 if empty := CollectBox(Probes{}, time.Now()); empty.Metrics != nil {
1960 t.Fatalf("a box that knows nothing published a Metrics block: %+v", empty.Metrics)
1961 }
1962}
1963```
1964
1965`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.
1966
1967- [ ] **Step 2: Run the test — expect failure**
1968
1969```bash
1970TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/host/ ./internal/status/ -run 'RouteUID|ConfigSchema|NestedExec' -count=1
1971```
1972
1973Expected: build failure, `undefined: routeUIDShape`, `undefined: status.UIDDynamic`, `unknown field ConfigSchema in struct literal`.
1974
1975- [ ] **Step 3: Implement**
1976
1977`internal/status/snapshot.go`, a const block beside `SystemState`:
1978
1979```go
1980// The three shapes an instance's process can take a unix uid from, as the
1981// box that runs it reports them. They exist so a console can refuse to put
1982// a per-instance credential somewhere a sibling can read it, from a fact
1983// only the box holds: the route's backing and the operator's capability
1984// grants both live in the box's own registry.
1985const (
1986 // UIDDynamic is DynamicUser=yes: systemd mints a fresh, unpredictable
1987 // uid on every start, with no supplementary groups, so no sibling ever
1988 // shares it. This is the only shape a per-instance secret is safe in.
1989 UIDDynamic = "dynamic"
1990 // UIDApp is User=carlos-<app>, which a host_role grant renders
1991 // (caps.RenderService). One uid per APP, shared by every instance of
1992 // it, and ProtectProc hides other users' processes rather than a
1993 // sibling's at the same uid.
1994 UIDApp = "app"
1995 // UIDAgent is an exec child of the agent, nested layout included: the
1996 // agent's own uid, shared with every other exec route on the box.
1997 UIDAgent = "agent"
1998)
1999```
2000
2001On `Probes`, beside `Release`:
2002
2003```go
2004 // ConfigSchema is appconfig.ConfigAckSchema as this agent knows it: a
2005 // positive statement of which config-layer version it renders. It is
2006 // here rather than derived from Release because Release is a git short
2007 // sha, which nothing can compare, and from System.Version because that
2008 // is absent on every box rolled by hand. 0 means an agent older than
2009 // the instance config layer.
2010 ConfigSchema int
2011 // RouteUIDs is host -> one of UIDDynamic, UIDApp or UIDAgent for every
2012 // non-platform instance route this box carries. Hostname-keyed, which
2013 // is why it lives in the private Metrics block beside RouteVersions
2014 // and never reaches the public fleet feed.
2015 RouteUIDs map[string]string
2016```
2017
2018On `BoxMetrics`, beside `Release`, the same two with `json:"config_schema,omitempty"` and `json:"route_uids,omitempty"` and a one-line comment pointing at `Probes`.
2019
2020In `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.
2021
2022`internal/host/metrics_loop.go`, beside `metricsBoxLabel`:
2023
2024```go
2025// routeUIDShape is which uid this box's rendering gives one route's
2026// process, in the three words internal/status names (design §6.4).
2027//
2028// The grant column decides the host_role case rather than the manifest,
2029// deliberately. RenderService emits User=carlos-<app> only when the
2030// manifest declares host_role AND the operator granted it, so reading the
2031// grant alone can over-report UIDApp for a route whose manifest never
2032// declared it. Over-reporting refuses an email enable that might have been
2033// safe; under-reporting would hand a customer's SMTP credential to every
2034// sibling at the same uid. An unparseable grant column reports UIDApp for
2035// the same reason: it is the answer that refuses.
2036func routeUIDShape(rt registry.Route) string {
2037 if rt.Backing != registry.BackingUnit || rt.Unit != "" {
2038 // An exec child of the agent, nested layout included: nesting is a
2039 // mount namespace, and carlos-instance@.service runs as
2040 // User=carlos, so every nested instance shares the agent's uid.
2041 return status.UIDAgent
2042 }
2043 g, err := caps.ParseGrants(rt.Caps)
2044 if err != nil || g.HostRole {
2045 return status.UIDApp
2046 }
2047 return status.UIDDynamic
2048}
2049```
2050
2051`internal/host/agent.go`, in the status tick's `status.Probes` literal, beside `Release`:
2052
2053```go
2054 // What this agent can say about itself that no console can
2055 // work out: which config-layer schema it renders, and which
2056 // uid each of its routes runs under. Both are what the email
2057 // instance doors refuse on (design §6.4, §6.5).
2058 ConfigSchema: appconfig.ConfigAckSchema,
2059 RouteUIDs: routeUIDShapes(routes),
2060```
2061
2062with `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).
2063
2064- [ ] **Step 4: Run the test — expect success**
2065
2066```bash
2067TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/host/ ./internal/status/ -count=1
2068```
2069
2070- [ ] **Step 5: Gate and commit**
2071
2072```bash
2073gofmt -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
2074GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
2075GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
2076TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
2077gofmt -l .
2078git commit -am "status: a box reports its config schema and each route's uid shape (plan 5, task 7)
2079
2080Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
2081```
2082
2083---
2084
2085### Task 8: The two refusals at enable
2086
2087**Files:**
2088- Create: `internal/console/apimailinstance.go`, `internal/console/apimailinstance_test.go`
2089
2090**Interfaces:**
2091- 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`).
2092- Produces:
2093 ```go
2094 type instanceBox struct {
2095 Label, Region, Release, UID string
2096 ConfigSchema int
2097 Found, Stale bool
2098 }
2099 func (s *Server) instanceBoxFacts(ctx context.Context, host string, now time.Time) (instanceBox, error)
2100 func (s *Server) refuseUnlessOwnUID(w http.ResponseWriter, host string, b instanceBox) bool
2101 func (s *Server) refuseUnlessLayerAgent(w http.ResponseWriter, host string, b instanceBox) bool
2102 ```
2103
2104**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.
2105
2106**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.
2107
2108- [ ] **Step 0: Run the copy-review gate**
2109
2110Six 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.
2111
21121. `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`
21132. `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`
21143. `{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`
21154. `{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`
21165. `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`
21176. `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)
2118
2119- [ ] **Step 1: Write the failing test**
2120
2121`internal/console/apimailinstance_test.go` (create):
2122
2123```go
2124package console
2125
2126import (
2127 "context"
2128 "net/http/httptest"
2129 "testing"
2130 "time"
2131
2132 "github.com/carlosframework/platform/internal/status"
2133)
2134
2135// The permissive direction is the dangerous one: a credential delivered to
2136// a route whose uid a sibling shares is a credential every customer of a
2137// multi-tenant app can read. Each of these four states refuses.
2138func TestOnlyADynamicUserInstanceCanHoldItsOwnCredential(t *testing.T) {
2139 fx := newConsoleFixture(t)
2140 for _, tc := range []struct {
2141 name string
2142 box instanceBox
2143 refused bool
2144 }{
2145 {"its own uid per start", instanceBox{Found: true, Label: "annie-welding", UID: status.UIDDynamic}, false},
2146 {"one uid per app", instanceBox{Found: true, Label: "annie-welding", UID: status.UIDApp}, true},
2147 {"the agent's uid", instanceBox{Found: true, Label: "annie-welding", UID: status.UIDAgent}, true},
2148 {"no box has reported it", instanceBox{}, true},
2149 {"the box has gone quiet", instanceBox{Found: true, Label: "annie-welding", UID: status.UIDDynamic, Stale: true}, true},
2150 } {
2151 w := httptest.NewRecorder()
2152 got := fx.srv.refuseUnlessOwnUID(w, "book.example.com", tc.box)
2153 if got != tc.refused {
2154 t.Errorf("%s: refused = %v, want %v", tc.name, got, tc.refused)
2155 }
2156 if tc.refused && w.Code != 409 {
2157 t.Errorf("%s: status = %d, want 409", tc.name, w.Code)
2158 }
2159 if tc.refused && w.Body.Len() == 0 {
2160 t.Errorf("%s: refused with an empty body, so nothing tells the caller what to do", tc.name)
2161 }
2162 }
2163}
2164
2165// The agent-version refusal reads the box record, never the absence of an
2166// ack: acks are written only when a delivery changes, so a fresh instance
2167// on a brand new agent has none (design §6.5).
2168func TestTheLayerRefusalReadsTheBoxRecordAndNotTheAck(t *testing.T) {
2169 fx := newConsoleFixture(t)
2170 old := instanceBox{Found: true, Label: "barbara-tinkering", Release: "16593de5", ConfigSchema: 0}
2171 w := httptest.NewRecorder()
2172 if !fx.srv.refuseUnlessLayerAgent(w, "book.example.com", old) {
2173 t.Fatal("an agent that predates the instance config layer was allowed to enable")
2174 }
2175 if w.Code != 409 {
2176 t.Fatalf("status = %d, want 409", w.Code)
2177 }
2178 body := w.Body.String()
2179 if !strings.Contains(body, "barbara-tinkering") || !strings.Contains(body, "16593de5") {
2180 t.Fatalf("the refusal names neither the box nor its version, so nobody knows which box to roll: %s", body)
2181 }
2182 // A current agent passes, with no ack written anywhere.
2183 w = httptest.NewRecorder()
2184 current := instanceBox{Found: true, Label: "annie-welding", Release: "c1d40ee", ConfigSchema: appconfig.ConfigAckSchema}
2185 if fx.srv.refuseUnlessLayerAgent(w, "book.example.com", current) {
2186 t.Fatalf("a current agent was refused because no ack existed yet: %s", w.Body.String())
2187 }
2188}
2189
2190// instanceBoxFacts joins the two files that already exist: which box serves
2191// this host (control/edges/<box>.json) and what that box says about itself
2192// (status/boxes/<label>.json).
2193func TestInstanceBoxFactsJoinsTheEdgesFileToTheBoxRecord(t *testing.T) {
2194 fx := newConsoleFixture(t)
2195 ctx, now := context.Background(), time.Now()
2196 writeEdgesFile(t, fx.rel, "annie-welding", "eu-west-1", "book.example.com")
2197 if err := status.WriteBox(ctx, fx.rel, status.Box{
2198 GeneratedAt: now, Label: "annie-welding", Region: "eu-west-1",
2199 Metrics: &status.BoxMetrics{
2200 Release: "c1d40ee", ConfigSchema: 1,
2201 RouteUIDs: map[string]string{"book.example.com": status.UIDDynamic},
2202 },
2203 }); err != nil {
2204 t.Fatal(err)
2205 }
2206 got, err := fx.srv.instanceBoxFacts(ctx, "book.example.com", now)
2207 if err != nil {
2208 t.Fatal(err)
2209 }
2210 if !got.Found || got.Stale || got.Label != "annie-welding" || got.UID != status.UIDDynamic || got.ConfigSchema != 1 {
2211 t.Fatalf("instanceBoxFacts = %+v", got)
2212 }
2213 // A host no edges file names is not found, and that is not an error.
2214 if got, err := fx.srv.instanceBoxFacts(ctx, "shop.example.com", now); err != nil || got.Found {
2215 t.Fatalf("an unreported host = %+v %v, want not found and no error", got, err)
2216 }
2217 // A box that has stopped reporting is stale rather than absent, so the
2218 // refusal can say which box to go and look at.
2219 later := now.Add(4 * status.SnapshotPeriod)
2220 if got, err := fx.srv.instanceBoxFacts(ctx, "book.example.com", later); err != nil || !got.Stale || got.Label != "annie-welding" {
2221 t.Fatalf("a quiet box = %+v %v, want stale and still named", got, err)
2222 }
2223}
2224```
2225
2226`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.
2227
2228- [ ] **Step 2: Run the test — expect failure**
2229
2230```bash
2231TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run 'OwnCredential|LayerRefusal|BoxFacts' -count=1
2232```
2233
2234Expected: build failure, `undefined: instanceBox`, `undefined: refuseUnlessOwnUID`.
2235
2236- [ ] **Step 3: Implement**
2237
2238`internal/console/apimailinstance.go` (create) with a file comment covering the family, then:
2239
2240```go
2241// instanceBox is everything the enable door needs to know about the machine
2242// an instance runs on, joined from the two files that already carry it:
2243// control/edges/<box>.json says which box serves the host, and
2244// status/boxes/<label>.json is what that box says about itself.
2245//
2246// Found is false when no edges file names the host or no box record matches
2247// the label it named. That is a state, not an error: a freshly created
2248// instance has neither until its box next reports.
2249type instanceBox struct {
2250 Label, Region, Release, UID string
2251 ConfigSchema int
2252 Found, Stale bool
2253}
2254
2255// instanceBoxFacts answers for one host. A stale box keeps its label, so
2256// the refusal can name the machine to go and look at rather than saying
2257// "somewhere".
2258func (s *Server) instanceBoxFacts(ctx context.Context, host string, now time.Time) (instanceBox, error) {
2259 served := loadEdgesJoin(ctx, s.cfg.Releases)[host]
2260 if len(served) == 0 {
2261 return instanceBox{}, nil
2262 }
2263 boxes, err := status.LoadBoxes(ctx, s.cfg.Releases)
2264 if err != nil {
2265 return instanceBox{}, err
2266 }
2267 byLabel := make(map[string]status.Box, len(boxes))
2268 for _, b := range boxes {
2269 byLabel[b.Label] = b
2270 }
2271 // A host can legitimately be served by more than one box across a move.
2272 // The FRESHEST box is the one describing what is running now, which is
2273 // the same rule LatestConfigAck applies to acks.
2274 var best instanceBox
2275 for _, sb := range served {
2276 b, ok := byLabel[sb.Box]
2277 if !ok {
2278 continue
2279 }
2280 cur := instanceBox{
2281 Label: b.Label, Region: b.Region, Found: true,
2282 Stale: status.BoxStale(b, now, status.SnapshotPeriod),
2283 }
2284 if b.Metrics != nil {
2285 cur.Release, cur.ConfigSchema = b.Metrics.Release, b.Metrics.ConfigSchema
2286 cur.UID = b.Metrics.RouteUIDs[host]
2287 }
2288 if !best.Found || (best.Stale && !cur.Stale) {
2289 best = cur
2290 }
2291 }
2292 return best, nil
2293}
2294
2295// refuseUnlessOwnUID is §6.4's first check: this instance's process must
2296// get a unix uid no sibling shares, or the credential about to be delivered
2297// to it is readable by every other instance of the same app.
2298//
2299// The predicate is NOT "unit-backed". A host_role app runs its units as
2300// User=carlos-<app> with DynamicUser=no, one uid per app, and ProtectProc
2301// hides other users' processes rather than a sibling's at the same uid. An
2302// exec route, nested layout included, runs at the agent's own uid.
2303//
2304// Reports whether it refused; a refusal has already been written.
2305func (s *Server) refuseUnlessOwnUID(w http.ResponseWriter, host string, b instanceBox) bool {
2306 switch {
2307 case !b.Found:
2308 jsonError(w, http.StatusConflict, /* copy-review string 1 */)
2309 case b.Stale:
2310 jsonError(w, http.StatusConflict, /* copy-review string 2 */)
2311 case b.UID == status.UIDApp:
2312 jsonError(w, http.StatusConflict, /* copy-review string 3 */)
2313 case b.UID == status.UIDDynamic:
2314 return false
2315 default:
2316 // UIDAgent, and anything a newer agent reports that this console
2317 // does not recognise. An unknown word is refused rather than
2318 // guessed at: the permissive direction hands a credential to every
2319 // sibling.
2320 jsonError(w, http.StatusConflict, /* copy-review string 4 */)
2321 }
2322 return true
2323}
2324
2325// refuseUnlessLayerAgent is §6.4's second check: the box must be running an
2326// agent that renders the instance config layer, or the credential this
2327// enable is about to write would sit in a bucket object nothing reads.
2328//
2329// It reads the box record and never the absence of an ack. Acks are written
2330// only when a pass changes a file, so a fresh instance, or one whose config
2331// has not moved since its agent was rolled, has no ack at all — absence
2332// means "not yet delivered", never "old agent" (design §6.5).
2333func (s *Server) refuseUnlessLayerAgent(w http.ResponseWriter, host string, b instanceBox) bool {
2334 switch {
2335 case !b.Found:
2336 jsonError(w, http.StatusConflict, /* copy-review string 1 */)
2337 case b.Stale:
2338 jsonError(w, http.StatusConflict, /* copy-review string 2 */)
2339 case b.ConfigSchema < appconfig.ConfigAckSchema:
2340 jsonError(w, http.StatusConflict, /* copy-review string 5 */)
2341 default:
2342 return false
2343 }
2344 return true
2345}
2346```
2347
2348Both 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.
2349
2350- [ ] **Step 4: Run the test — expect success**
2351
2352```bash
2353TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -count=1
2354```
2355
2356- [ ] **Step 5: Gate and commit**
2357
2358```bash
2359gofmt -w internal/console/apimailinstance.go internal/console/apimailinstance_test.go
2360GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
2361GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
2362TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
2363gofmt -l .
2364git add internal/console/apimailinstance.go internal/console/apimailinstance_test.go
2365git commit -m "console: the two refusals an instance sender's enable makes (plan 5, task 8)
2366
2367Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
2368```
2369
2370---
2371
2372### Task 9: The seven doors
2373
2374**Files:**
2375- 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`)
2376
2377**Interfaces:**
2378- 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.
2379- Produces:
2380 ```go
2381 func (s *Server) emailInstanceGate(w http.ResponseWriter, r *http.Request, write bool) (actor string, acct *Account, app, host, sender string, ok bool)
2382 func (s *Server) handleAPIEmailInstanceDeclare(w http.ResponseWriter, r *http.Request)
2383 func (s *Server) handleAPIEmailInstanceStatus(w http.ResponseWriter, r *http.Request)
2384 func (s *Server) handleAPIEmailInstanceDomainEnsure(w http.ResponseWriter, r *http.Request)
2385 func (s *Server) handleAPIEmailInstanceDomainVerify(w http.ResponseWriter, r *http.Request)
2386 func (s *Server) handleAPIEmailInstanceEnable(w http.ResponseWriter, r *http.Request)
2387 func (s *Server) handleAPIEmailInstanceRotate(w http.ResponseWriter, r *http.Request)
2388 func (s *Server) handleAPIEmailInstanceTeardown(w http.ResponseWriter, r *http.Request)
2389 func (s *Server) registerEmailInstanceRoutes()
2390 ```
2391
2392**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.
2393
2394**`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.
2395
2396**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.
2397
2398- [ ] **Step 0: Run the copy-review gate**
2399
2400New 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.
2401
2402- [ ] **Step 1: Write the failing test**
2403
2404`internal/console/apimailinstance_test.go` (append):
2405
2406```go
2407// The whole flow through the doors, and the transition that the FIRST
2408// instance enable performs: the app moves into per-instance mode, its
2409// credential is reaped, and the variables leave the default bundle.
2410func TestTheFirstInstanceEnableMovesTheAppIntoPerInstanceMode(t *testing.T) {
2411 fx := newMailInstanceFixture(t) // app declared and enabled, one instance route reporting UIDDynamic
2412 ctx := context.Background()
2413
2414 fx.do(t, "POST", "/api/cli/apps/"+OpsAccount+"/harbourmail/instances/book.example.com/email",
2415 `{"env_prefix":"HARBOURMAIL_SMTP","from":"hello@book.example.com"}`, 200)
2416 fx.do(t, "POST", "/api/cli/apps/"+OpsAccount+"/harbourmail/instances/book.example.com/email/domains",
2417 `{"domain":"book.example.com"}`, 200)
2418 fx.verifyDomain(t, "book.example.com")
2419 fx.do(t, "POST", "/api/cli/apps/"+OpsAccount+"/harbourmail/instances/book.example.com/email/enable", ``, 200)
2420
2421 appSender, _, err := mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", mail.SenderAppID)
2422 if err != nil {
2423 t.Fatal(err)
2424 }
2425 if !appSender.PerInstance || appSender.Provisioned.Active {
2426 t.Fatalf("the first instance enable did not move the app: %+v", appSender)
2427 }
2428 man, err := appconfig.Get(ctx, fx.rel, OpsAccount, "harbourmail")
2429 if err != nil {
2430 t.Fatal(err)
2431 }
2432 if _, ok := man.Vars["HARBOURMAIL_SMTP_PASS"]; ok {
2433 t.Fatal("the app-wide credential is still delivered to every instance")
2434 }
2435 // And the last teardown takes the app back out, so a plain `carlos
2436 // email enable` works again afterwards.
2437 fx.do(t, "DELETE", "/api/cli/apps/"+OpsAccount+"/harbourmail/instances/book.example.com/email", ``, 200)
2438 appSender, _, err = mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", mail.SenderAppID)
2439 if err != nil {
2440 t.Fatal(err)
2441 }
2442 if appSender.PerInstance || appSender.Provisioned.PauseKind != "" {
2443 t.Fatalf("the last teardown left the app in per-instance mode: %+v", appSender)
2444 }
2445}
2446
2447// A stranger, and an instance that exists in another account, must be
2448// byte-identical. Anything else makes this family an oracle for which
2449// hostnames exist.
2450func TestAStrangersRefusalIsByteIdenticalAcrossTheInstanceEmailDoors(t *testing.T) {
2451 fx := newMailInstanceFixture(t)
2452 mine := fx.dial(t, "GET", "/api/cli/apps/"+OpsAccount+"/harbourmail/instances/nosuchhost.example.com/email", ``, fx.strangerToken)
2453 theirs := fx.dial(t, "GET", "/api/cli/apps/"+OpsAccount+"/harbourmail/instances/book.example.com/email", ``, fx.strangerToken)
2454 if mine.Code != theirs.Code || mine.Body.String() != theirs.Body.String() {
2455 t.Fatalf("a real instance answers a stranger differently from an imaginary one:\n %d %q\n %d %q",
2456 mine.Code, mine.Body.String(), theirs.Code, theirs.Body.String())
2457 }
2458}
2459
2460// A path traversal in {host} never reaches a bucket key.
2461func TestTheInstanceEmailDoorsRefuseAMalformedHost(t *testing.T) {
2462 fx := newMailInstanceFixture(t)
2463 fx.do(t, "GET", "/api/cli/apps/"+OpsAccount+"/harbourmail/instances/..%2f..%2fchannels/email", ``, 404)
2464}
2465```
2466
2467`internal/console/svcroutes_test.go`, `TestMailRoleReachesExactlyTheEmailDoors`'s `want` gains the seven, with the sentence that says why they are Mail and not Instance:
2468
2469```go
2470 // §6.4: the instance email family is the hub's, not the customer
2471 // instance's. Allow(svcrole.Mail) keeps the Instance role pin
2472 // untouched, which is what makes per-instance machine tokens a
2473 // later change to a role set and nothing else.
2474 "POST /api/cli/apps/{account}/{app}/instances/{host}/email",
2475 "GET /api/cli/apps/{account}/{app}/instances/{host}/email",
2476 "DELETE /api/cli/apps/{account}/{app}/instances/{host}/email",
2477 "POST /api/cli/apps/{account}/{app}/instances/{host}/email/domains",
2478 "POST /api/cli/apps/{account}/{app}/instances/{host}/email/domains/{domain}/verify",
2479 "POST /api/cli/apps/{account}/{app}/instances/{host}/email/enable",
2480 "POST /api/cli/apps/{account}/{app}/instances/{host}/email/rotate",
2481```
2482
2483Nothing 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.
2484
2485- [ ] **Step 2: Run the test — expect failure**
2486
2487```bash
2488TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run 'MailRoleReaches|PerInstanceMode|ByteIdentical|MalformedHost' -count=1
2489```
2490
2491Expected: `TestMailRoleReachesExactlyTheEmailDoors` fails with seven missing doors, and the flow tests 404.
2492
2493- [ ] **Step 3: Implement**
2494
2495`internal/console/apimailinstance.go`, the gate:
2496
2497```go
2498// emailInstanceGate is every door in this file's front half: the principal
2499// and the account/app 404 (apiAccountApp), the host's shape, the instance
2500// record's existence, and then the email family's own budget and backend
2501// gates. It returns the sender id as well as the host, because the id is
2502// the bounded form and every mail verb takes it.
2503//
2504// write picks the gate: the two reads take mailAvailable, the five writes
2505// take mailWriteGates, which is the email family's budget rather than the
2506// instances one. These are email doors that happen to name a host, and
2507// their caller is a hub onboarding customers in bulk — the workload plan 3
2508// sized that budget for.
2509func (s *Server) emailInstanceGate(w http.ResponseWriter, r *http.Request, write bool) (string, *Account, string, string, string, bool) {
2510 actor, acct, app, ok := s.apiAccountApp(w, r)
2511 if !ok {
2512 return "", nil, "", "", "", false
2513 }
2514 host := r.PathValue("host")
2515 // Before GetRecord, always: a {host} path value arrives
2516 // percent-decoded, and RecordKey joins whatever it is given.
2517 if !provision.ValidHostShape(host) {
2518 http.NotFound(w, r)
2519 return "", nil, "", "", "", false
2520 }
2521 if write {
2522 if !s.mailWriteGates(w, r) {
2523 return "", nil, "", "", "", false
2524 }
2525 } else if !s.mailAvailable(w) {
2526 return "", nil, "", "", "", false
2527 }
2528 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
2529 defer cancel()
2530 rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
2531 if errors.Is(err, os.ErrNotExist) {
2532 http.NotFound(w, r)
2533 return "", nil, "", "", "", false
2534 }
2535 if err != nil {
2536 log.Printf("console: api email instance %s/%s/%s: %v", acct.Sqid, app, host, err)
2537 jsonError(w, http.StatusBadGateway, "could not read the instance")
2538 return "", nil, "", "", "", false
2539 }
2540 _ = rec
2541 return actor, acct, app, host, provision.InstanceID(host), true
2542}
2543```
2544
2545The seven handlers, each the app-level door's body with the sender threaded through:
2546
2547- **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.
2548- **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.
2549- **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.
2550- **enable** is the ordered one, and the order is the task:
2551 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.
2552 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`.)
2553 3. `mail.EnterPerInstance` — idempotent, so the second instance's enable is a no-op and the first one's is the transition.
2554 4. `mail.EnableInstance`, keeping the returned overlay etag.
2555 5. `s.bumpSerial(ctx)`, because plan 4's `ApplyInstance` bumps the app generation and the boxes read config when the serial moves.
2556 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.
2557- **rotate** mirrors `handleAPIEmailRotate`, `?finish=1` included, over `mail.RotateInstance`/`mail.RotateFinishInstance`.
2558- **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.
2559- **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.
2560
2561`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.
2562
2563- [ ] **Step 4: Run the test — expect success**
2564
2565```bash
2566TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -count=1
2567```
2568
2569- [ ] **Step 5: Gate and commit**
2570
2571```bash
2572gofmt -w internal/console/apimailinstance.go internal/console/apimailinstance_test.go internal/console/authcli.go internal/console/svcroutes_test.go
2573GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
2574GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
2575TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
2576gofmt -l .
2577git commit -am "console: the seven instance email doors (plan 5, task 9)
2578
2579Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
2580```
2581
2582---
2583
2584### Task 10: The pause door gains an `instance` field
2585
2586**Files:**
2587- Modify: `internal/console/apimail.go` (`handleAPIEmailPause`), `internal/console/apimail_test.go`
2588
2589**Interfaces:**
2590- 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.
2591- 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.
2592
2593**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.
2594
2595- [ ] **Step 0: Run the copy-review gate**
2596
2597Two 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.
2598
2599- [ ] **Step 1: Write the failing test**
2600
2601`internal/console/apimail_test.go` (append):
2602
2603```go
2604// An operator pauses ONE customer, not the app. This is the whole point of
2605// a sender: one tenant's bounce rate stops that tenant.
2606func TestThePauseDoorTakesAnInstanceAndPausesOnlyThatSender(t *testing.T) {
2607 fx := newMailInstanceFixture(t)
2608 ctx := context.Background()
2609 fx.enableTwoInstances(t, "book.example.com", "shop.example.com")
2610
2611 fx.asOperator(t, "POST", "/api/cli/apps/"+OpsAccount+"/harbourmail/email/pause",
2612 `{"instance":"book.example.com","paused":true}`, 200)
2613
2614 paused, _, err := mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", "book.example.com")
2615 if err != nil {
2616 t.Fatal(err)
2617 }
2618 if paused.Provisioned.Active {
2619 t.Fatal("the named instance was not paused")
2620 }
2621 sibling, _, err := mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", "shop.example.com")
2622 if err != nil {
2623 t.Fatal(err)
2624 }
2625 if !sibling.Provisioned.Active {
2626 t.Fatal("pausing one customer paused another")
2627 }
2628 // A cap raise is per sender too.
2629 fx.asOperator(t, "POST", "/api/cli/apps/"+OpsAccount+"/harbourmail/email/pause",
2630 `{"instance":"book.example.com","daily_cap":5000}`, 200)
2631 raised, _, err := mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", "book.example.com")
2632 if err != nil {
2633 t.Fatal(err)
2634 }
2635 if raised.Grant.DailyCap != 5000 || sibling.Grant.DailyCap == 5000 {
2636 t.Fatalf("the cap raise was not this sender's alone: %d", raised.Grant.DailyCap)
2637 }
2638 // And the app sender cannot be resumed while the app is per-instance:
2639 // resuming it would re-arm a credential nothing delivers any more.
2640 fx.asOperator(t, "POST", "/api/cli/apps/"+OpsAccount+"/harbourmail/email/pause",
2641 `{"paused":false}`, 409)
2642}
2643
2644// The operator gate is unmoved by the new field: a member, and a fleet
2645// operator not on CARLOS_MAIL_OPERATORS, both still get the same silent
2646// 404 they get today, before any account is looked up.
2647func TestTheInstanceFieldDoesNotWidenThePauseDoor(t *testing.T) {
2648 fx := newMailInstanceFixture(t)
2649 got := fx.dial(t, "POST", "/api/cli/apps/"+OpsAccount+"/harbourmail/email/pause",
2650 `{"instance":"book.example.com","paused":true}`, fx.memberToken)
2651 if got.Code != 404 {
2652 t.Fatalf("a member reached the pause door with an instance field: %d %s", got.Code, got.Body.String())
2653 }
2654}
2655```
2656
2657- [ ] **Step 2: Run the test — expect failure**
2658
2659```bash
2660TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run 'PauseDoorTakesAnInstance|DoesNotWidenThePauseDoor' -count=1
2661```
2662
2663Expected: the instance field is ignored, so the app sender is paused and the assertions fail.
2664
2665- [ ] **Step 3: Implement**
2666
2667`internal/console/apimail.go`, `handleAPIEmailPause`. The request struct gains the field, with the comment saying what an empty one means:
2668
2669```go
2670 var req struct {
2671 Paused *bool `json:"paused"`
2672 // Instance names ONE instance sender to act on. Empty means the
2673 // app sender, which is what every existing caller sends, so the
2674 // door's wire shape stays additive. It is a field rather than a
2675 // second door because the operator gate above it is the thing this
2676 // family must never grow a second copy of.
2677 Instance string `json:"instance"`
2678 DailyCap int `json:"daily_cap"`
2679 }
2680```
2681
2682After `readJSON` and the "this request would change nothing" check, resolve the sender once:
2683
2684```go
2685 sender := mail.SenderAppID
2686 if req.Instance != "" {
2687 // The same shape check the instance doors make, and for the same
2688 // reason: this string becomes a bucket key segment.
2689 if !provision.ValidHostShape(req.Instance) {
2690 jsonError(w, http.StatusBadRequest, "that is not an instance hostname")
2691 return
2692 }
2693 sender = provision.InstanceID(req.Instance)
2694 }
2695```
2696
2697Then: 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.
2698
2699The 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.
2700
2701- [ ] **Step 4: Run the test — expect success**
2702
2703```bash
2704TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -count=1
2705```
2706
2707- [ ] **Step 5: Gate and commit**
2708
2709```bash
2710gofmt -w internal/console/apimail.go internal/console/apimail_test.go
2711GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
2712GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
2713TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
2714gofmt -l .
2715git commit -am "console: the pause door pauses one instance sender (plan 5, task 10)
2716
2717Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
2718```
2719
2720---
2721
2722### Task 11: The reply carries every sender, its host and its delivery
2723
2724**Files:**
2725- Modify: `internal/console/apimail.go` (`emailReply`, `handleAPIEmailStatus`), `internal/console/apimailinstance.go` (`emailInstanceReply`), `internal/console/apimail_test.go`
2726
2727**Interfaces:**
2728- 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`.
2729- Produces:
2730 ```go
2731 func (s *Server) emailInstanceReply(ctx context.Context, acct *Account, app string, sn *mail.Sender) (map[string]any, error)
2732 // emailReply's per-sender objects gain: "host", "delivery", "missing_vars"
2733 ```
2734
2735**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.
2736
2737**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.
2738
2739- [ ] **Step 1: Write the failing test**
2740
2741`internal/console/apimail_test.go` (append):
2742
2743```go
2744// An instance sender appears in the senders array with its host, its own
2745// counts and its delivery state, and the top-level fields still describe
2746// the app sender.
2747func TestEmailReplyCarriesAnInstanceSendersHostAndDelivery(t *testing.T) {
2748 fx := newMailInstanceFixture(t)
2749 ctx := context.Background()
2750 fx.enableTwoInstances(t, "book.example.com", "shop.example.com")
2751 day := time.Now().UTC().Format("2006-01-02")
2752 if err := fx.st.RecordMailEvent(ctx, OpsAccount, "harbourmail", "book.example.com", day, mail.EventSend); err != nil {
2753 t.Fatal(err)
2754 }
2755
2756 acct, err := fx.st.AccountBySqid(OpsAccount)
2757 if err != nil || acct == nil {
2758 t.Fatalf("AccountBySqid = %v %v", acct, err)
2759 }
2760 rec, _, err := mail.Get(ctx, fx.rel, OpsAccount, "harbourmail")
2761 if err != nil {
2762 t.Fatal(err)
2763 }
2764 out, err := fx.srv.emailReply(ctx, acct, "harbourmail", rec)
2765 if err != nil {
2766 t.Fatal(err)
2767 }
2768 senders, _ := out["senders"].([]map[string]any)
2769 if len(senders) != 3 {
2770 t.Fatalf("senders = %d, want the app sender and two instances", len(senders))
2771 }
2772 var book map[string]any
2773 for _, sn := range senders {
2774 if sn["id"] == "book.example.com" {
2775 book = sn
2776 }
2777 }
2778 if book == nil {
2779 t.Fatal("the instance sender is missing from the reply")
2780 }
2781 if book["host"] != "book.example.com" || book["scope"] != mail.ScopeInstance {
2782 t.Fatalf("the instance sender does not describe itself: %+v", book)
2783 }
2784 if book["delivery"] != appconfig.InstanceConfigNotYetDelivered {
2785 t.Fatalf("delivery = %v, want %q before any box has acked", book["delivery"], appconfig.InstanceConfigNotYetDelivered)
2786 }
2787 counts, _ := book["counts"].(map[string]any)
2788 if counts["sends"] != 1 {
2789 t.Fatalf("this sender's counts are not its own: %+v", counts)
2790 }
2791 // Unchanged for an older CLI: the top-level fields are the app's.
2792 if out["active"] != false {
2793 t.Fatalf("the top-level active moved off the app sender: %v", out["active"])
2794 }
2795}
2796
2797// An ack carrying the overlay's current etag reads as delivered, and one
2798// carrying an older etag reads as stale. Both are decidable; the absence of
2799// an ack is neither.
2800func TestDeliveryIsDecidedByTheAcksOverlayEtag(t *testing.T) {
2801 fx := newMailInstanceFixture(t)
2802 ctx := context.Background()
2803 fx.enableTwoInstances(t, "book.example.com", "shop.example.com")
2804 _, etag, _, err := appconfig.LoadInstanceConfig(ctx, fx.rel, OpsAccount, "harbourmail", "book.example.com")
2805 if err != nil {
2806 t.Fatal(err)
2807 }
2808 fx.writeAck(t, "annie-welding", "book.example.com", appconfig.ConfigAckSchema, etag)
2809 sn, _, err := mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", "book.example.com")
2810 if err != nil {
2811 t.Fatal(err)
2812 }
2813 acct, _ := fx.st.AccountBySqid(OpsAccount)
2814 out, err := fx.srv.emailInstanceReply(ctx, acct, "harbourmail", sn)
2815 if err != nil {
2816 t.Fatal(err)
2817 }
2818 if out["delivery"] != appconfig.InstanceConfigDelivered {
2819 t.Fatalf("delivery = %v, want %q", out["delivery"], appconfig.InstanceConfigDelivered)
2820 }
2821 fx.writeAck(t, "annie-welding", "book.example.com", appconfig.ConfigAckSchema, "an-older-etag")
2822 out, err = fx.srv.emailInstanceReply(ctx, acct, "harbourmail", sn)
2823 if err != nil {
2824 t.Fatal(err)
2825 }
2826 if out["delivery"] != appconfig.InstanceConfigStale {
2827 t.Fatalf("delivery = %v, want %q", out["delivery"], appconfig.InstanceConfigStale)
2828 }
2829}
2830
2831// The status read resumes a stale cap pause for EVERY sender, not just the
2832// app's. A paused sender sends nothing, so no SES event ever arrives to run
2833// the event-path check, and running `carlos email status` is what actually
2834// clears a rolled-over daily budget.
2835func TestTheStatusReadResumesEverySendersStaleCapPause(t *testing.T) {
2836 fx := newMailInstanceFixture(t)
2837 ctx, now := context.Background(), time.Now()
2838 fx.enableTwoInstances(t, "book.example.com", "shop.example.com")
2839 fx.pauseSenderDirectly(t, "book.example.com", mail.PauseKindCap, now.AddDate(0, 0, -1))
2840
2841 fx.do(t, "GET", "/api/cli/apps/"+OpsAccount+"/harbourmail/email", ``, 200)
2842
2843 got, _, err := mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", "book.example.com")
2844 if err != nil {
2845 t.Fatal(err)
2846 }
2847 if !got.Provisioned.Active || got.Provisioned.PauseKind != "" {
2848 t.Fatalf("the instance sender's yesterday cap pause was not cleared by the status read: %+v", got.Provisioned)
2849 }
2850}
2851```
2852
2853- [ ] **Step 2: Run the test — expect failure**
2854
2855```bash
2856TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run 'HostAndDelivery|OverlayEtag|ResumesEverySender' -count=1
2857```
2858
2859Expected: `host`, `delivery` and `missing_vars` are absent from the per-sender objects, `emailInstanceReply` is undefined, and only the app sender resumes.
2860
2861- [ ] **Step 3: Implement**
2862
2863`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.
2864
2865Pull the per-sender object into one function so the array and the single-sender reply can never drift:
2866
2867```go
2868// senderOut is the one description of a sender this package gives, used by
2869// the senders array and by the single-sender reply the instance doors and
2870// the pause door answer with. One function, for storeReply's reason: two
2871// descriptions of the same record drift, and the first anyone notices is a
2872// member reading two different answers to one question.
2873func (s *Server) senderOut(ctx context.Context, acct *Account, app string, sn *mail.Sender, day string) (map[string]any, error)
2874```
2875
2876`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.
2877
2878`internal/console/apimailinstance.go`:
2879
2880```go
2881// emailInstanceReply describes ONE instance sender, for the instance status
2882// door, the instance walk doors and an operator's instance pause. It is
2883// senderOut plus the app-level context a caller acting on one instance
2884// still needs: which app, and which env names it is delivered under.
2885func (s *Server) emailInstanceReply(ctx context.Context, acct *Account, app string, sn *mail.Sender) (map[string]any, error)
2886```
2887
2888- [ ] **Step 4: Run the test — expect success**
2889
2890```bash
2891TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -count=1
2892```
2893
2894- [ ] **Step 5: Gate and commit**
2895
2896```bash
2897gofmt -w internal/console/apimail.go internal/console/apimailinstance.go internal/console/apimail_test.go
2898GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
2899GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
2900TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
2901gofmt -l .
2902git commit -am "console: every sender's host, counts and delivery in the reply (plan 5, task 11)
2903
2904Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
2905```
2906
2907---
2908
2909### Task 12: `carlos email status --instance`, and a line per instance sender
2910
2911**Files:**
2912- Modify: `mail_cli.go` (`apiEmailSender`, `emailStatus`, `runEmailStatus`, `printEmailStatus`), `mail_cli_test.go`
2913
2914**Interfaces:**
2915- 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.
2916- Produces:
2917 ```go
2918 // apiEmailSender gains: Host string `json:"host,omitempty"`; Delivery string `json:"delivery,omitempty"`; MissingVars []string `json:"missing_vars,omitempty"`
2919 func (s *apiSession) emailInstanceStatus(ctx context.Context, a apiAcct, app, host string) (apiEmailSender, error)
2920 func printEmailSenderLine(w io.Writer, res apiEmailReply, sn apiEmailSender)
2921 func printEmailInstanceStatus(w io.Writer, app string, sn apiEmailSender)
2922 ```
2923
2924- [ ] **Step 0: Run the copy-review gate**
2925
2926Every 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:
2927
2928- the per-sender line: ` instance {host}: {active|PAUSED} (key {keyid}) — {n}/{cap} sends today`
2929- 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`
2930- the per-instance drift line: ` {names} not yet delivered to this instance`
2931- 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`
2932- `--instance` with no sender: `no email sender for {host} — carlos email instance enable --app {app} --instance {host}`
2933
2934Note 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.
2935
2936- [ ] **Step 1: Write the failing test**
2937
2938`mail_cli_test.go` (append):
2939
2940```go
2941// A member with instance senders sees one line each, with the host, the
2942// pause state and the delivery state. A member with only the app sender
2943// sees exactly what they saw before, which is what keeps this additive.
2944func TestPrintEmailStatusRendersInstanceSendersWithTheirDelivery(t *testing.T) {
2945 var buf bytes.Buffer
2946 printEmailStatus(&buf, apiEmailReply{
2947 App: "harbourmail", DailyCap: 50, Active: false,
2948 Provisioned: &apiEmailProvisioned{KeyID: "AKIA1", PauseKind: "per-instance"},
2949 Counts: apiEmailCounts{Sends: 12},
2950 Senders: []apiEmailSender{
2951 {ID: "app", Scope: "app", Source: "domain", Active: false, PauseKind: "per-instance", DailyCap: 50, KeyID: "AKIA1"},
2952 {ID: "book.example.com", Scope: "instance", Source: "domain", Host: "book.example.com",
2953 Active: true, DailyCap: 1000, KeyID: "AKIA9", Delivery: "delivered",
2954 Counts: apiEmailCounts{Sends: 8, Bounces: 1}},
2955 {ID: "shop.example.com", Scope: "instance", Source: "domain", Host: "shop.example.com",
2956 Active: false, PauseKind: "cap", DailyCap: 1000, KeyID: "AKIA7", Delivery: "stale",
2957 Counts: apiEmailCounts{Sends: 1000}},
2958 },
2959 })
2960 out := buf.String()
2961 for _, want := range []string{"book.example.com", "shop.example.com", "AKIA9", "8/1000"} {
2962 if !strings.Contains(out, want) {
2963 t.Errorf("status is missing %q:\n%s", want, out)
2964 }
2965 }
2966 // A paused instance says why, using the same sentence the app sender
2967 // has always used for a cap pause.
2968 if !strings.Contains(out, "daily cap reached") {
2969 t.Errorf("a cap-paused instance does not say it will clear itself:\n%s", out)
2970 }
2971 // And the app sender says it is per-instance rather than reading as an
2972 // unexplained pause, which is what it would look like otherwise.
2973 if !strings.Contains(out, "delivered per instance") {
2974 t.Errorf("the app sender in per-instance mode reads as a mystery pause:\n%s", out)
2975 }
2976}
2977
2978// --instance prints one sender and nothing else.
2979func TestPrintEmailInstanceStatusPrintsOneSender(t *testing.T) {
2980 var buf bytes.Buffer
2981 printEmailInstanceStatus(&buf, "harbourmail", apiEmailSender{
2982 ID: "book.example.com", Scope: "instance", Source: "domain", Host: "book.example.com",
2983 Active: true, DailyCap: 1000, KeyID: "AKIA9", Delivery: "not-yet-delivered",
2984 MissingVars: []string{"HARBOURMAIL_SMTP_PASS"},
2985 Counts: apiEmailCounts{Sends: 3},
2986 })
2987 out := buf.String()
2988 if strings.Contains(out, "shop.example.com") {
2989 t.Errorf("--instance printed more than one sender:\n%s", out)
2990 }
2991 if !strings.Contains(out, "HARBOURMAIL_SMTP_PASS") {
2992 t.Errorf("--instance does not name what has not been delivered:\n%s", out)
2993 }
2994}
2995```
2996
2997- [ ] **Step 2: Run the test — expect failure**
2998
2999```bash
3000GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'PrintEmailStatusRendersInstance|PrintEmailInstanceStatus' -count=1
3001```
3002
3003Expected: build failure, `unknown field Host in struct literal of type apiEmailSender`, `undefined: printEmailInstanceStatus`.
3004
3005- [ ] **Step 3: Implement**
3006
3007`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.
3008
3009`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.
3010
3011`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.
3012
3013`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.
3014
3015`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.
3016
3017- [ ] **Step 4: Run the test — expect success**
3018
3019```bash
3020GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -count=1
3021```
3022
3023- [ ] **Step 5: Gate and commit**
3024
3025```bash
3026gofmt -w mail_cli.go mail_cli_test.go
3027GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
3028GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
3029TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
3030gofmt -l .
3031git commit -am "cli: a line per instance sender, and email status --instance (plan 5, task 12)
3032
3033Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
3034```
3035
3036---
3037
3038### Task 13: `carlos email instance`, the wrapper over the same doors
3039
3040**Files:**
3041- Modify: `mail_cli.go` (`runEmail`, `emailUsage`, the session methods), `mail_cli_test.go`
3042
3043**Interfaces:**
3044- 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`.
3045- Produces:
3046 ```go
3047 func runEmailInstance(args []string) // enable|domains|rotate|teardown|status
3048 func (s *apiSession) emailInstanceDeclare(ctx context.Context, a apiAcct, app, host, envPrefix, from string) (apiEmailSender, error)
3049 func (s *apiSession) emailInstanceDomainEnsure(ctx context.Context, a apiAcct, app, host, domain string) (apiEmailDomainEnsureReply, error)
3050 func (s *apiSession) emailInstanceDomainVerify(ctx context.Context, a apiAcct, app, host, domain string) (apiEmailDomainVerifyReply, error)
3051 func (s *apiSession) emailInstanceEnable(ctx context.Context, a apiAcct, app, host string) (apiEmailSender, error)
3052 func (s *apiSession) emailInstanceRotate(ctx context.Context, a apiAcct, app, host string, finish bool) (apiEmailSender, error)
3053 func (s *apiSession) emailInstanceTeardown(ctx context.Context, a apiAcct, app, host string) error
3054 func runEmailInstanceEnableFlow(ctx context.Context, w io.Writer, sess *apiSession, acct apiAcct, app, host, envPrefix, from, domain string, timeout time.Duration) error
3055 ```
3056
3057**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.
3058
3059**`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".
3060
3061- [ ] **Step 0: Run the copy-review gate**
3062
3063New 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.
3064
3065- [ ] **Step 1: Write the failing test**
3066
3067`mail_cli_test.go` (append):
3068
3069```go
3070// The flow calls the instance doors in order and stops at the first
3071// refusal, printing the console's own sentence rather than a wrapper's.
3072func TestEmailInstanceEnableFlowStopsAtTheDoorsRefusal(t *testing.T) {
3073 var got []string
3074 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3075 got = append(got, r.Method+" "+r.URL.Path)
3076 switch {
3077 case strings.HasSuffix(r.URL.Path, "/email/enable"):
3078 w.WriteHeader(http.StatusConflict)
3079 fmt.Fprint(w, `{"error":"book.example.com runs as a child of the box agent"}`)
3080 default:
3081 writeTestJSON(t, w, map[string]any{"domain": map[string]any{"domain": "book.example.com", "kind": "custom",
3082 "regions": map[string]any{"eu-west-1": map[string]any{"status": "verified"}}}})
3083 }
3084 }))
3085 defer srv.Close()
3086
3087 var buf bytes.Buffer
3088 err := runEmailInstanceEnableFlow(context.Background(), &buf, testSession(t, srv.URL), testAcct,
3089 "harbourmail", "book.example.com", "HARBOURMAIL_SMTP", "hello@book.example.com", "book.example.com", time.Second)
3090 if err == nil {
3091 t.Fatal("the flow reported success after the door refused")
3092 }
3093 if !strings.Contains(err.Error(), "child of the box agent") {
3094 t.Fatalf("the door's own sentence was swallowed: %v", err)
3095 }
3096 want := []string{
3097 "POST /api/cli/apps/bag/harbourmail/instances/book.example.com/email",
3098 "POST /api/cli/apps/bag/harbourmail/instances/book.example.com/email/domains",
3099 "POST /api/cli/apps/bag/harbourmail/instances/book.example.com/email/domains/book.example.com/verify",
3100 "POST /api/cli/apps/bag/harbourmail/instances/book.example.com/email/enable",
3101 }
3102 if !reflect.DeepEqual(got, want) {
3103 t.Fatalf("door order:\n got %q\nwant %q", got, want)
3104 }
3105}
3106
3107// A host with a slash, or one that is not a hostname at all, never reaches
3108// a URL: the CLI refuses before it dials, the same discipline
3109// validAppName applies to --app.
3110func TestEmailInstanceRefusesAHostThatIsNotAHostname(t *testing.T) {
3111 for _, bad := range []string{"", "../channels", "a/b", strings.Repeat("x", 254)} {
3112 if err := validInstanceHostArg(bad); err == nil {
3113 t.Errorf("validInstanceHostArg(%q) = nil", bad)
3114 }
3115 }
3116}
3117```
3118
3119- [ ] **Step 2: Run the test — expect failure**
3120
3121```bash
3122GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run EmailInstance -count=1
3123```
3124
3125Expected: build failure, `undefined: runEmailInstanceEnableFlow`, `undefined: validInstanceHostArg`.
3126
3127- [ ] **Step 3: Implement**
3128
3129`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.
3130
3131`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.
3132
3133Each 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.
3134
3135`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.
3136
3137`carlos email pause` and `carlos email resume` (`:1632`) gain `--instance`, passing it straight into the pause door's new field.
3138
3139- [ ] **Step 4: Run the test — expect success**
3140
3141```bash
3142GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -count=1
3143```
3144
3145- [ ] **Step 5: Gate and commit**
3146
3147```bash
3148gofmt -w mail_cli.go mail_cli_test.go
3149GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
3150GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
3151TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
3152gofmt -l .
3153git commit -am "cli: carlos email instance, the wrapper over the instance doors (plan 5, task 13)
3154
3155Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
3156```
3157
3158---
3159
3160### Task 14: One customer end to end, and the roll note
3161
3162**Files:**
3163- Modify: `internal/console/apimailinstance_test.go` (the end-to-end test), `README.md` (the roll note)
3164
3165**Interfaces:**
3166- Consumes: everything the previous thirteen tasks produced.
3167- Produces: no new name. One test that exercises the whole of §6 through the doors, and the operator-facing note that the roll needs.
3168
3169**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.
3170
3171- [ ] **Step 1: Write the failing test**
3172
3173`internal/console/apimailinstance_test.go` (append):
3174
3175```go
3176// TWO CUSTOMERS, ONE APP, END TO END. Everything in §6 at once, through
3177// the doors and nothing else, asserting the one property the whole design
3178// exists for: what customer A's instance can read is not customer B's.
3179func TestTwoCustomersOnOneAppNeverShareACredential(t *testing.T) {
3180 fx := newMailInstanceFixture(t)
3181 ctx := context.Background()
3182
3183 for _, host := range []string{"book.example.com", "shop.example.com"} {
3184 fx.do(t, "POST", fx.path(host, ""), `{"env_prefix":"HARBOURMAIL_SMTP","from":"hello@`+host+`"}`, 200)
3185 fx.do(t, "POST", fx.path(host, "/domains"), `{"domain":"`+host+`"}`, 200)
3186 fx.verifyDomain(t, host)
3187 fx.do(t, "POST", fx.path(host, "/enable"), ``, 200)
3188 }
3189
3190 // 1. Each instance's overlay carries its own key and nobody else's.
3191 book := fx.overlay(t, "book.example.com")
3192 shop := fx.overlay(t, "shop.example.com")
3193 if book["HARBOURMAIL_SMTP_USER"] == shop["HARBOURMAIL_SMTP_USER"] {
3194 t.Fatal("both customers were delivered the same credential")
3195 }
3196 // 2. The default bundle carries none of it, so no sibling reads either.
3197 man, err := appconfig.Get(ctx, fx.rel, OpsAccount, "harbourmail")
3198 if err != nil {
3199 t.Fatal(err)
3200 }
3201 for name := range book {
3202 if _, ok := man.Vars[name]; ok {
3203 t.Fatalf("%s is in the app-wide bundle every instance renders", name)
3204 }
3205 }
3206 // 3. Each principal's policy names its own domain and not the other's.
3207 for _, tc := range []struct{ host, other string }{
3208 {"book.example.com", "shop.example.com"},
3209 {"shop.example.com", "book.example.com"},
3210 } {
3211 sn, _, err := mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", tc.host)
3212 if err != nil {
3213 t.Fatal(err)
3214 }
3215 pol, ok := fx.prov.UserPolicy(sn.Provisioned.IAMUser)
3216 if !ok {
3217 t.Fatalf("no policy for %s", sn.Provisioned.IAMUser)
3218 }
3219 if !strings.Contains(pol, tc.host) || strings.Contains(pol, tc.other) {
3220 t.Fatalf("%s's policy is not its own:\n%s", tc.host, pol)
3221 }
3222 }
3223 // 4. The app sender is off, per-instance, and its keys are gone.
3224 appSender, _, err := mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", mail.SenderAppID)
3225 if err != nil {
3226 t.Fatal(err)
3227 }
3228 if appSender.Provisioned.Active || appSender.Provisioned.PauseKind != mail.PauseKindPerInstance {
3229 t.Fatalf("the app sender is still armed: %+v", appSender.Provisioned)
3230 }
3231 if keys := fx.prov.Keys(appSender.Provisioned.IAMUser); len(keys) != 0 {
3232 t.Fatalf("the app-wide credential still has %d live keys", len(keys))
3233 }
3234 // 5. A cap pause on one customer leaves the other sending.
3235 fx.asOperator(t, "POST", "/api/cli/apps/"+OpsAccount+"/harbourmail/email/pause",
3236 `{"instance":"book.example.com","paused":true}`, 200)
3237 other, _, err := mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", "shop.example.com")
3238 if err != nil {
3239 t.Fatal(err)
3240 }
3241 if !other.Provisioned.Active {
3242 t.Fatal("pausing one customer stopped the other")
3243 }
3244 // 6. Tearing the first one down leaves the second whole, and the app
3245 // stays in per-instance mode while any instance sender survives.
3246 fx.do(t, "DELETE", fx.path("book.example.com", ""), ``, 200)
3247 appSender, _, err = mail.GetSender(ctx, fx.rel, OpsAccount, "harbourmail", mail.SenderAppID)
3248 if err != nil {
3249 t.Fatal(err)
3250 }
3251 if !appSender.PerInstance {
3252 t.Fatal("the app left per-instance mode while an instance sender was still live")
3253 }
3254 if _, _, found, err := appconfig.LoadInstanceConfig(ctx, fx.rel, OpsAccount, "harbourmail", "shop.example.com"); err != nil || !found {
3255 t.Fatalf("the surviving customer's config went with the other one's teardown: %v %v", found, err)
3256 }
3257}
3258```
3259
3260- [ ] **Step 2: Run the test — expect failure**
3261
3262```bash
3263TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TwoCustomers -count=1
3264```
3265
3266Expected: 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.
3267
3268- [ ] **Step 3: Implement**
3269
3270Nothing in the tree, unless step 2 found something. Then `README.md`, in the platform roll notes:
3271
3272```markdown
3273### Rolling tenant email step 5 (instance senders)
3274
3275Three things change for people who are not asking for instance senders,
3276and each is visible before anyone asks.
3277
32781. **A domain can no longer be attached to two apps in one account.**
3279 `carlos email domains add` now refuses a domain another app or
3280 instance in the same account already holds. Domains already attached
3281 keep working; this fires only on a new attach. An account that needs
3282 two apps on one domain needs a second domain.
32832. **The first `carlos email instance enable` on an app switches that
3284 app's own credential off.** Its keys are deleted, not deactivated, and
3285 its `*_SMTP_*` variables are removed from the app's config bundle. Any
3286 instance still running on the app-wide credential stops sending at its
3287 next config render. This is per-instance mode, it is what the feature
3288 is, and `carlos email status --app` says so on the app's line.
32893. **Every box republishes its snapshot with two new fields** (the config
3290 schema it renders and each route's uid shape) within one heartbeat,
3291 five minutes. Until a box has republished, `carlos email instance
3292 enable` for an instance on it refuses and names the box. That is the
3293 expected state for the first five minutes after the roll, not a fault.
3294
3295**A box behind the fleet door cannot confirm delivery.**
3296`control/config-ack/` is outside that door's read and write scopes, so an
3297instance sender on such a box enables and sends, and its delivery reads as
3298not yet delivered forever. Granting the door that prefix is its own piece
3299of work.
3300
3301**Roll order:** boxes first, then the console. A console rolled ahead of
3302the boxes refuses every instance enable with the version refusal, which is
3303correct but reads as a broken feature.
3304```
3305
3306Run 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.
3307
3308- [ ] **Step 4: Run the full gate**
3309
3310```bash
3311GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
3312GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
3313TMPDIR=/tmp/g GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
3314gofmt -l .
3315```
3316
3317Cross-compile, because CI does:
3318
3319```bash
3320GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off GOOS=darwin GOARCH=arm64 go build ./... && \
3321GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off GOOS=windows GOARCH=amd64 go build ./...
3322```
3323
3324- [ ] **Step 5: Commit and land**
3325
3326```bash
3327gofmt -w internal/console/apimailinstance_test.go README.md 2>/dev/null; gofmt -l .
3328git commit -am "console: two customers on one app never share a credential, and the roll note (plan 5, task 14)
3329
3330Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
3331git push amadan HEAD:worktree-tenant-email-senders
3332```
3333
3334Then 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`.
3335
3336---
3337
3338## What this plan does not do
3339
3340Each of these is a decision with a reason, not an omission.
3341
3342- **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.
3343- **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.
3344- **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.
3345- **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.
3346- **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.
3347- **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.
3348- **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.
3349- **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.
3350
3351## Self-review
3352
3353**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`.
3354
3355**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.
3356
3357**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.
3358
3359**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.
3360
3361**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`.
3362
3363**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.
3364