carlos / platform Public

mail: prefix SES tenant names to avoid colliding with keymail's namespace

The live AWS account this estate deploys into (271376211898, eu-west-1)
already carries SES tenants from keymail — dermdaly, paul, till — a
different product sharing the account. walk.go's EnsureTenant call was
passing the bare CARLOS account sqid, which can collide with a keymail
tenant name outright (sqids are ^[a-z]{3,16}$) and would in any case
scatter CARLOS tenants through another product's namespace.

Add TenantName(acct) = "carlos-" + acct, alongside UserName and
StandaloneUserName in internal/mail/policy.go, and use it at the
EnsureTenant call site. Corrects spec §3, which said the tenant is
"named by sqid" — the prefix is permanent once any tenant exists,
since renaming an SES tenant means destroying and recreating it along
with its resource associations.

Landed before any tenant was created, for exactly that reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev ab716047ab33b1703703c6e506b3c0a629107c72 parent 3fe64b7
5 files changed, +66 −7
  • docs/superpowers/specs/2026-08-19-tenant-email-design.md +19 −4
  • internal/mail/policy.go +13 −0
  • internal/mail/policy_test.go +6 −0
  • internal/mail/walk.go +5 −2
  • internal/mail/walk_domain_test.go +23 −1

No CI script — run details

