Design: round three fixes
Attribution parses first and reads the index only for slugged names, so existing apps keep their by_sender rows on the roll; the index gets the domain-claim idempotence rule and a delete at revoke, teardown and reap; Get returns an empty etag on the email.json fallback so every Put is a create; step 3's doors and provisioner verbs are named; tenant associations are placed in step 2; RefreshPolicies walks senders; and the hub credential's full reach is written down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 file changed,
+148
−25
docs/superpowers/specs/2026-09-15-tenant-email-senders-design.md+148 −25
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 92d90f62f..547912255 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 |
| @@ -169,21 +169,42 @@ characters against IAM's 64 with nothing checking it. The bounds test |
| enumerates every form. |
| Because the slug is a hash, nothing parses it back, and a 40-bit slug |
| -needs collision detection. Both are served by a create-only reverse |
| -index at `email/callers/<iamuser>.json` → (account, app, sender), |
| -written **before** `MintKey`: its create-only Put is the collision check, |
| -and it is what `SenderOfCaller` (§8) reads, so attributing an event costs |
| -one Get rather than a listing. |
| +needs collision detection. Both are served by a reverse index at |
| +`email/callers/<iamuser>.json` → (account, app, sender), with the same |
| +rules the domain claim already uses (`domainclaim.go:124`): |
| + |
| +- Written **right after `EnsureUser`** (`walk.go:669`), which runs on |
| + every enable, so a re-enable converges it. Writing it before `MintKey` |
| + would strand a sender forever when a crash landed between the two. |
| +- The Put is create-only; on conflict it re-reads. The same (account, |
| + app, sender) is success — otherwise it is a slug collision, and the |
| + slug is recomputed with a salt that is stored on the record. |
| +- Deleted by `RevokeStandalone`, `Teardown` and the account reap. The |
| + key sits outside `apps/<acct>/<app>/`, so `release.PurgeApp`'s prefix |
| + sweep never reaches it; without an explicit delete, a stale entry |
| + would attribute SES events to a purged app and insert counts for it. |
| +- Only the slugged names need it. `SenderOfCaller` (§8) falls back to |
| + parsing for `carlos-mail-<acct>-<app>` and its `+s+` standalones. |
| **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`. |
| +`SenderNames` covers standalone credentials too, but it never changes a |
| +name that exists: `RefreshPolicies` recomputes `StandaloneUserName` |
| +rather than reading `sa.IAMUser` (`walk.go:364`), so a changed rule |
| +would write a policy for a new principal and leave the live one stale. |
| +The rule is: existing shapes are unchanged, and a name that would exceed |
| +the bound is refused at create. |
| + |
| **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 |
| +falls back to `email.json`. When it served the fallback it returns an |
| +empty etag, so every writer's `Put` — and they all pass back the etag |
| +they were given (`walk.go:522`, `:736`, `:909`, `:993`, `:1070`) — is a |
| +create by construction, and a lost race surfaces as `ErrCASConflict` |
| +exactly as it does today. `mail.Put` **always** writes |
| +`senders/app.json`. `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. |
| @@ -203,9 +224,37 @@ documented delivery path is `carlos secrets set -app <app>` |
| (`apiinstances.go:1367`), the bundle every instance renders, so anything |
| delivered that way reaches every customer. The hub's credential lives |
| wherever the hub runs — a different app, or an instance with its own |
| -overlay once §6.2 exists — and the operator check at enable time is |
| -simply that the app in question is not delivering an admin credential |
| -through its default bundle. |
| +overlay once §6.2 exists. The console cannot check this: bundle values |
| +are sealed and it holds no key (`apimail.go:483`), so where a credential |
| +was delivered is not a decidable question. It is an operator |
| +attestation, stated in the enable door's documentation, not a gate. |
| + |
| +**What an Admin-role service credential reaches.** `svcrole.Allow` adds |
| +Admin to every non-empty set (`svcrole.go:120`), so a titogo-bound Admin |
| +credential reaches every app-scoped door that names any role: ship, |
| +promote and rollback, pipeline, ledgers, instances create/patch/delete/ |
| +move, domains, logs and analytics, features, place, the store's |
| +create/provision/grant/rotate, relay, and every email door except |
| +credentials and pause. It cannot reach the person-only doors |
| +(`svcrole.Set{}`): instance-token minting, the instances manifest, |
| +channel, routes, service minting, fleets, store apply and approve, and |
| +system. A compromised hub is therefore a compromised titogo deploy, not |
| +only a compromised mailer. |
| + |
| +That inverts why those doors are Admin in the first place |
| +(`apimail.go:460`): Admin was chosen to be *narrower* than Publish or |
| +Instance, so that a deploy credential could not acquire a sending |
| +identity. For a hub whose only job is onboarding, it means the opposite |
| +— it must be able to deploy in order to onboard. The alternative is one |
| +new entry in the closed role catalogue, `svcrole.Mail`, with the email |
| +doors declared `Allow(svcrole.Mail)`; that is a small change and it is |
| +the one this design recommends if the reach above is unacceptable. |
| + |
| +**Rate budget.** `mailWriteGates` uses the person-sized write limiter |
| +keyed on the audit string (`apimail.go:136`), not `allowWrite` for |
| +service principals (`svcprincipal.go:394`). Bulk onboarding through the |
| +hub will 429 against a person's budget, so the mail write gate takes the |
| +service budget when the principal is a service. |
| Two consequences worth stating plainly. If customer code ever needs to |
| call these doors for itself, tokens bound to (account, app, instance) |
| @@ -281,6 +330,18 @@ and mints a dead key. |
| `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. |
| +Everything that walks `rec.Apps` must walk `Senders` instead, or an |
| +instance sender's policy is never refreshed when its configuration set |
| +is rebound — which is 554 on every send, the failure #271 and #279 |
| +already cost us. That means `RefreshPolicies` (`walk.go:324`, called |
| +from `EnsureDomain:297` and `VerifyDomain:461`) and `mailAppDomains` |
| +(`apimail.go:299`), which the enable door and status both read. |
| + |
| +There is also a door for the transition itself: the first instance |
| +`enable` performs it, and `DELETE .../instances/{host}/email` on the |
| +last instance sender leaves per-instance mode. Nothing else sets the |
| +flag. |
| + |
| ### 6.4 The doors, and what they check |
| ``` |
| @@ -394,6 +455,53 @@ 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. |
| +**Doors.** Registration is operator-only, the claim is a member's, and |
| +both are new routes: |
| + |
| +``` |
| +PUT /api/cli/email/external-identities/{domain} register (operator) |
| +DELETE /api/cli/email/external-identities/{domain} deregister (operator) |
| +GET /api/cli/email/external-identities list (operator) |
| +GET /api/cli/email/external-identities/{domain}/claims roster (operator) |
| +POST /api/cli/apps/{account}/{app}/email/address claim + declare (Admin) |
| +``` |
| + |
| +The claim door takes `{address}` and is what sets |
| +`Sender.Source = SourceAddress`, `Sender.Address` and |
| +`Sender.Identity`; it is `Declare` for an address sender, so declaring a |
| +From address and claiming it are one call and cannot disagree. Enable, |
| +rotate, status and teardown are the existing app-sender doors, which |
| +read `Source` off the record. CLI: `carlos email external-identity |
| +register|list|claims`, and `carlos email enable --from jelly@tito.io` |
| +calls the claim door first when the domain resolves to an external |
| +identity. |
| + |
| +**Refusal scope.** `EnsureDomain` refuses a domain that is a registered |
| +external identity **or any subdomain of one**: a claim on |
| +`mail.tito.io` would otherwise let CARLOS create and later delete an |
| +identity underneath one it does not own. |
| + |
| +**Provisioner verbs.** The interface (`provisioner.go:49`) gains four, |
| +each idempotent in the usual way, with `MemProvisioner` equivalents: |
| + |
| +```go |
| +AssociateTenantResource(ctx, tenant, resourceARN string) error |
| +DisassociateTenantResource(ctx, tenant, resourceARN string) error |
| +TenantStatus(ctx, tenant string) (TenantStatus, error) // GetTenant |
| +SetTenantSending(ctx, tenantARN string, enabled bool) error // §8's pause |
| +``` |
| + |
| +plus `IdentityStatus` returning the identity's bound configuration set, |
| +which it already carries (`Status.ConfigSet`), so enable can refuse an |
| +external identity whose default set is not associated with the tenant. |
| + |
| +**Associations are wired in step 2, not here.** Today `EnsureTenant` |
| +creates the container and nothing associates anything, and `aws.go:171` |
| +warns that no send may carry `X-SES-TENANT` until associations exist. |
| +Step 2 associates each account's existing tenant with its identities and |
| +its configuration set, which changes no behaviour on its own, so that |
| +step 3 can rely on them. |
| + |
| **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 |
| @@ -409,15 +517,27 @@ built now. |
| **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)` reads |
| -the reverse index `email/callers/<iamuser>.json` (§5) — one Get per |
| -event, against today's walk of every account's domain records — and |
| -resolves it to (account, app, sender), 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. |
| +Bounce, Complaint, Delivery and Reject. `SenderOfCaller(caller)` |
| +resolves it to (account, app, sender) in two steps: parse first, exactly |
| +as `AppOfCaller` does today (`policy.go:42`), which answers for |
| +`carlos-mail-<acct>-<app>` and its `+s+` standalones with sender |
| +`"app"` and costs nothing; then, only for a slugged `+i+` name, one Get |
| +on `email/callers/<iamuser>.json`. That ordering matters on the roll: |
| +every app enabled today short-circuits `Enable` before `MintKey` |
| +(`walk.go:677`), so it has no index entry until it next rotates, and a |
| +Get-only resolver would silently stop writing the `by_sender` log rows |
| +that `sesevents.go:498` writes now. |
| + |
| +`recordAndEnforceMail` counts against the resolved sender 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. |
| + |
| +**This is a visible change, and it belongs in the roll note.** Today |
| +every app on a shared domain is charged for every event on that domain |
| +(`sesevents.go:509`); afterwards only the sending app is. A sibling's |
| +cap stops being consumed by another app's sends, which is the intent, |
| +but a tenant watching its own numbers will see them move. |
| **Counts.** A new table, because SQLite cannot widen a primary key: |
| @@ -431,11 +551,14 @@ CREATE TABLE IF NOT EXISTS mail_sender_counts ( |
| ``` |
| Backfilled once from `mail_counts` with `sender = 'app'`; `mail_counts` |
| -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. |
| +stays, unwritten, and is dropped a release later by a sweep that runs |
| +once at console start. The rename, transfer, purge and account-reap |
| +sweeps (`carryMailApp`, `forgetMailApp`, `forgetMailAccount`) all take |
| +the new table, but it cannot join `mailAppTables` blindly because those |
| +sweeps key on a timestamp column (`mailAppTimeColumn`, `mailevents.go:170`) |
| +and this table has a `day` string instead — so it gets an explicit case |
| +comparing `day` against the date. That also closes today's gap, where |
| +`mail_counts` rows survive a rename. |
| **Caps and pauses.** `TierCap` and the thresholds are unchanged and now |
| apply per sender. A pause calls `SetKeyActive(false)` on that sender's |