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>
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). |