diff --git a/docs/superpowers/specs/2026-08-19-tenant-email-design.md b/docs/superpowers/specs/2026-08-19-tenant-email-design.md
index 0b5668996..575b126fc 100644
--- a/docs/superpowers/specs/2026-08-19-tenant-email-design.md
+++ b/docs/superpowers/specs/2026-08-19-tenant-email-design.md
@@ -74,10 +74,25 @@ prevents either; the record shapes are account-agnostic.
Three levels, three owners, three lifetimes.
-**SES tenant ↔ CARLOS account.** One tenant per account, named by sqid,
-created on first use. This is what makes one account's reputation stop at
-its own boundary and what makes a per-account pause meaningful to SES
-itself rather than only to us.
+**SES tenant ↔ CARLOS account.** One tenant per account, named
+`carlos-<sqid>` (`TenantName`, `internal/mail/policy.go`), created on first
+use. This is what makes one account's reputation stop at its own boundary
+and what makes a per-account pause meaningful to SES itself rather than
+only to us.
+
+**CORRECTED 2026-08-20:** this section originally said the tenant is
+"named by sqid" — a bare account sqid, with no prefix. That is wrong: the
+live AWS account this estate deploys into (`271376211898`, eu-west-1)
+already carries SES tenants from keymail, a different product sharing the
+account — `dermdaly`, `paul`, `till` — and the SES tenant namespace has no
+per-product scoping of its own. A CARLOS account sqid (`^[a-z]{3,16}$`, all
+lowercase letters) can collide with one of those directly, and even absent
+a collision a bare sqid scatters CARLOS tenants through another product's
+namespace with nothing to tell them apart. The prefix closes both problems
+and, once any tenant exists, is permanent: SES has no rename, so correcting
+a live tenant's name means destroying it and recreating it along with every
+resource association it carries — which is why this had to be fixed before
+any tenant was ever created.
The tenant layer is **additive and seam-shaped**, not load-bearing.
diff --git a/internal/mail/policy.go b/internal/mail/policy.go
index d05cec36a..020ff03c1 100644
--- a/internal/mail/policy.go
+++ b/internal/mail/policy.go
@@ -24,6 +24,19 @@ const UserPath = "/carlos-mail/"
// UserName is the principal that owns an app's DELIVERED credentials.
func UserName(acct, app string) string { return "carlos-mail-" + acct + "-" + app }
+// TenantName is the SES tenant CARLOS creates for a CARLOS account.
+//
+// It is prefixed, never the bare sqid, because the SES tenant namespace is
+// shared with whatever else lives in the same AWS account — the live estate
+// this package ships into already holds keymail's `dermdaly`, `paul` and
+// `till` tenants, and a CARLOS account sqid (`^[a-z]{3,16}$`, all lowercase
+// letters) can collide with one of those outright, or simply land among
+// them as if it belonged to that other product. The prefix must not change
+// once any tenant exists: renaming an SES tenant means destroying it and
+// recreating it, along with every resource association it carries, not
+// editing a name in place.
+func TenantName(acct string) string { return "carlos-" + acct }
+
// StandaloneUserName is the principal for one displayed credential.
//
// It is a SEPARATE user, and that is forced by IAM's ceiling of two access
diff --git a/internal/mail/policy_test.go b/internal/mail/policy_test.go
index bc8fec51d..a6ed193d0 100644
--- a/internal/mail/policy_test.go
+++ b/internal/mail/policy_test.go
@@ -178,6 +178,12 @@ func TestPolicyAcceptsLegitimateARN(t *testing.T) {
}
}
+func TestTenantNameIsPrefixed(t *testing.T) {
+ if got, want := TenantName("bag"), "carlos-bag"; got != want {
+ t.Fatalf("TenantName = %q, want %q", got, want)
+ }
+}
+
func TestUserNamesAreStableAndDistinct(t *testing.T) {
if got, want := UserName("bag", "seapointish"), "carlos-mail-bag-seapointish"; got != want {
t.Fatalf("UserName = %q, want %q", got, want)
diff --git a/internal/mail/walk.go b/internal/mail/walk.go
index f7e7c5aca..5df300775 100644
--- a/internal/mail/walk.go
+++ b/internal/mail/walk.go
@@ -176,8 +176,11 @@ func EnsureDomain(ctx context.Context, s release.Store, p Provisioner, w dnsrec.
// the claim.
return nil, nil, fmt.Errorf("mail: claim domain %s: %w", domain, err)
}
- if err := p.EnsureTenant(ctx, acct); err != nil {
- return nil, nil, fmt.Errorf("mail: ensure tenant %s: %w", acct, err)
+ // TenantName, not the bare acct: the SES tenant namespace is shared with
+ // other products in this AWS account (see its doc comment in policy.go).
+ tenant := TenantName(acct)
+ if err := p.EnsureTenant(ctx, tenant); err != nil {
+ return nil, nil, fmt.Errorf("mail: ensure tenant %s: %w", tenant, err)
}
rec, etag, err := GetDomain(ctx, s, acct, domain)
diff --git a/internal/mail/walk_domain_test.go b/internal/mail/walk_domain_test.go
index 50a9edf08..6da76969f 100644
--- a/internal/mail/walk_domain_test.go
+++ b/internal/mail/walk_domain_test.go
@@ -280,11 +280,33 @@ func TestEnsureDomainRefusesInvalidAccount(t *testing.T) {
if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), "../evil", "seapointish", "seapointish.bag.oncarlos.com", now); err == nil {
t.Fatal("EnsureDomain accepted an invalid account")
}
- if p.Tenants["../evil"] {
+ if p.Tenants["../evil"] || p.Tenants[TenantName("../evil")] {
t.Fatal("an invalid account reached EnsureTenant before validation")
}
}
+// EnsureDomain must create the tenant under its PREFIXED name, never the
+// bare account sqid — the SES tenant namespace is shared with other
+// products in the same AWS account (TenantName's doc comment in policy.go
+// has the live collision this guards against), so a bare sqid landing in
+// p.Tenants would be the exact defect this closes.
+func TestEnsureDomainCreatesPrefixedTenant(t *testing.T) {
+ ctx := context.Background()
+ s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter()
+ now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
+
+ if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(),
+ "bag", "seapointish", "seapointish.bag.oncarlos.com", now); err != nil {
+ t.Fatalf("EnsureDomain: %v", err)
+ }
+ if !p.Tenants["carlos-bag"] {
+ t.Fatal("EnsureDomain did not create the prefixed tenant carlos-bag")
+ }
+ if p.Tenants["bag"] {
+ t.Fatal("EnsureDomain created a bare-sqid tenant — this collides with another product's namespace")
+ }
+}
+
func TestEnsureDomainRefusesInvalidApp(t *testing.T) {
ctx := context.Background()
s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter()