Design: a hub onboards, so instance email doors stay Admin
Customer instances never call these doors, so per-instance machine tokens drop out of this work and the Instance role pin is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 file changed,
+41
−37
docs/superpowers/specs/2026-09-15-tenant-email-senders-design.md+41 −37
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 2bec1dd5a..92d90f62f 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 |
| @@ -45,12 +45,9 @@ every other customer's domain. |
| 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.** |
| -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. |
| +5. **A central hub does the onboarding, holding one credential** (§6.1). |
| + Customer instances never call these doors, so the doors keep today's |
| + `Allow(svcrole.Admin)` and no per-instance machine token is needed. |
| 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). |
| @@ -193,24 +190,32 @@ 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.1 The caller is a hub, not the customer instances |
| + |
| +titogo's own backend onboards every customer, so the instance email |
| +doors are `Allow(svcrole.Admin)` — the same role the app-level email |
| +doors carry today — and the hub holds one named service credential with |
| +that role, bound to the titogo app. |
| + |
| +That credential must not be delivered into customer instances. Today |
| +`handleAPIInstanceToken` mints a token bound to (account, app) and its |
| +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. |
| + |
| +Two consequences worth stating plainly. If customer code ever needs to |
| +call these doors for itself, tokens bound to (account, app, instance) |
| +become a prerequisite again, and this design is shaped so that is a |
| +later change to the door's role set and nothing else. And separately, |
| +the app-wide `CARLOS_INSTANCE_TOKEN` that customer instances already |
| +hold reaches the existing instance create, patch and delete doors |
| +(`authcli.go:612`, `:628`), so one customer's code can already delete a |
| +sibling instance. That is not introduced here and not fixed here; it is |
| +worth its own discussion. |
| ### 6.2 Per-instance config delivery |
| @@ -288,11 +293,10 @@ GET .../instances/{host}/email status |
| DELETE .../instances/{host}/email teardown |
| ``` |
| -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. |
| +All are `Allow(svcrole.Admin)`, like the app-level email doors, so the |
| +`Instance` role pin is untouched and no widening is recorded. 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: |
| @@ -474,8 +478,9 @@ fields keep describing the app sender, so an older CLI still works. |
| - 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. |
| +- Door tests: an app-scoped `Instance`-role token is refused at every |
| + instance email door, and a stranger's 404 is byte-identical to the |
| + refusal for an instance that exists in another account. |
| - 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. |
| @@ -499,12 +504,11 @@ sender, and is where a non-SES SMTP provider attaches. |
| 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. |
| + wrapper, the address branch through enable and the policy renderer, |
| + `<P>_CONFIG_SET`. Unblocks Jelly and depends only on step 2. |
| +4. **Per-instance config layer and acks** (§6.2, §6.5). |
| +5. **#148** (§6.3, §6.4): instance senders, per-instance mode, the |
| + doors, the uid check. |
| ## 12. Quotas and costs to watch |