carlos / platform Public

Design: fold in the code and AWS reviews

Per-instance tokens become a prerequisite, names get a hash slug inside
the IAM and SES bounds, configuration sets stay per account because an
identity has only one default, per-instance mode is a CAS flag that
deactivates the app key, and SES's own tenant pauses are reconciled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev ce69f2711bad91eb0bed326e952070c136eddb87 parent 6642673
1 file changed, +411 −307
  • docs/superpowers/specs/2026-09-15-tenant-email-senders-design.md +411 −307
diff --git a/docs/superpowers/specs/2026-09-15-tenant-email-senders-design.md b/docs/superpowers/specs/2026-09-15-tenant-email-senders-design.md
index d2a99bbb8..5fd37a42d 100644
--- a/docs/superpowers/specs/2026-09-15-tenant-email-senders-design.md
+++ b/docs/superpowers/specs/2026-09-15-tenant-email-senders-design.md
@@ -6,7 +6,8 @@ address under an SES identity CARLOS does not own) and **#148**
customer of a multi-tenant app).
Base: `origin/main` at 520fcc04. Author: Paul Campbell with Claude.
-Date: 2026-09-15.
+Date: 2026-09-15. Revised after two reviews: one against the code, one
+against the AWS documentation.
## 1. The problem
@@ -19,292 +20,354 @@ default config bundle. Two requests do not fit that shape.
both must send as `jelly@tito.io`. `tito.io` is a verified SES identity
that Tito Classic has owned for years in the same AWS account. The
existing walk would adopt it and then rewrite it: `EnsureIdentity`
-moves MAIL FROM to `mail-eu-west-1.tito.io`, `SetIdentityConfigSet`
-binds `carlos-<acct>` as the identity's default configuration set, the
-domain claim is first-come so the second account is refused, and
-`Teardown` calls `DeleteIdentity`.
+moves MAIL FROM to `mail-eu-west-1.tito.io` (`aws.go:292`),
+`SetIdentityConfigSet` binds `carlos-<acct>` as the identity's default
+configuration set (`walk.go:301`), the domain claim is first-come
+(`domainclaim.go:119`), and `Teardown` calls `DeleteIdentity`
+(`walk.go:1205`).
**#148.** titogo runs one instance per customer under a single app.
-Each customer needs their own domain, their own credential, their own
-cap and their own pause. Today `EnsureUser` writes one policy listing
-every domain granted to the app, and that one credential is delivered
-to every instance, so code in one customer's instance can send as every
-other customer's domain. That is the blocker.
+Each customer needs their own domain, credential, cap and pause. Today
+`EnsureUser` writes one policy listing every domain granted to the app
+(`walk.go:634`), delivered to every instance through the app's default
+bundle (`walk.go:818`), so code in one customer's instance can send as
+every other customer's domain.
## 2. Decisions taken before this document
1. **Extend the IAM-per-sender model now, and iterate towards a
platform SMTP relay.** The relay is wanted anyway, as a layer over
- SES that could also front a different SMTP provider. This design
- keeps that door open (§9) rather than building it.
-2. **Per-instance mode.** Once an app has instance senders, instances
- no longer receive the app's credential at all.
+ SES that could front a different SMTP provider. §10 keeps that door
+ open rather than building it.
+2. **Per-instance mode.** Once an app has instance senders, instances no
+ longer receive the app's credential at all.
3. **titogo's backend drives domain onboarding through the console
- API.** The CLI is a wrapper over the same doors.
+ API**, with the CLI as a wrapper over the same doors.
4. **For #147, an operator registers the external identity once and may
mark it open; accounts then claim addresses under it themselves.**
-
-## 3. The sender
-
-A **sender** is the unit that owns a From address, a credential, a cap,
-a pause and a set of counts.
+5. **Per-instance machine tokens are a prerequisite for #148** (§6.1).
+ Today `CARLOS_INSTANCE_TOKEN` is delivered through the app-wide
+ bundle, so every customer's instance holds a token that reaches every
+ other instance of the app. Opening instance email doors to
+ `svcrole.Instance` without scoping that token would move #148's leak
+ from the credential to the API.
+6. **Instance backing is mixed today, and moving to the isolating one is
+ the intent.** So `enable` checks the route's backing rather than
+ assuming it (§6.4).
+
+## 3. What the AWS documentation actually says
+
+The first draft of this design got three of these wrong, so they are
+recorded here with their sources.
+
+- **Condition keys.** The v1 action set, which is what SMTP authorises
+ against, has `ses:ApiVersion`, `ses:FeedbackAddress`,
+ `ses:FromAddress`, `ses:FromDisplayName` and `ses:Recipients`. SES v2
+ adds `ses:TenantName` and `ses:MultiRegionEndpointId`. **No condition
+ key names a configuration set.** The configuration set is a *resource*
+ on the send action, which constrains which set may be named but cannot
+ force one to be named — so a credential cannot be compelled to publish
+ events.
+- **`ses:TenantName` is documented for `SendEmail` and `SendBulkEmail`
+ only.** The `SendRawEmail` row, which is what the SMTP interface
+ authorises against, does not list it, although an AWS blog publishes a
+ policy that uses it there. This is the first spike (§4).
+- **There is no tenant-level default configuration set.** A send on
+ behalf of a tenant must either name a configuration set associated
+ with that tenant, or use an identity whose *default* set is associated
+ with it. So per-sender configuration sets on a shared identity are
+ impossible, and an address sender under a borrowed identity can only
+ publish events if the message names CARLOS's set.
+- **Associating a resource with a tenant does not modify it**, and one
+ identity may belong to many tenants. It has one side effect on the
+ owner: an identity cannot be deleted while an association exists.
+- **SES pauses tenants by itself.** Every new tenant gets the Standard
+ reputation policy, which auto-pauses on high-severity findings, and
+ Trust & Safety can pause one too. `SetKeyActive(true)` cannot lift
+ that. Sending quotas remain account-wide per region; the account
+ suppression list still applies unless a tenant opts into its own.
+- **`ses:caller-identity` names the IAM user** on Send, Bounce,
+ Complaint, Delivery and Reject, but only for events published through
+ a configuration set, and not on Rendering Failure or DeliveryDelay.
+
+## 4. Spike first
+
+Half a day against the Tito deployment's AWS account, on a throwaway
+identity and tenant. It is blocking, because two of its answers change
+the policy this design writes.
+
+1. **Send over SMTP with `X-SES-TENANT` set, under a policy conditioned
+ on `ses:TenantName`.** Then send again without the header. Three
+ outcomes: both succeed (the key is not populated for SMTP, so the
+ condition buys nothing); tagged succeeds and untagged is refused (the
+ design works as written); both are refused (SES does not populate the
+ key at all and the condition denies everything). In outcome one and
+ three the tenant condition comes out of the policy, and isolation
+ rests on `ses:FromAddress` plus SES's own tenant-association
+ validation.
+2. **Send under a tenant without `X-SES-CONFIGURATION-SET`** on an
+ identity whose default set is not associated with that tenant, to
+ confirm it is refused, and with the header to confirm events arrive.
+3. **Associate an identity created outside CARLOS** and confirm its MAIL
+ FROM, DKIM and default configuration set are unchanged, and that
+ sends on it *without* a tenant still work — Classic's mail must not
+ change behaviour the moment CARLOS associates `tito.io`.
+4. **Pause a tenant** with `UpdateReputationEntityCustomerManagedStatus`
+ and confirm `GetTenant.SendingStatus` reflects it, so §8 can adopt it
+ as the pause verb.
+
+Record the outcomes in this document before plans are written.
+
+## 5. The sender
+
+A **sender** owns a From address, a credential, a cap, a pause and a set
+of counts.
```go
// internal/mail/sender.go
type Sender struct {
Schema int `json:"schema"` // 1
- ID string `json:"id"` // "app", or the instance id
+ ID string `json:"id"` // "app", or provision.InstanceID(host)
+ Slug string `json:"slug"` // AWS-safe name component, see below
Scope string `json:"scope"` // ScopeApp | ScopeInstance
- Host string `json:"host,omitempty"` // instance host, scope=instance
+ Host string `json:"host,omitempty"`
Source string `json:"source"` // SourceDomain | SourceAddress
- Declared *Declared `json:"declared,omitempty"`
- Grant *GrantBlock `json:"grant,omitempty"`
- Provisioned *Provisioned `json:"provisioned,omitempty"`
+ Declared *Declared `json:"declared,omitempty"`
+ Grant *GrantBlock `json:"grant,omitempty"`
+ Provisioned *Provisioned `json:"provisioned,omitempty"`
Standalone []*Standalone `json:"standalone,omitempty"`
- Identity string `json:"identity,omitempty"` // external identity ARN, source=address
- Address string `json:"address,omitempty"` // exact address, source=address
+ Identity string `json:"identity,omitempty"` // external identity ARN
+ Address string `json:"address,omitempty"` // exact address, lowercased
+ TenantARN string `json:"tenant_arn,omitempty"` // from CreateTenant
+ PerInstance bool `json:"per_instance,omitempty"` // app sender only, §6.3
}
```
-- `ScopeApp` is what exists today; `ScopeInstance` is #148.
-- `SourceDomain` is a domain CARLOS verified and may write to;
- `SourceAddress` is one address under an identity CARLOS does not own
- (#147).
-- `Declared`, `GrantBlock`, `Provisioned` and `Standalone` keep their
- current shapes and move inside the sender unchanged.
+`Declared`, `GrantBlock`, `Provisioned` and `Standalone` keep their
+current shapes and move inside the sender unchanged.
**Records.** `apps/<acct>/<app>/email/senders/<id>.json`, one object per
sender, each with its own CAS etag so two instances never contend.
-`<id>` is `app` for the app sender and `provision.InstanceID(host)` for
-an instance sender (already bounded at 40 characters, which keeps the
-IAM user name inside its 64-character limit).
-
-**Migration.** `mail.Get(acct, app)` keeps working: it reads
-`email.json` when no `senders/app.json` exists and returns it as the app
-sender. The first write through a sender-aware path writes
-`senders/app.json` and leaves `email.json` in place as a tombstone for
-one release. Nothing has to be migrated before a roll, and a console
-rollback still finds `email.json`.
-
-**Per-instance mode.** An app is in per-instance mode when any
-`senders/<id>.json` with `Scope == ScopeInstance` exists. Entering it
-unsets the app sender's `CARLOS_SMTP_*` vars from the app's default
-bundle (§6) and refuses `carlos email enable` for the app sender until
-the last instance sender is gone. The app sender's own record and its
-domains survive, so leaving per-instance mode is a re-enable, not a
-re-claim.
-
-**Naming.** One IAM user, one SES tenant and one configuration set per
-sender:
-
-| | app sender | instance sender |
-|---|---|---|
-| IAM user | `carlos-mail-<acct>-<app>` (unchanged) | `carlos-mail-<acct>-<app>+i+<id>` |
-| tenant | `carlos-<acct>-<app>` | `carlos-<acct>-<app>-<id>` |
-| config set | `carlos-<acct>-<app>` | `carlos-<acct>-<app>-<id>` |
-
-The account-wide tenant and configuration set (`carlos-<acct>`) stay for
-existing app senders until they are re-enabled; `ConfigSetName` gains a
-sender argument and the old name remains the value for the app sender
-of an account that has not been re-enabled, so no live identity gets
-rebound by a deploy.
-
-## 4. SES tenants carry the isolation
-
-SES's IAM condition keys are only `ses:Recipients`, `ses:FromAddress`,
-`ses:FromDisplayName` and `ses:FeedbackAddress`. There is no condition
-key for a configuration set, so a credential cannot be forced to publish
-events — which is how a sender could evade its cap.
-
-SES tenants do have one, `ses:TenantName`, and `StringEquals` on an
-absent key fails, so a policy conditioned on the tenant refuses any
-message that does not name it. Tenants also give each sender its own
-reputation, and `CreateTenantResourceAssociation` associates an existing
-identity with a tenant **without modifying the identity**, which is
-exactly what #147 needs.
-
-So each sender's policy is:
-
-```
-Allow ses:SendRawEmail
- Resource <identity ARN(s)> + <sender config set ARN>
- Condition StringEquals ses:TenantName = <sender tenant>
- StringEquals ses:FromAddress = <address> (source=address)
- StringLike ses:FromAddress = ["*@<domain>", …] (source=domain)
-```
-
-`SendPolicyJSON` grows a sender argument and an exact-address form; its
-existing refusals (no identities, no domains, wildcards, malformed ARNs,
-an identity that is not a parent of a granted domain) all stay, with the
-address form checking that the address's domain is the identity or a
-child of it.
-
-**Spike first (blocking, half a day).** Against the Tito deployment's
-AWS account, confirm on a throwaway identity and tenant:
-
-1. `ses:TenantName` is enforced for SMTP sends, not only the v2 API — a
- send without `X-SES-TENANT` must be refused.
-2. A tenant's associated configuration set is applied to a message
- automatically, so events arrive without the app setting
- `X-SES-CONFIGURATION-SET`.
-3. `CreateTenantResourceAssociation` against an identity CARLOS did not
- create leaves that identity's MAIL FROM, default configuration set
- and DKIM untouched.
-4. Whether tenant-level sending can be paused directly, which would be a
- better pause than `SetKeyActive`.
-
-If (1) or (2) fails, the isolation still holds through `ses:FromAddress`
-and the per-sender IAM user, but counting an address sender depends on
-the app's own header. Record that outcome in this document before the
-plans are written; it changes §7's fallback, not the record shapes.
-
-## 5. #147: a sender under an identity CARLOS does not own
-**The external identity** is registered once per deployment by an
-operator, at `email/external-identities/<domain>.json`:
+**Names.** The first draft's names were invalid: SES caps tenant and
+configuration-set names at 64 characters of `[A-Za-z0-9_-]`, IAM caps a
+user name at 64, and an instance id is the host itself when it is 40
+characters or less, dots included — `carlos-mail-<acct>-<app>+i+<id>`
+reaches 104 characters. So each sender gets a `Slug`, computed once at
+creation and stored on the record:
+
+- app sender: `Slug = ""`, names unchanged
+ (`carlos-mail-<acct>-<app>`, tenant and set `carlos-<acct>`).
+- instance sender: `Slug = <first 8 of base32(sha256(acct/app/id))>`,
+ giving IAM user `carlos-mail-<acct>-<app>+i+<slug>` and tenant
+ `carlos-<acct>-<app>-<slug>`, both bounded at 12 + 16 + 1 + 32 + 3 + 8
+ = 72 — still over 64 for the longest account and app, so the app
+ component is itself truncated to 24 characters with the hash carrying
+ uniqueness. The exact rule lives in one function, `SenderNames`, with
+ a test that enumerates the bounds.
+
+Because the slug is a hash, nothing parses it back. `SenderOfCaller`
+(§8) resolves a caller identity by looking up the recorded `IAMUser`,
+not by decomposing the name.
+
+**Configuration sets stay per account** (`carlos-<acct>`), because an
+identity has only one default set and §3 rules out per-sender sets on a
+shared identity. Each sender's tenant is associated with that set, and
+the set's ARN stays in the sender's policy `Resource`.
+
+**Migration.** `mail.Get(acct, app)` prefers `senders/app.json` and
+falls back to `email.json`; `mail.Put` **always** writes
+`senders/app.json`, create-only on first write since the old etag does
+not carry over. `email.json` is left in place, unwritten, for one
+release and then deleted by a sweep. A console rolled back after the
+first sender write loses writes made in between — a pause or a rotation
+would have to be redone — which is recorded here and in the roll notes.
+
+## 6. #148: instance senders
+
+### 6.1 Prerequisite: per-instance machine tokens
+
+`handleAPIInstanceToken` mints a token bound to (account, app), and the
+documented delivery path is `carlos secrets set -app <app>`, i.e. the
+bundle every instance renders. Every customer's instance therefore holds
+a credential that reaches every other instance of the app.
+
+Before any instance email door opens, the platform mints a token bound
+to (account, app, instance) and delivers it through the per-instance
+config layer (§6.2) as `CARLOS_INSTANCE_TOKEN`, replacing the app-wide
+one for instances that have it. `Principal` gains an `Instance` field
+alongside `App`, populated from a new `cli_tokens.instance` column;
+`apiAccountApp` grows an instance check beside its existing app check,
+so a token bound to instance A gets the same byte-identical 404 on
+instance B that a stranger gets. Tokens without an instance binding keep
+today's reach, so nothing breaks while the fleet rolls.
+
+This is its own plan, and it lands before the email doors (§11).
+
+### 6.2 Per-instance config delivery
+
+A third bundle layer, resolved after the named environment:
-```go
-type ExternalIdentity struct {
- Schema int `json:"schema"` // 1
- Domain string `json:"domain"` // tito.io
- Region string `json:"region"`
- IdentityARN string `json:"identity_arn"`
- Open bool `json:"open"` // any account may claim an address
- RegisteredBy string `json:"registered_by"`
- At time.Time `json:"at"`
-}
+```
+apps/<acct>/<app>/config.json default
+apps/<acct>/<app>/environments/<name>.json environment
+apps/<acct>/<app>/instances-config/<host>.json instance ← new
```
-CARLOS never calls `CreateEmailIdentity`,
-`PutEmailIdentityMailFromAttributes`, `SetIdentityConfigSet` or
-`DeleteIdentity` for a registered external identity. The only call it
-makes is `IdentityStatus` (a `GetEmailIdentity` read) to confirm the
-identity is verified and sending-enabled at registration and at enable
-time. The walk asserts this: a new `Provisioner` wrapper refuses those
-four verbs for any domain that has an external-identity record, so a
-future edit cannot reach them by accident, and a test pins it.
-
-**Address claims.** `email/address-claims/<address>/<acct>.json` records
-that an account sends as an address. Unlike the domain claim it is
-**not** exclusive: `jam` and `stripe` may both hold `jelly@tito.io`, and
-the record names the account, the app, the sender id and who claimed it.
-Claiming requires `Open == true` on the external identity; otherwise an
-operator grant is needed (the same record, written by an operator).
-Releasing a sender deletes its own claim only.
-
-`ListAddressClaims` gives an operator the roster: who is sending as what
-under an identity they registered. `Teardown` of an address sender
-deletes the IAM user, the tenant association and the claim, and never
-touches the identity.
-
-**The trade-off you accepted.** With `Open == true`, any account in the
-deployment can claim any address under `tito.io`, including
-`billing@tito.io`. The mitigations available, in order of cost: leave
-`Open` false and grant per address; or add an optional `Local []string`
-allowlist of local parts on the external identity record. This design
-implements `Open` plus the operator-grant path, and leaves `Local` out
-until it is asked for.
-
-**Enable.** `carlos email enable --app jelly --from jelly@tito.io`
-resolves `tito.io` to an external identity, checks or takes the address
-claim, creates the sender's tenant, associates the identity and the
-configuration set with it, renders the address-form policy and mints the
-key. No DNS records are printed, because the identity's DNS belongs to
-whoever owns it.
-
-## 6. #148: instance senders and per-instance delivery
-
-**Onboarding a customer domain** (titogo's backend, with its existing
-app token):
+- Same `Manifest` shape, same sealed envelopes, same name and value
+ rules. `ExecDelivery` is **not** read from this layer, matching the
+ ruling at `appconfig.go:61-69`.
+- `LoadMerged` keeps its signature; `LoadMergedHost(ctx, s, acct, app,
+ env, host)` adds the third overlay, and `Sync`/`SyncExec` call it per
+ route after resolving the group base, so a pass costs one extra bucket
+ read per instance that has an overlay and none for instances that do
+ not.
+- Writing the layer bumps the app's generation object, as
+ `SetExecDeliveryAt` does, so the fast loop converges.
+- Guards that assume a two-layer merge and must be revisited:
+ `lostKeys`/`defaultMissing` (`sync.go:260`, `:388`), the mass-removal
+ guard (`syncexec.go:351`), `allShadowed`/`AwaitingRestart`
+ (`syncexec.go:712`, `:796`). `ConfigRouteFingerprints`
+ (`routefingerprint.go:58`) digests registry columns only and cannot
+ see a bucket-side change; the generation bump is that layer's signal.
+- Only the platform writes this layer: no CLI verb, no console door. It
+ exists so a credential can be scoped to one instance.
+
+### 6.3 Entering per-instance mode
+
+A prefix listing cannot decide this without racing an app-sender enable,
+so the app sender carries `PerInstance bool`, and the transition is one
+CAS write on `senders/app.json` that, in order:
+
+1. Sets `PerInstance`.
+2. `SetKeyActive(false)` on the app sender's key, its previous key and
+ every standalone credential — otherwise the credential already
+ delivered to every instance keeps authorising sends for every domain,
+ and addr-shaped routes are never bounced (`syncexec.go:440`), so the
+ old environment survives in running processes.
+3. Unsets the app sender's `CARLOS_SMTP_*` vars from the default bundle.
+
+App-sender `Enable` refuses when `PerInstance` is set, under that same
+etag. Instance `Enable` requires it. Leaving per-instance mode, once the
+last instance sender is gone, is a re-enable of the app sender.
+
+`grantedDomains` filters on the domain record's new `Senders` list, not
+`Apps`, and an instance sender's claim never adds the app to `Apps`. So
+the app sender's policy can never name a customer domain.
+
+### 6.4 The doors, and what they check
```
-POST /api/cli/apps/{account}/{app}/instances/{host}/email declare
-POST /api/cli/apps/{account}/{app}/instances/{host}/email/domains
-POST /api/cli/apps/{account}/{app}/instances/{host}/email/domains/{domain}/verify
-POST /api/cli/apps/{account}/{app}/instances/{host}/email/enable
-POST /api/cli/apps/{account}/{app}/instances/{host}/email/rotate
-GET /api/cli/apps/{account}/{app}/instances/{host}/email status
-DELETE /api/cli/apps/{account}/{app}/instances/{host}/email teardown
+POST /api/cli/apps/{account}/{app}/instances/{host}/email declare
+POST .../instances/{host}/email/domains attach
+POST .../instances/{host}/email/domains/{domain}/verify
+POST .../instances/{host}/email/enable
+POST .../instances/{host}/email/rotate
+GET .../instances/{host}/email status
+DELETE .../instances/{host}/email teardown
```
-Roles: every door above is `Allow(svcrole.Instance)`, so
-`CARLOS_INSTANCE_TOKEN` reaches them, and the widening is recorded in
-`exemptFromLegacyPin` alongside `TestInstanceRoleMatchesTodaysAppTokenReach`.
-Pause, resume and cap changes stay where they are — operator only,
-through the existing `POST …/email/pause` with an added `instance`
-field.
-
-This is a real widening: an app token can now claim a domain, mint a
-sending credential for it and deliver it into one of its own instances.
-It cannot raise a cap, lift a pause, reach another app, or send as any
-domain it has not verified, and the credential it mints lands in the
-config of an instance whose environment that app already controls.
+All are `Allow(svcrole.Instance)`, recorded in `exemptFromLegacyPin`
+beside `TestInstanceRoleMatchesTodaysAppTokenReach`, and reachable only
+by a token bound to that instance (§6.1). Pause, resume and cap changes
+stay operator-only through the existing `POST …/email/pause`, which
+gains an `instance` field.
+
+Two checks at `enable`, both refusals with an explaining message:
+
+- **Backing.** The instance's route must isolate its config file from
+ siblings: unit-backed (systemd reads the 0600 file as root and drops
+ to a per-unit `DynamicUser`), or exec-backed under an isolated-exec
+ layout with a distinct uid. Plain exec-backed routes share the agent's
+ uid, so `<dataDir>/<host>.config.env` is readable by every sibling and
+ a per-instance credential buys nothing (`admintoken.go:6-12`). This is
+ the dependency on #126's per-instance uids.
+- **Delivery.** The instance's box must ack the new config-ack schema
+ (§6.5), otherwise its agent cannot render the instance layer and the
+ credential would never arrive.
+
+**Domain attach.** `claimDomain` gains a `Sender` field and refuses a
+re-claim whose (app, sender) differs from the holder's, *including
+within the same account* — today a same-account re-claim is a silent
+no-op (`domainclaim.go:137`), which would let one instance attach
+another's verified domain. `EnsureDomain` refuses a domain that has an
+external-identity record before it claims anything, and the orphan-claim
+sweep in `Teardown` matches on sender and skips external domains, so a
+refused `EnsureIdentity` can never wedge an app's teardown.
+
+### 6.5 Confirming delivery
+
+`ConfigAck` gains `Schema int` and `InstanceConfigETag string`: the etag
+of the instance overlay the agent rendered. The console compares it with
+the etag its own `Put` returned, which is decidable — unlike a key-id
+map, which cannot distinguish an old agent from a new agent rendering a
+route with no mail vars. `Schema` is what the enable-time delivery check
+reads. `carlos email status --instance <host>` reports delivered, stale
+or never delivered.
+
+## 7. #147: a sender under an identity CARLOS does not own
-The domain walk is the existing one with the sender in place of the
-app: the claim records (account, app, sender), `DomainRecord` gains
-`Senders []string` (`app` or an instance id) beside `Apps`, and an
-identity is deleted only when both lists are empty. `Apps` stays
-populated for the app sender so a rollback still reads it.
-
-**Per-instance config delivery.** A third bundle layer, resolved after
-the named environment:
-
-```
-apps/<acct>/<app>/config.json default
-apps/<acct>/<app>/environments/<name>.json environment
-apps/<acct>/<app>/instances-config/<host>.json instance ← new
-```
+**The external identity** is registered once per deployment by an
+operator at `email/external-identities/<domain>.json`: domain, region,
+identity ARN, `Open bool`, who registered it and when. The region must
+match the deployment's, because tenants and identities are per region.
-- Same `Manifest` shape, same sealed-secret envelope, same
- `ValidVarName`/`ValidVarValue` rules. `ExecDelivery` is **not** read
- from this layer, matching the ruling in `appconfig.go:61-69`.
-- `LoadMerged` keeps its signature; a new `LoadMergedHost(ctx, s, acct,
- app, env, host)` adds the third overlay, and `Sync`/`SyncExec` call it
- per route after resolving the group base. The group caches stay, so a
- pass costs one extra bucket read per instance that has an overlay, and
- none for instances that do not.
-- Writing the layer bumps the app's generation object, exactly as
- `SetExecDeliveryAt` does, so the fast loop converges.
-- The guards that must be revisited, because they assume a two-layer
- merge: `lostKeys`/`defaultMissing` (`sync.go:260`, `:388`), the
- mass-removal guard (`syncexec.go:351`), `allShadowed`/`AwaitingRestart`
- (`syncexec.go:712`, `:796`), and `ConfigRouteFingerprints`
- (`routefingerprint.go:58`), which digests registry columns only and so
- cannot see a bucket-side overlay change — the generation bump is the
- signal for that layer.
-- Only the platform writes this layer in this design. No CLI verb, no
- console door: it exists so a credential can be scoped to one instance.
-
-**Delivery, confirmed.** `ConfigAck` has no generation field, so
-following the relay's proven pattern, the ack gains
-`MailKeyIDs map[string]string` (host → delivered `_USER` value) and the
-console compares it exactly against the sender's current `KeyID`, the
-way `relayMatchingAck` compares `RelaySuffixes`. `carlos email status
---instance <host>` then says delivered, stale or never delivered rather
-than guessing from `missing_vars`.
-
-**Rolling.** A box running an older agent ignores the instance layer, so
-its instances get no `CARLOS_SMTP_*` and the status reads as not
-delivered. Boxes must be rolled before instance senders are enabled on
-them; the console refuses `enable` for an instance whose box has not
-acked the new ack shape.
-
-## 7. Counting, caps and pauses per sender
-
-**Attribution by caller, not by domain.** Every sender has its own IAM
-user, and SES already puts the caller identity in
-`mail.tags["ses:caller-identity"]`, which `mail.AppOfCaller` already
-parses (it currently discards the `+s+` suffix). Extend it to
-`SenderOfCaller(caller) (acct, app, senderID string, ok bool)`, which
-resolves an address sender too — the case where the domain tells you
-nothing, because the domain is `tito.io` and belongs to Classic.
-
-`recordAndEnforceMail` then:
-
-1. Resolves the sender from the caller identity and counts there, once.
-2. Falls back to today's domain-wide loop only when the caller is
- unknown, and skips any app in per-instance mode, whose events must
- never be charged to the app sender.
+CARLOS never calls `CreateEmailIdentity`,
+`PutEmailIdentityMailFromAttributes` (both inside `EnsureIdentity`),
+`SetIdentityConfigSet` or `DeleteIdentity` for a registered identity. A
+provisioner wrapper refuses those verbs for any domain with such a
+record, and a test pins the refusal. `EnsureDomain` refuses the domain
+before claiming it (§6.4), so the refusal can never strand a claim.
+
+**Verification.** `Status.Verified()` requires `MailFrom == "SUCCESS"`
+(`provisioner.go:45`), which an identity with no custom MAIL FROM never
+reaches. External identities are checked with a relaxed predicate: DKIM
+and sending enabled, MAIL FROM ignored.
+
+**Address claims.** `email/address-claims/<address>/<acct>.json`, where
+the address is lowercased and validated to the same grammar `Declared`
+already enforces. The claim is **not** exclusive: `jam` and `stripe` may
+both hold `jelly@tito.io`. It names the account, app, sender and who
+claimed it, and `ListAddressClaims` gives the operator the roster.
+Claiming requires `Open == true`; otherwise an operator writes the same
+record as a grant. The policy condition is
+`StringEqualsIgnoreCase ses:FromAddress`, since SES's `StringEquals` is
+case-sensitive and a From address is not.
+
+**Counting an address sender depends on the app.** Because there is no
+tenant-level default set (§3) and CARLOS may not touch `tito.io`'s
+default, events only reach CARLOS when the message names CARLOS's
+configuration set. So the sender's env bundle gains
+`<P>_CONFIG_SET`, documented as a header the app must set, and
+`carlos email status` says plainly when a sender has sent nothing —
+the honour-system limitation you accepted, and one the relay removes.
+Enable also reads the external identity's default configuration set: if
+it has one that is not associated with the sender's tenant, a
+tenant-tagged send is refused outright, so enable refuses first, with
+that explanation.
+
+**The cross-team contract.** While CARLOS's tenant association exists,
+Classic cannot delete the `tito.io` identity — `DeleteEmailIdentity`
+fails until the association goes. The registration step says so, and
+teardown removes the association.
+
+**The trade-off you accepted.** With `Open`, any account in the
+deployment can claim any address under `tito.io`, including
+`billing@tito.io`. The cheap mitigation if it bites is an optional
+`Local []string` allowlist on the external-identity record; it is not
+built now.
+
+## 8. Counting, caps and pauses per sender
+
+**Attribution by caller.** Every sender has its own IAM user, and SES
+stamps `mail.tags["ses:caller-identity"]` with the user name on Send,
+Bounce, Complaint, Delivery and Reject. `SenderOfCaller(caller)`
+resolves it to (account, app, sender) through the recorded `IAMUser`,
+covering the address-sender case where the domain names Classic, not
+CARLOS. `recordAndEnforceMail` counts there once, and falls back to
+today's domain-wide loop only when the caller is unknown, skipping any
+app in per-instance mode so its events are never charged to the app
+sender.
**Counts.** A new table, because SQLite cannot widen a primary key:
@@ -318,63 +381,104 @@ CREATE TABLE IF NOT EXISTS mail_sender_counts (
```
Backfilled once from `mail_counts` with `sender = 'app'`; `mail_counts`
-is left in place, unwritten, and dropped a release later. The rename and
-purge sweeps (`mailAppTables`) gain the new table, which also closes the
-existing gap where `mail_counts` rows survive an app rename.
+stays, unwritten, and is dropped a release later. The rename and
+transfer sweeps key on a timestamp column (`mailAppTimeColumn`), which
+this table does not have, so it gets a special case comparing `day`
+rather than being added blindly to `mailAppTables` — which also closes
+today's gap, where `mail_counts` rows survive an app rename.
**Caps and pauses.** `TierCap` and the thresholds are unchanged and now
-apply per sender: `Grant.DailyCap` on the sender overrides the tier, an
-operator sets it per sender, and a pause calls `SetKeyActive(false)` on
-that sender's keys alone. `resumeStaleCapPause` keeps its rule — cap
-pauses lift on the UTC day roll, rate and manual pauses never do. One
-customer's bounce rate pauses that customer.
+apply per sender. A pause calls `SetKeyActive(false)` on that sender's
+keys alone, so one customer's bounce rate pauses that customer.
+`resumeStaleCapPause` keeps its rule: cap pauses lift on the UTC day
+roll, rate and manual pauses never do.
+
+**SES's own pauses.** A tenant carries the Standard reputation policy
+and SES can pause it independently, which `SetKeyActive` cannot lift.
+`EnsureTenant` stores `TenantArn`; status reads `GetTenant.SendingStatus`
+and reports an SES pause distinctly from a CARLOS pause; and if spike
+item 4 confirms it, `UpdateReputationEntityCustomerManagedStatus`
+becomes the pause verb for senders that have a tenant, with
+`SetKeyActive` as the fallback for those that do not.
**Status.** `carlos email status --app <app>` prints the app sender as
-today plus a line per instance sender (host, from, domains, cap, today's
-counts, pause state, delivery). `--instance <host>` prints one. The API
-reply gains a `senders` array; the existing top-level fields keep
-describing the app sender so an older CLI still works.
-
-## 8. Testing
-
-- `MemProvisioner` grows tenants-with-associations and refuses the four
- forbidden verbs for a registered external identity; a test pins that
- refusal.
-- Walk tests per sender kind: address sender enable/teardown never calls
- the forbidden verbs; instance sender enable writes only the instance
- layer; entering per-instance mode unsets the app sender's vars.
-- Policy tests: exact-address condition, tenant condition, and the
- existing refusals.
-- Attribution tests: an event whose caller names an instance sender
- counts only there; an event on a shared domain in per-instance mode
- never charges the app sender; an unknown caller keeps today's
- behaviour.
-- Limiter tests reuse `mailFixture`, parameterised by sender.
-- Config tests: three-layer merge precedence, an old agent ignoring the
- layer, the revisited `lostKeys` and mass-removal guards, and the ack
- carrying the delivered key id.
-- Door tests: the new routes in `svcroutes_test.go`, the `Instance` role
- pin exemption, and stranger-404 behaviour on an instance that is not
- the caller's.
-
-## 9. What this leaves for the relay
-
-The relay (#148's "we'd rather the platform own caps") becomes a third
-`Source` on the same sender: `SourceRelay`. The app still reads
-`<P>_HOST`, `<P>_PORT`, `<P>_USER`, `<P>_PASS` and `<P>_FROM` from its
-config, so switching a sender to the relay is a credential swap and a
-host change, invisible to the app's code. The relay enforces the cap
-before the send instead of after, which removes the "about 50"
-imprecision, and it is where a non-SES SMTP provider would attach.
-Nothing in this design blocks that: the cap, the pause and the counts
-already live on the sender, and the relay would authenticate a sender
-rather than an app.
-
-## 10. Out of scope
+today plus a line per instance sender; `--instance <host>` prints one.
+The API reply gains a `senders` array while its existing top-level
+fields keep describing the app sender, so an older CLI still works.
+
+## 9. Testing
+
+- `MemProvisioner` grows tenants with associations and reputation
+ status, and refuses the forbidden verbs for a registered external
+ identity; a test pins that refusal and one pins that `EnsureDomain`
+ refuses before claiming.
+- Walk tests per sender kind: address enable and teardown never call the
+ forbidden verbs; instance enable writes only the instance layer;
+ entering per-instance mode deactivates the app key and every
+ standalone, and unsets the vars in the same pass.
+- Race test: instance enable interleaved with app enable leaves no app
+ credential in the bundle.
+- Name tests enumerating the IAM and SES bounds, including the longest
+ legal account and app.
+- Policy tests: address condition (case-insensitive), tenant condition
+ present and absent, and the existing refusals.
+- Attribution tests: caller names an instance sender; per-instance mode
+ never charges the app sender; unknown caller keeps today's behaviour;
+ an event type with no caller tag.
+- Token tests: a token bound to instance A gets a 404 on instance B,
+ identical to a stranger's.
+- Config tests: three-layer precedence, an old agent ignoring the layer,
+ the revisited `lostKeys` and mass-removal guards, and the ack carrying
+ the overlay etag.
+- Door tests in `svcroutes_test.go`, the `Instance` role pin exemption,
+ and the backing and delivery refusals at enable.
+
+## 10. What this leaves for the relay
+
+The relay becomes a third `Source` on the same sender: `SourceRelay`.
+The app still reads `<P>_HOST`, `<P>_PORT`, `<P>_USER`, `<P>_PASS` and
+`<P>_FROM`, so moving a sender onto it is a credential swap and a host
+change, invisible to the app's code. It enforces the cap before the send
+rather than after, removes the `<P>_CONFIG_SET` honour system for
+address senders, spends one IAM user for the fleet rather than one per
+sender, and is where a non-SES SMTP provider attaches.
+
+## 11. Order of work
+
+1. **Spike** (§4). Blocking; changes the policy and the pause verb.
+2. **Senders** (§5): the record, the migration, the naming, per-sender
+ counts, caps, pauses and status. No new capability, and it lands
+ `mail_sender_counts` and attribution by caller.
+3. **#147** (§7): external identities, address claims, the provisioner
+ wrapper, `<P>_CONFIG_SET`. Unblocks Jelly without touching #148's
+ prerequisites.
+4. **Per-instance tokens** (§6.1).
+5. **Per-instance config layer and acks** (§6.2, §6.5).
+6. **#148** (§6.3, §6.4): instance senders, per-instance mode, the
+ doors, the backing check.
+
+## 12. Quotas and costs to watch
+
+- IAM users: 5,000 per AWS account, **not adjustable**. One per instance
+ sender plus one per standalone credential. At titogo's scale this is
+ the ceiling that eventually forces the relay.
+- IAM inline policy: 2,048 characters per user, all policies aggregated.
+ An app sender with many domains can reach it; `SendPolicyJSON` should
+ refuse with a clear error rather than let IAM reject it.
+- Configuration sets: 10,000 per region, not adjustable. Per account
+ here, so not a constraint.
+- SES tenants: 10,000 per region, adjustable. Associations per tenant
+ are undocumented — ask AWS before relying on many.
+- The SES control plane is throttled to one request per second for
+ everything except sends, so an enable that creates a tenant, two
+ associations and IAM resources needs backoff, and bulk onboarding is
+ serial.
+- SES charges per tenant per month on top of per-message pricing.
+
+## 13. Out of scope
- The relay itself.
-- Per-instance sending domains for the platform's own suffix
- (`<app>.<sqid>.<suffix>`); an instance sender needs a custom domain or
- a registered address.
-- Inbound email (#138), event push per sender, and per-sender billing.
-- `Local` allowlists on an external identity (§5).
+- Per-instance sending domains under the platform suffix; an instance
+ sender needs a custom domain or a registered address.
+- Inbound email (#138), event push per sender, per-sender billing.
+- `Local` allowlists on an external identity (§7).