carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 5eb4c6ea0296faf30b2201d4e38844cc037d1cfc parent 7a24189
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