carlos / platform Public

Design: tenant email senders (#147, #148)

A sender owns a From address, a credential, a cap, a pause and its own
counts. Scope is app or instance; source is a CARLOS-verified domain or
an address under an identity CARLOS does not own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 6642673de034560cf0fb939849957fd265eb57ad parent 91cf688
1 file changed, +380 −0
  • docs/superpowers/specs/2026-09-15-tenant-email-senders-design.md +380 −0
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
new file mode 100644
index 000000000..d2a99bbb8
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-15-tenant-email-senders-design.md
@@ -0,0 +1,380 @@
+# Tenant email: senders
+
+Design for amadan `carlos/platform` discussions **#147** (send as an
+address under an SES identity CARLOS does not own) and **#148**
+(tenant email per instance: domain, credential, cap and pause for each
+customer of a multi-tenant app).
+
+Base: `origin/main` at 520fcc04. Author: Paul Campbell with Claude.
+Date: 2026-09-15.
+
+## 1. The problem
+
+Tenant email today is keyed by (account, app) everywhere: `email.json`,
+the IAM user `carlos-mail-<acct>-<app>`, the `DailyCap`, the pause, the
+`mail_counts` rows, and the delivery of `CARLOS_SMTP_*` into the app's
+default config bundle. Two requests do not fit that shape.
+
+**#147.** Jelly runs as an app in two accounts (`jam` and `stripe`) and
+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`.
+
+**#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.
+
+## 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.
+3. **titogo's backend drives domain onboarding through the console
+ API.** The CLI is 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.
+
+```go
+// internal/mail/sender.go
+type Sender struct {
+ Schema int `json:"schema"` // 1
+ ID string `json:"id"` // "app", or the instance id
+ Scope string `json:"scope"` // ScopeApp | ScopeInstance
+ Host string `json:"host,omitempty"` // instance host, scope=instance
+ Source string `json:"source"` // SourceDomain | SourceAddress
+ 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
+}
+```
+
+- `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.
+
+**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`:
+
+```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"`
+}
+```
+
+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):
+
+```
+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
+```
+
+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.
+
+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
+```
+
+- 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.
+
+**Counts.** A new table, because SQLite cannot widen a primary key:
+
+```sql
+CREATE TABLE IF NOT EXISTS mail_sender_counts (
+ account TEXT NOT NULL, app TEXT NOT NULL, sender TEXT NOT NULL,
+ day TEXT NOT NULL, sends INTEGER NOT NULL DEFAULT 0,
+ bounces INTEGER NOT NULL DEFAULT 0, complaints INTEGER NOT NULL DEFAULT 0,
+ rejects INTEGER NOT NULL DEFAULT 0, deliveries INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (account, app, sender, day));
+```
+
+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.
+
+**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.
+
+**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
+
+- 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).