carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 6e922aa77992cc79c44098ea10f5b3352fa57f00 parent 276d0f2
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