carlos / platform Public

spec: rebuild the inbound design on what the SES API actually offers

Two adversarial review rounds — one on SES factual correctness, one on
tenancy and security — returned eleven blocker-class findings. Three of
them rewrote whole sections rather than annotating them.

PutReceiptRuleSet does not exist. Revision 1 was built on a wholesale
rule-set swap; the API has no such call, receipt rules are absent from
SESv2 entirely (a new service/ses dependency), and building a set is N+1
calls against a 1 req/s throttle and a 40-set quota.

An exact-address recipient condition does not match a +tag variant. That
killed one-rule-per-address, which turned the rule set into a single
constant domain rule, which moved tenancy resolution entirely into the
deliverer — and dissolved the shared-mutable-global problem revision 1
spent its longest section defending against. Enabling an app now makes no
SES call at all: it writes a claim.

The delivery POST had no authentication. Revision 1 argued the SQS poll
made it unspoofable; the poll makes the WAKE unspoofable, and the
schedule runner authenticates its ticks to the app separately with a
bearer. It cited ServeScheduled while dropping the half that does this
job — so an outsider could POST forged verdicts and a forged In-Reply-To
onto a real thread, with an attacker-chosen raw_url as SSRF.

Also folded in: the claims are the routing authority, never the record's
Addresses (the hole domainclaim.go already paid for once); released
address claims are tombstoned, because a dead address keeps receiving
forwarded mail for months; reserved names are real claim objects rather
than an upstream check; the cap trips into a floor interval rather than a
hard stop, which was a cheap repeatable shutdown of a named app; the
backlog lives in the bucket, since SQS retention cannot hold a pause;
and the pause door needs mailPauseTarget's shape, not apiAccountApp's,
or the abuse brake deadlocks exactly when it is needed.

Verified against the AWS API operations page rather than remembered.
Docs-only: go build and internal/docsite green, gofmt clean.

Draft for review — not approved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKRd4dVyn1Da27uGBj9Skh
Paul Campbell pushed by paul@keymail.dev e320f46047f822f6a3c517d1062de6d4538151e4 parent 231a997
1 file changed, +693 −331
  • docs/superpowers/specs/2026-09-04-inbound-email-design.md +693 −331

CI passed — run details

diff --git a/docs/superpowers/specs/2026-09-04-inbound-email-design.md b/docs/superpowers/specs/2026-09-04-inbound-email-design.md
index c73761526..a238cb067 100644
--- a/docs/superpowers/specs/2026-09-04-inbound-email-design.md
+++ b/docs/superpowers/specs/2026-09-04-inbound-email-design.md
@@ -1,70 +1,133 @@
# Inbound email — SES receiving as a first-class platform layer
-**Date:** 2026-09-04 · **Status:** draft (design conversation 2026-09-04) ·
-**Requested by:** Paul, for James's shared-inbox app (a CARLOS
-implementation of letsjelly.com)
+**Date:** 2026-09-04 · **Status:** draft, revision 2 (design conversation
+2026-09-04; two adversarial review rounds folded in) · **Requested by:**
+Paul, for James's shared-inbox app (a CARLOS implementation of
+letsjelly.com)
**Builds on:** 2026-08-19-tenant-email-design.md (`internal/mail` — the
-sending layer this one is the mirror of, and whose domain record stage 2
-extends), #151 / 2026-08-12-domain-attach-design.md (the global claim,
-first-writer-wins), 2026-08-27-managed-custom-domains-design.md (managed
-customer-domain connections — where stage 2 lives, not here),
+sending layer, whose record shapes and claim discipline this one follows and
+whose SES estate it does *not* share), #151 /
+2026-08-12-domain-attach-design.md (the global claim, first-writer-wins),
+2026-08-27-managed-custom-domains-design.md (managed customer-domain
+connections — where stage 2 lives, not here),
2026-08-30-scheduled-ticks-and-hibernation-design.md / #371
-(`ServeScheduled`, the non-stamping wake this spec's delivery path reuses).
+(`ServeScheduled`, and the runner's bearer token, both of which this spec's
+delivery path reuses).
---
-## 0. Scope rulings taken in conversation (Paul, 2026-09-04)
-
-Four rulings shaped this, and each one removed a subsystem:
+## 0. How this spec was arrived at
+
+Four scope rulings (Paul, 2026-09-04), then two adversarial review rounds —
+one on SES factual correctness and design soundness, one on tenancy and
+security. **Eleven blocker-class findings**, and they changed the design
+rather than annotating it. Where the wrong answer is the one a reader would
+otherwise reach again, the finding is kept inline as rationale rather than
+stripped — the `internal/mail/aws.go` house style.
+
+Three findings rewrote whole sections, and a reader who skips them will
+re-derive the original mistakes:
+
+- **`PutReceiptRuleSet` does not exist.** Revision 1 was built on a
+ wholesale rule-set swap. The SES API has no such call, receipt rules are
+ not in SESv2 at all, and the operation is N+1 throttled calls. §3
+ is rebuilt around what the API actually offers — and the result is
+ *simpler*, because it forced the realisation in the next bullet.
+- **An exact-address recipient condition does not match a `+tag` variant.**
+ Which killed one-rule-per-address, which turned the rule set into a single
+ constant domain rule, which moved tenancy resolution entirely into the
+ deliverer and dissolved the shared-mutable-global problem revision 1 spent
+ its longest section defending against. §3, §4.
+- **The delivery POST had no authentication.** Revision 1 argued the SQS
+ poll made delivery unspoofable. It makes the *wake* unspoofable; the
+ schedule runner authenticates its ticks to the app with a bearer token,
+ and revision 1 cited `ServeScheduled` while dropping the half that does
+ this job. §5.
+
+### 0a. Scope rulings
1. **Stage 1 is forwarding-only.** Customers forward to a platform address;
- nobody points an MX at CARLOS yet. Direct MX is stage 2, sketched in §10
- and not specced here.
+ nobody points an MX at CARLOS yet. Direct MX is stage 2, sketched in §11.
2. **Delivery is a pointer plus a wake**, not an inline POST of the message
and not a mailbox the app polls. §5.
3. **Outbound is out of scope entirely.** Customer sending will be some
combination of the titogo tenancy model, custom SMTP, and the Gmail API.
- Nothing in this layer sends. §9 records what that buys.
+ Nothing in this layer sends — with one bounded exception, `carlos inbound
+ test` (§13).
4. **Therefore the domain record needs no sub-tenant dimension** — at either
stage. Stage 2 needs a *receiving* grant per customer domain, and that is
a capability on the `internal/domains` connection, not a new axis on
- `internal/mail`'s `DomainRecord`. §10.
+ `internal/mail`'s `DomainRecord`. §11.
-The driving use case matters to the reader: a shared inbox. It is
-human-attended during a working day, it needs threading (`Message-ID`,
-`In-Reply-To`, `References`) and attachments, and it wants messages in
-seconds rather than on a poll. That is what rules out both alternatives in
-§5, and it is why §7's verdict discussion lands where it does.
+The driving use case matters to the reader: a shared inbox. Human-attended
+during a working day, needs threading (`Message-ID`, `In-Reply-To`,
+`References`) and attachments, wants messages in seconds rather than on a
+poll. That rules out both alternatives in §5, and it is why §8's verdict
+discussion lands where it does.
---
## 1. What this layer is
An app declares that it *receives* email. The platform mints it addresses on
-a receiving domain the platform owns, arranges for SES to accept mail for
-those addresses, stores each raw message in a per-tenant prefix of a
+a receiving domain the platform owns, stores each raw message in a
deployment-owned bucket, and hands the app a pointer plus the envelope and
SES's scanning verdicts — waking a hibernating instance to do so.
-It is the mirror of `internal/mail` and follows it verb for verb, because it
-is the same problem with the arrows reversed: a declaration block, a grant
-block, a provisioned block, idempotent walks, a console door family, a
-`carlos` verb, a daily cap and a pause. Where it *diverges* from
-`internal/mail` is the subject of §3, and every divergence is forced.
+New package **`internal/inbound`**, following `internal/mail`'s record
+shapes verb for verb. It does **not** share `internal/mail`'s SES estate:
+receipt rules live in the SES **v1** API (`service/ses`), which
+`internal/mail` does not import at all — it uses only `service/sesv2` — so
+this is a second AWS client on a new `go.mod` dependency, behind its own
+`Provisioner` seam.
+
+### 1a. The shape, in one paragraph
-New package: **`internal/inbound`**. Not a second half of `internal/mail`.
-The two share nothing at stage 1 (§10 is where they meet, on the domain
-record), and `internal/mail` is already 6,954 lines across fifteen files.
+The SES estate is **deployment-scoped and constant**. One receiving domain,
+one receipt rule set holding one rule, one bucket, one topic, one queue —
+built once at deployment setup, drift-checked hourly, and untouched by
+member activity. Enabling inbound for an app therefore makes **no SES API
+call at all**: it writes a claim object and a record. All tenancy resolution
+happens in the deliverer, at delivery time, from the claims.
+
+That property is worth stating first because it is what the review rounds
+bought, and because every "how does this scale / who writes it / what races
+what" question below is answered by it.
---
## 2. Records
-### 2a. `apps/<acct>/<app>/inbound.json`
+### 2a. Deployment-level: the inbound estate
+
+Regional, and **not** necessarily `mail.Deployment.Region`. Seven SES
+regions have no receiving at all (ap-south-2, ap-southeast-5, ca-west-1,
+eu-central-2, me-central-1, both GovCloud), and with the exception of the S3
+bucket, *every* resource used for receiving must be in the same region as
+the SES receiving endpoint — SNS topic and SQS queue included.
+
+```go
+type Inbound struct {
+ Region string // the RECEIVING region; may differ from the sending region
+ Domain string // e.g. "in.oncarlos.com"
+ Bucket string
+ StagePrefix string // "incoming/"
+ TopicARN string
+ QueueURL string
+ RuleSet string // the active receipt rule set this deployment owns
+ IdentityVerified bool
+}
+```
+
+Per-app copies of `Region` / `Bucket` / `QueueARN` were in revision 1 and are
+gone: they are deployment facts, and duplicating them per app creates N
+records to migrate the first time one changes.
-Mirrors `mail.Record`'s three-blocks-for-three-actors shape exactly, so a
-reader of one can read the other:
+### 2b. `apps/<acct>/<app>/inbound.json`
+
+Mirrors `mail.Record`'s three-blocks-for-three-actors shape, so a reader of
+one can read the other:
```go
type Record struct {
@@ -76,145 +139,290 @@ type Record struct {
}
```
-- **`Declared`** — `EnvPrefix` (default `CARLOS_INBOUND`), `DeliverPath` (the
- route path the wake POSTs to, default `/_carlos/inbound`), `At`, `By`.
-- **`GrantBlock`** — `Addresses []string` (the local parts minted for this
- app on the receiving domain), `DailyCap int`, `Source string`
- (`tier:free` / `tier:pro` / `operator`, the same recomputable-without-
- clobbering-an-override discipline `mail.GrantBlock` documents), `At`, `By`.
-- **`Provisioned`** — `Bucket`, `Prefix`, `Region`, `QueueARN`, `Active bool`,
- `PauseKind string` (`cap` / `rate` / `""` for manual, §8), `At`.
-
-`Key(acct, app) = release.AppPrefix(acct, app) + "inbound.json"`. CAS on
-every write, read-modify-write, `release.ErrCASConflict` surfaced never
-absorbed — `mail.Put`'s rule.
-
-### 2b. The address claim — `email/inbound-addresses/<address>.json`
-
-A **global**, create-only key, for precisely the reason
-`internal/mail/domainclaim.go` gives for its own: the key itself is the
-uniqueness gate, because there is only ever one object to Put. Two apps must
-not both own `support@in.oncarlos.com`, and keying this under an account
-prefix would let each account hold its own claim on the same address —
-which is the whole failure the file exists to prevent.
+- **`Declared`** — `EnvPrefix` (default `CARLOS_INBOUND`), `DeliverPath`
+ (default `/_carlos/inbound`), `At`, `By`.
+- **`GrantBlock`** — `Addresses []string`, `DailyCap int`, `Source string`
+ (`tier:<name>` or `operator`; the tier names come from `mailcounts.go`'s
+ `TierCap` and are not enumerated here), `At`, `By`.
+- **`Provisioned`** — `DeliverHost string` (§6d), `Active bool`,
+ `PauseKind string` (§9), `At`.
-Address grammar is deliberately narrow: `^[a-z0-9][a-z0-9._-]{2,63}$`, no
-plus-addressing in the claim (a `+tag` suffix routes to the base claim, §4),
-and a reserved list — `postmaster`, `abuse`, `noreply`, `mailer-daemon`,
-`admin`, `root` — that no app may claim, because the deployment needs them
-and RFC 2142 expects two of them to exist.
+**`Addresses` is a declaration, never an authority.** It records what the
+app asked for and drives nothing. Every routing decision reads the claims
+(§2c). This is the single most important sentence in §2, and §4a explains
+what it prevents.
----
-
-## 3. The three ways this differs from sending, and why
-
-Each of these is a place where copying `internal/mail` would be wrong.
-
-### 3a. There is no IAM isolation to have
-
-Sending isolates tenants with a per-app IAM user carrying an inline policy —
-that is `internal/mail/policy.go`'s entire argument, and its doc comment is
-blunt that the policy, not the SES tenant, is the security boundary.
-
-**Receiving has no equivalent.** SES has exactly one *active receipt rule
-set* per account per region: an ordered list, matched by recipient
-condition, and every tenant's inbound configuration is an entry in that one
-shared mutable global. There is no per-tenant principal to scope, because no
-tenant principal is involved in receiving at all — SES acts on its own.
-
-The consequence is a hard rule, and it is the single most important
-sentence in this spec:
+`Key(acct, app) = release.AppPrefix(acct, app) + "inbound.json"`. CAS on
+every write, `release.ErrCASConflict` surfaced never absorbed —
+`mail.Put`'s rule.
-> **The control plane owns the entire receipt rule set and rewrites it
-> wholesale from the records. Nothing ever patches a rule into the live set
-> additively.**
+### 2c. The address claim — `email/inbound-addresses/<local>@<domain>.json`
-`PutReceiptRuleSet` into a versioned name, then `SetActiveReceiptRuleSet` —
-so the swap is atomic and the previous set survives for rollback. An
-additive patch model would make a mis-ordered or overlapping rule deliver
-one tenant's mail into another tenant's prefix, which is the same class of
-hole `domainclaim.go` was written to close, reached by a different road.
+A **global**, create-only key, for the reason `internal/mail/domainclaim.go`
+gives for its own: the key itself is the uniqueness gate, because there is
+only ever one object to Put.
-Ordering within the rendered set is fixed and derived, never authored:
-explicit-recipient rules sorted by address (longest match is irrelevant —
-SES matches recipients, not prefixes, so exact addresses cannot overlap
-once §2b guarantees uniqueness), then a terminal catch-all rule whose action
-is a `Stop`. The catch-all is what makes an unknown recipient a **silent
-drop** rather than an accept-then-bounce (§7).
+Keyed on the **full address**, not the local part. A deployment with more
+than one receiving domain is inevitable at stage 2, and a local-part key
+would collide `support` across every domain — one app's claim silently
+governing another domain's address.
-`ScanEnabled` is true on every rule, as a platform invariant rather than a
-per-app knob, because §7's verdicts do not exist without it.
+```go
+type AddressClaim struct {
+ Schema int `json:"schema"` // 1
+ Address string `json:"address"` // canonical local@domain
+ Account string `json:"account"`
+ App string `json:"app"`
+ CreatedAt string `json:"created_at"`
+ CreatedBy string `json:"created_by,omitempty"`
+ ReleasedAt string `json:"released_at,omitempty"` // tombstone; see §2e
+}
+```
-### 3b. The MX record and the apex
+**Grammar**, applied by the single canonicalisation function in §4c:
+`^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$`, 3–64 characters, **no leading,
+trailing, or consecutive dots** (revision 1's grammar admitted `support.`
+and `a..b`, neither a legal unquoted local part — a mintable address many
+senders would refuse to address). No `+` — the tag namespace belongs to the
+base claim by construction.
+
+**The refusal is neutral.** `ErrAddressClaimed` never names the holder, the
+rule `mail.ErrDomainClaimed` and `domains.ErrHostUnavailable` both state
+explicitly. On a namespace where the addresses *are* the product, a refusal
+that names or hints at the holder makes the mint door a cross-tenant
+enumeration oracle.
+
+### 2d. Reserved addresses are real claims, held by the platform
+
+Revision 1 enforced a reserved list in the mint path. That is a gate
+computed **upstream** of the key, and the whole lesson of `domainclaim.go`
+is that such a gate holds only for as long as every future writer of the
+prefix remembers to run it.
+
+So: at deployment setup, the platform **writes real claim objects** for
+`postmaster`, `abuse`, `hostmaster`, `webmaster`, `security`, `noreply`,
+`no-reply`, `bounce`, `bounces`, `dmarc`, `admin`, `root`, `mailer-daemon`
+— and, at the moment §3c's zone write happens, for the deployment's own
+DMARC `rua` mailbox if it lives on the receiving domain. That last one is
+not hypothetical housekeeping: §3c publishes `rua=` in a DMARC record, and
+an unreserved `rua` mailbox is claimable by any app, which would hand it
+**the deployment's DMARC aggregate reports for every domain** — a
+fleet-wide map of who sends to whom.
+
+The key is now the gate for reserved names exactly as for everyone else.
+The mint-path check survives only to produce a better error message.
+
+### 2e. A released claim is tombstoned, never freed
+
+Revision 1 modelled release on `domainclaim.go`. The two protect opposite
+arrows and their release hazards are not comparable:
+`releaseDomainClaim` guards against someone else **sending as you**; an
+address claim's re-use hazard is someone else **receiving your mail**.
+
+That tail is far longer. A customer's Gmail or Microsoft 365 forwarding rule
+keeps forwarding to a dead address for months, and §11.3's recommended
+default mints `<app>@in.<suffix>` — derivable from an app name visible on a
+platform hostname. So: tear down an app, wait, claim its address, and
+receive its customers' mail indefinitely. Not a race window.
+
+**Teardown sets `ReleasedAt` and clears `Account`/`App`. The key stays
+occupied and the address is permanently retired.** The namespace is the
+platform's own and effectively unbounded, so retirement is cheap; the
+alternative — a quarantine of at least the retention period plus a
+forwarding-cache margin — buys reuse nobody has asked for at the cost of a
+timer that must never be wrong.
+
+Teardown order is fixed: **stop delivering, then tombstone.** A tombstoned
+claim makes every subsequent arrival unroutable (§4b), which is the safe
+failure.
-`internal/mail/dnsrecords.go` documents that nothing is published at the
-domain apex — the edge owns the A record at an app's host — and
-`TestNoRecordSitsAtTheDomainApex` keeps it true.
+---
-**Stage 1 does not touch that invariant**, and this is worth stating so
-nobody goes looking for a conflict that is not there. The receiving domain
-is a distinct label the platform owns — `in.<PlatformSuffix>`, e.g.
-`in.oncarlos.com` — which is not any app's host and carries no A record. Its
-records are:
+## 3. The SES estate
+
+### 3a. What the API actually offers
+
+There is no wholesale put. The receipt-rule operations are
+`CreateReceiptRuleSet` (creates an *empty* set), `CreateReceiptRule` (one
+rule per call, positioned with `After`), `CloneReceiptRuleSet`,
+`ReorderReceiptRuleSet`, `SetReceiptRulePosition`, `UpdateReceiptRule`,
+`DeleteReceiptRule`, `DeleteReceiptRuleSet`, `SetActiveReceiptRuleSet`.
+None of them exist in SESv2.
+
+Two consequences revision 1 got wrong by assuming a `PutReceiptRuleSet`:
+
+- Building a set is **N+1 calls**, and every non-send SES API call is
+ throttled to **1 request per second**. "Rewrite wholesale on every pass"
+ is a multi-second-to-minutes operation, not an atomic swap. Only
+ `SetActiveReceiptRuleSet` is atomic.
+- A reconciler that mints a versioned set name per pass and never deletes
+ exhausts the **40-rule-sets-per-account** quota in 40 passes.
+
+### 3b. Therefore: one rule, written once
+
+The rule set holds exactly **one rule**, with a **domain** condition:
+
+| | |
+| --- | --- |
+| Recipient condition | `in.<suffix>` (the domain, not addresses) |
+| `ScanEnabled` | `true`, set **explicitly** |
+| Action 1 | S3 → `<bucket>/incoming/`, then |
+| Action 2 | SNS → the deployment topic |
+
+**Why a domain condition and not one rule per address.** An exact-address
+recipient condition is evaluated against the envelope `RCPT TO` as received
+and **does not match a `+tag` variant** — AWS states this verbatim for Mail
+Manager rules ("an EQUALS or CONTAINS rule targeting user@example.com will
+not match user+tag@example.com"), and the classic `ReceiptRule.Recipients`
+contract offers no wildcard or sub-address support. Revision 1's
+one-rule-per-address design would have left every tagged message unmatched,
+so the shared inbox's per-customer addressing — §4's whole point — would
+have failed on day one, silently.
+
+`ScanEnabled` is set explicitly because the **API default is `false`** (the
+dev guide's "SES scans by default" describes the console). §8's verdicts do
+not exist without it.
+
+**What this dissolves.** Revision 1's longest section defended a shared
+mutable global: one active rule set per account per region, every tenant an
+entry in it, a mis-ordered rule delivering one tenant's mail to another. A
+constant one-rule set has no per-tenant entries to mis-order. The
+concurrency discipline that section reached for — generation stamping,
+compare-before-activate, single-writer election — is replaced by: **the
+console writes the estate at deployment setup, and a `DescribeActiveReceiptRuleSet`
+drift check hourly. Boxes never call the SES receipt API.**
+
+The single-writer rule still matters for the drift check (two boxes
+rendering concurrently would race on `CreateReceiptRuleSet`'s
+`AlreadyExistsException` with no way to know whether the winner had finished
+adding rules — a torn set whose gap is a permanent 5xx to real senders). The
+console owns it, serialised by an in-process mutex, which is where
+`EnsureDomain` already lives.
+
+### 3c. The receiving domain
+
+`in.<PlatformSuffix>` — e.g. `in.oncarlos.com` — is the platform's own, and
+carries no A record, so **`internal/mail/dnsrecords.go`'s
+no-record-at-the-apex invariant and `TestNoRecordSitsAtTheDomainApex` are
+untouched at stage 1.** Worth stating so nobody hunts a conflict that is not
+there. §11 is where it becomes real.
| Name | Type | Value |
| --- | --- | --- |
| `in.<suffix>` | MX | `10 inbound-smtp.<region>.amazonaws.com` |
-| `in.<suffix>` | TXT | `v=spf1 -all` (it never sends) |
+| `in.<suffix>` | TXT | `v=spf1 -all` |
| `_dmarc.in.<suffix>` | TXT | `v=DMARC1; p=reject; rua=<deployment rua>` |
-The SPF and DMARC records are not decoration: a receive-only domain that
-publishes neither is an attractive forgery target, and both records cost one
-zone write at deployment setup.
+SPF and DMARC are not decoration: a receive-only domain publishing neither
+is an attractive forgery target, and both cost one zone write.
-Stage 2 is where the apex collision becomes real, because a customer's
-`theircompany.com` may hold both an MX for us and an A record elsewhere.
-§10 records it; this stage does not solve it.
+**The domain must also be a verified SES identity in the receiving region** —
+"email addresses have to belong to one of your verified domain identities".
+Revision 1 omitted this, and nothing in `internal/mail` would ever supply
+it: `platformNameOwnedBy` (walk.go:96) refuses the bare suffix by design and
+verifies only `<label>.<sqid>.<suffix>` names. Deployment setup creates and
+verifies `in.<suffix>` as its own identity, records it as
+`Inbound.IdentityVerified`, and `carlos inbound enable` refuses until it is
+true.
-### 3c. Reputation runs the other way
+### 3d. Resource policies — nothing works without these
-Outbound's scarce resource is the shared sending reputation, which is why
-`internal/mail` has a daily cap and an automatic pause. Inbound's scarce
-resources are different — S3 spend, and instance wake time (§6) — so the cap
-exists for a different reason and behaves differently on trip (§8). Copying
-the outbound cap semantics unexamined would produce a brake that stops the
-wrong thing.
+None of this delivers a byte until:
----
+- **Bucket policy** allowing `ses.amazonaws.com` `s3:PutObject` with
+ `AWS:SourceAccount` and `AWS:SourceArn` = the receipt rule ARN.
+- **SNS topic policy** allowing SES to publish.
+- **SQS policy** allowing the topic to `SendMessage`, with **raw message
+ delivery** on the subscription — otherwise the deliverer unwraps an SNS
+ envelope it did not expect.
+- **Box IAM**: `sqs:ReceiveMessage`, `sqs:DeleteMessage`,
+ `sqs:ChangeMessageVisibility`, `s3:GetObject` on the staging prefix.
-## 4. The receive path
+Because the rule set is constant (§3b), the `SourceArn` is stable and can be
+exact rather than a wildcard — a property revision 1's versioned set names
+would have destroyed, breaking delivery silently on the first rewrite.
-```
-sender → SES (receipt rule set, scan enabled)
- → S3 action: s3://<deployment-inbound-bucket>/<acct>/<app>/<messageId>
- → SNS topic → SQS queue (per deployment)
- → box deliverer (§6) → non-stamping wake → POST to the app's route
-```
-
-Recipient → app resolution happens **once**, in the reconciler that renders
-the rule set, and again in the deliverer as a check. A `+tag` suffix is
-stripped before the claim lookup and preserved verbatim in the delivered
-payload — that is how a shared inbox does per-thread or per-customer
-addressing without a claim per thread.
+---
-The S3 object key is SES's `messageId`, which is also the notification's
-`objectKey`. One identifier, two uses, no mapping table.
+## 4. Routing: recipient to app
+
+### 4a. The claims are the only authority
+
+The deliverer resolves each envelope recipient against the **claim**, never
+against `GrantBlock.Addresses`.
+
+This is the correction to revision 1's central hole, and it is worth
+spelling out because the hole is the one `domainclaim.go` already paid for
+once. That file's doc comment: *"Both of §4's rails held perfectly and the
+tenant boundary still fell, because both are computed downstream of a domain
+set nothing ever gated on ownership."* Revision 1 rendered routing from
+`Addresses`, which an operator override, a restored backup, or a CAS retry
+can populate with an address the app does not hold — and nothing re-checked
+it. Two apps would then both route one address, with sort order deciding who
+received the mail.
+
+`Addresses` is a declaration. The claim is the authority. The deliverer
+joins one to the other and **skips any address whose claim names a different
+`(account, app)`**, and there is a test for it: a record listing an address
+it does not hold produces no route at all.
+
+### 4b. Unroutable mail
+
+With a domain-condition rule, SES accepts everything at `in.<suffix>` and
+the deliverer decides. A recipient that does not canonicalise, has no claim,
+or hits a tombstone (§2e) is **dropped by the deliverer**: the staging object
+is deleted, the event counted as `unroutable`, and the count surfaced in
+`carlos inbound status`.
+
+**Say the cost honestly.** Revision 1 justified a silent drop with a
+backscatter argument that was backwards: with no matching rule SES *blocks*
+the message at rule evaluation — a refusal to the sending MTA, not a bounce
+this deployment generates — so there was no backscatter to prevent. The real
+position is less comfortable: accept-all is **forced by the tag feature**
+(§3b), and it means `in.<suffix>` accepts and discards mail for addresses
+nobody owns, billed per message, with no NDR. At stage 1, where a customer
+types the forward target by hand into Gmail, **a mistyped address is silent
+loss.**
+
+The mitigation is visibility, not cleverness: `carlos inbound status` lists
+recent unroutable recipients, so "I set up forwarding and nothing arrives"
+is diagnosable in one command rather than being invisible on both sides.
+`postmaster@` and `abuse@` are reserved (§2d) and must route somewhere real
+before launch.
+
+### 4c. One canonicalisation function
+
+Used identically by the mint path, the deliverer, and the status command:
+lowercase; reject quoted local parts and non-ASCII/SMTPUTF8; strip **one**
+`+tag` at the first `+`; apply §2c's grammar to what remains.
+
+Claim grammar and wire recipient are different input spaces — the wire
+admits mixed case, `"support"@…`, and punycode — and revision 1 treated them
+as one. A recipient that fails to canonicalise is unroutable (§4b), never
+coerced.
---
-## 5. Delivery contract: pointer plus wake
+## 5. Delivery contract
-The app receives an HTTP POST at `Declared.DeliverPath` on its own route,
-carrying JSON:
+The app receives an HTTP POST at `Declared.DeliverPath` on its own route:
+
+```
+POST /_carlos/inbound
+Authorization: Bearer <CARLOS_ADMIN_TOKEN>
+X-Carlos-Inbound: 1
+User-Agent: carlos-inbound/<build>
+```
```json
{
"schema": 1,
- "message_id": "<SES messageId — also the S3 object key>",
+ "message_id": "<SES messageId — also the staging object key>",
"received_at": "<ISO 8601>",
- "recipient": "support+acme@in.oncarlos.com",
- "recipient_base": "support@in.oncarlos.com",
- "tag": "acme",
+ "recipients": [
+ { "address": "support+acme@in.oncarlos.com",
+ "base": "support@in.oncarlos.com", "tag": "acme" }
+ ],
"envelope": { "from": "...", "to": ["..."] },
"headers": { "message_id": "...", "from": "...", "subject": "...",
"in_reply_to": "...", "references": "..." },
@@ -223,175 +431,312 @@ carrying JSON:
"verdicts": { "spam": "PASS", "virus": "PASS", "spf": "FAIL",
"dkim": "PASS", "dmarc": "FAIL", "dmarc_policy": "none" },
"size": 48213,
- "raw_url": "<presigned GET, ~1h>"
+ "raw_url": "<presigned GET on the deployment bucket host, 15 min>"
}
```
-**Why a pointer and not the message.** An inline POST caps message size at
-whatever the route will take and puts MIME parsing in the platform; SES
-accepts up to 40MB and a shared inbox gets attachments. A mailbox the app
-polls fails the "in seconds" requirement and cannot wake a sleeping
-instance. The pointer is `internal/objstore`'s discipline — hand over
-coordinates, never data.
-
-**Why a presigned URL and not a delivered credential.** A long-lived scoped
-read credential means an IAM principal, a rotation story, and a revocation
-story, all of which `internal/mail` and `internal/objstore` had to build. A
-presigned GET generated at delivery time has no lifecycle at all. If it
-expires before the app fetches, a console door re-mints one for a message
-the app can prove it owns.
-
-**`arrival` is load-bearing, not informational.** It is `"forwarded"` at
-stage 1 and `"direct"` at stage 2, and without it the app cannot interpret
-its own verdicts — see §7.
-
-**`headers` is a triage surface, never the source of truth.** SES truncates
-the header block in the notification above 10KB and sets
-`headersTruncated`; the S3 object is always complete. The field is carried
-so the app can decide whether to fetch at all.
+### 5a. The bearer token is mandatory, and the app must check it
+
+Revision 1 shipped this POST with no authentication and argued the SQS poll
+made it unspoofable. **It does not.** `ServeScheduled`'s "the call site is
+the signal" protects the *wake budget*; the schedule runner authenticates
+its ticks to the app separately, in `BuildRequest`
+(internal/schedule/runner.go:439), with `Authorization: Bearer
+<CARLOS_ADMIN_TOKEN>` sourced from the host env file the activator owns
+(internal/activator/admintoken.go — "what the agent presents to an instance
+for the things only the platform may ask").
+
+`DeliverPath` is an ordinary path on the app's public host — `_carlos` is
+reserved nowhere in the edge; it appears nowhere in the codebase outside
+this spec. Without a bearer, anyone who knows the hostname can POST a
+message with a forged `envelope.from`, forged `verdicts` (which §8 tells the
+app to trust as provenance), and a forged `In-Reply-To` that grafts it onto
+a real thread where a human reads it as the correspondent's next reply — and
+an attacker-chosen `raw_url` turns the app's fetch into SSRF from inside the
+instance.
+
+So, as part of the published wire contract (§14): **the app MUST verify the
+bearer against its own `CARLOS_ADMIN_TOKEN` and refuse otherwise**, and MUST
+check `raw_url`'s host against the deployment bucket host before fetching. A
+wire shape nobody is told to authenticate will not be authenticated. The
+same-uid residual `admintoken.go` and `internal/schedule/control.go` both
+document applies here and is a stated bound, not a later discovery.
+
+### 5b. Why a pointer, and why a presigned URL
+
+An inline POST caps message size at whatever the route takes and puts MIME
+parsing in the platform; SES accepts up to 40MB and a shared inbox gets
+attachments. A mailbox the app polls cannot wake a sleeping instance. The
+pointer is `internal/objstore`'s discipline — hand over coordinates, never
+data.
+
+The presigned URL is the contested choice and the objection is recorded
+here rather than resolved away. **A presign is an unrevocable, unattributable
+bearer**: no lifecycle also means no revocation, and S3 access logs record
+the *signing* principal, not the fetcher. If an app logs request bodies — a
+framework log, an error tracker capturing the POST body — anyone reading
+those logs fetches the complete raw message with its attachments and PII,
+with no authentication and no trace.
+
+It is kept because the alternative (the app fetching through a console door
+under its own credential) streams 40MB through the console and needs the app
+to hold a console credential it otherwise does not need. The mitigations are
+therefore mandatory rather than advisory: **15-minute TTL** (not the hour of
+revision 1), host pinning (§5a), and a re-mint door (§6e) for an expired
+one. Revisit if apps turn out to log bodies in practice.
+
+Note the presign is signed with the box's instance-role session and cannot
+outlive it; 15 minutes is comfortably inside.
+
+### 5c. Shape details that are contract from the first parse
+
+- **`recipients` is a list.** SES's `receipt.recipients` is a list, and one
+ message to `support@` and `sales@` is one object and one notification
+ naming both. Revision 1's singular `recipient` would have needed a
+ breaking change on the first CC. One POST per `(message_id, app)`.
+- **`envelope.to` comes from `receipt.recipients`** (the envelope, matched),
+ not `mail.destination` (the headers).
+- **`in_reply_to` and `references` are not in `commonHeaders`** — they come
+ from the `headers` array, and may be absent when `headers_truncated`.
+- **`headers` is a triage surface, never the source of truth.** SES
+ truncates above 10KB and sets `headersTruncated`; the object is always
+ complete. The field exists so the app can decide whether to fetch at all.
+- **`arrival`** is `"forwarded"` at stage 1, `"direct"` at stage 2. Without
+ it the app cannot interpret its own verdicts (§8a).
+
+---
+
+## 6. Delivery: the box deliverer
+
+### 6a. Why SQS and not an SNS HTTP subscription
+
+#371's argument holds and is the reason: a tick never leaves the box, so the
+call site *is* the signal. An HTTP webhook is a public door whose whole
+function is waking instances, with signature verification the only thing
+between a stranger and the fleet's wake budget. Polling a queue keeps the
+**wake** unspoofable by construction. (The **delivery** is made unforgeable
+by §5a's bearer — two properties, two mechanisms. Revision 1 claimed the
+first and assumed the second.)
+
+### 6b. The non-stamping wake, and its accepted cost
+
+Delivery goes through `activator.ServeInbound`, a sibling of
+`ServeScheduled` with `stamp=false`. A delivery must be able to **wake** an
+instance and must not be able to **hold** one awake — precisely #371's
+finding, and overnight inbound has exactly the shape it described.
+
+Two bounds to state rather than imply:
+
+- The in-flight hold in `serve()` means a slow handler (fetch 40MB, parse)
+ holds the instance for its duration, exactly as a tick does. "Must not
+ hold one awake" means *beyond the handler*.
+- #371's sweeper guard (`heldForTick`) reads registry **timers**, and
+ inbound has no next-due to feed it. So a trickle of mail at intervals
+ longer than `cfg.Idle` costs one checkpoint-and-restore per message,
+ bounded at one wake per idle window per host. **This is accepted**: a
+ human-attended inbox is awake through the working day, and the overnight
+ trickle is the case the bound covers.
+
+**Coalescing** is a batching property, not a rate limit. A receive batch is
+at most 10 messages; messages arriving within one idle window hit an already
+awake instance. Revision 1's "ten messages must not be ten wakes" is true
+only in those two senses, and the claim is narrowed to them here — §9 is
+what actually bounds wake frequency.
+
+### 6c. Queue ownership
+
+One queue per deployment. A box that does not own the named host
+**returns the message with a 15–60s backoff, never visibility zero.**
+
+Visibility zero is a hot spin: `ReceiveMessage` returns immediately while a
+message is visible, so every non-owning box spins at full request rate until
+the owner takes it — and if the owner is down, mid-move, or the app is
+paused, the message ping-pongs indefinitely while `ApproximateReceiveCount`
+climbs, until a `maxReceiveCount` meant for poison messages dead-letters
+real mail. Cost is O(fleet) per message even in the healthy case.
+
+A DLQ exists for genuinely undeliverable messages, its depth is surfaced in
+`carlos inbound status`, and **ownership churn must never reach it.** SQS
+retention (4 days default, 14 maximum) bounds how long "not mine yet" can be
+tolerated, which is why §9's backlog does not live in the queue.
+
+### 6d. Which host receives the POST
+
+An app has N instances across channels and pools, so "deliver to the app" is
+underspecified: delivering to all doubles every thread.
+`Provisioned.DeliverHost` names one, chosen at enable — the instance on the
+pipeline's entry channel, following #372 — refused when ambiguous, and
+re-resolved on serial moves so a residence move re-targets.
+
+### 6e. Retry semantics, and the re-mint door
+
+Per outcome, following `deliverVia` (schedule_wiring.go:383):
+
+| Outcome | Action |
+| --- | --- |
+| 2xx | delete the SQS message |
+| 4xx (app refused) | delete, count as `refused` |
+| App 5xx, timeout | return with backoff |
+| Activator wake refusal (503) | return with backoff, not a delivery failure |
+| Repeated wake failure past `maxReceiveCount` | DLQ, surfaced in status |
+
+The **re-mint door** (`POST .../inbound/messages/{message_id}/url`) composes
+the object key from the **caller's authenticated account and app**, never
+from anything in the request body, and returns a byte-identical 404 for both
+"no such message" and "not yours" — otherwise it is a message-existence
+oracle across tenants, and AGENTS.md's gate ordering exists for this.
+
+On classification: it is a **read-class door, not a mint door**. The
+`mintDoors` list in `svcroutes_test.go` is about credentials that are "a
+standing authority… [that] outlives the request", closed to every service
+role. A 15-minute single-object read is not that. Revision 1 asserted "no
+mint door here at all" without argument, which reads as licence to register
+it permissively; the argument is made here and belongs in the test file's
+comment too.
---
-## 6. Waking a hibernating instance
-
-The deliverer runs **on the box**, polls the deployment's SQS queue, checks
-the target host against its own registry, and delivers through a
-non-stamping wake — `activator.ServeInbound`, a sibling of `ServeScheduled`
-with the identical signature and the identical `stamp=false`.
-
-**Why SQS and not an SNS HTTP subscription.** #371's `ServeScheduled` doc
-comment makes the argument precisely: a tick never leaves the box, so the
-call site *is* the signal, and there is no header for an outsider to send.
-The same must be true here. An HTTP webhook is a public door whose whole
-function is to wake instances — signature verification would be the only
-thing between a stranger and the fleet's wake budget. Polling a queue keeps
-the wake unspoofable by construction, which is the property worth paying a
-poll for.
-
-**Why non-stamping.** #371's finding was that a 5-minute schedule against a
-15-minute idle window means an instance never sleeps, because a 1ms 204
-buys another full window. Overnight inbound has exactly that shape. A
-delivery must be able to *wake* an instance and must not be able to *hold*
-one awake; the app's own subsequent activity is what earns the idle credit.
-
-**Ownership.** One queue per deployment; each box checks whether the named
-host is its own and releases a message that is not (visibility timeout to
-zero) rather than deleting it. Recommended over per-box queues because the
-fleet is small and the registry already answers "is this host mine"
-authoritatively. Flagged in §11 as the decision most likely to want
-revisiting at fleet scale.
-
-**Coalescing.** A batch of messages for one sleeping host is one wake, all
-pending messages delivered on it. Ten messages must not be ten wakes.
-
-**At-least-once.** SQS redelivers, so the POST is not exactly-once and the
-app must be idempotent on `message_id`. This is one of two dedup keys the
-app needs, and they are not the same key — see §9.
+## 7. Deleted section
+
+*(Revision 1's §7b, "never bounce after accepting", is gone: its premise was
+wrong and its conclusion is now §4b. Kept as a numbered gap so cross-
+references from the review rounds still resolve.)*
---
-## 7. Spam, and what SES actually gives us
+## 8. Spam, and what SES actually gives us
-Verified against the SES developer guide on 2026-09-04
-(`receiving-email-notifications-contents.html`), in the spirit of
-`internal/mail/aws.go`'s tenant-attribution note — the earlier tenant-email
-spec guessed an `X-SES-TENANT-NAME` header that does not exist, and the cost
-of guessing here is the same.
+Verified against the SES developer guide, 2026-09-04, in the spirit of
+`internal/mail/aws.go`'s tenant-attribution note.
-SES performs five checks and reports each as one of exactly four values —
-`PASS`, `FAIL`, `GRAY`, `PROCESSING_FAILED`:
+Five checks, each reported as exactly one of `PASS`, `FAIL`, `GRAY`,
+`PROCESSING_FAILED`:
-- On the **S3 action**, results are stamped into the stored message as
- `X-SES-Spam-Verdict` and `X-SES-Virus-Verdict`.
-- On the **SNS notification**, all five arrive as structured JSON on the
- `receipt` object: `spamVerdict`, `virusVerdict`, `spfVerdict`,
- `dkimVerdict`, `dmarcVerdict`, each `{status: ...}`.
+- On the **S3 action**: `X-SES-Spam-Verdict` and `X-SES-Virus-Verdict`
+ stamped into the stored message, plus `Authentication-Results`.
+- On the **SNS notification**: all five as structured JSON on `receipt` —
+ `spamVerdict`, `virusVerdict`, `spfVerdict`, `dkimVerdict`,
+ `dmarcVerdict`.
-**There is no numeric score.** Nothing to threshold, nothing to tune. Any
-design that assumes a score is designing for a different provider.
+**There is no numeric score.** Nothing to threshold. Any design assuming one
+is designing for a different provider.
Two shapes to model correctly:
-- `dmarcPolicy` (`none` / `quarantine` / `reject`) **appears only when DMARC
- fails.** Its absence is not "policy: none" — it is "DMARC did not fail".
- A struct that decodes a missing field to `"none"` reports the opposite of
- the truth on every passing message.
-- Scanning is per-rule (`ScanEnabled`) and can be off, which is why §3a
- makes it a platform invariant.
+- `dmarcPolicy` (`none`/`quarantine`/`reject`) **appears only when DMARC
+ fails.** Absence is not "policy: none" — it is "DMARC did not fail". A
+ struct defaulting it to `"none"` reports the opposite of the truth on
+ every passing message.
+- `ScanEnabled` defaults to **`false`** in the API (§3b).
-Taking the verdicts from the notification is the useful part of this: the
-platform gets all five **without parsing the message**, so the app can
-triage before fetching a 40MB object.
+Taking verdicts from the notification is the useful part: the platform has
+all five **without parsing the message**, so the app triages before fetching
+40MB.
-### 7a. Why the verdicts are nearly worthless at stage 1
+### 8a. Why they are nearly worthless at stage 1
Forwarded mail reaches SES *after* the customer's Gmail or Microsoft 365 has
-already filtered it, so the spam verdict is computed on a pre-cleaned
-stream and will read `PASS` almost always. And forwarding breaks SPF by
-construction — the forwarding server is not authorised to send for the
-original sender's domain — so `spfVerdict: FAIL` is the **normal** case on
-this path, usually taking DMARC with it. DKIM generally survives.
-
-So: at stage 1 the verdicts are **provenance to record, not a filter to act
-on**. An implementation that treats `spf: FAIL` as suspicious will silently
-eat legitimate customer mail on day one. That is the failure this section
-exists to prevent, and it is why §5's `arrival` field is mandatory rather
-than nice to have — at stage 2 the same verdicts on the same wire become
-meaningful, and only `arrival` distinguishes the two.
-
-The exception is **virus**. `virusVerdict: FAIL` is a platform-side drop at
-both stages: counted, visible in `carlos inbound status`, never delivered.
-It is the one verdict whose meaning does not depend on the arrival path.
+filtered it, so the spam verdict is computed on a pre-cleaned stream and
+reads `PASS` almost always. And forwarding breaks SPF by construction — the
+forwarding server is not authorised to send for the original sender's domain
+— so `spf: FAIL` is the **normal** case here, usually taking DMARC with it.
+DKIM generally survives.
+
+At stage 1 the verdicts are **provenance to record, not a filter to act
+on**. An implementation treating `spf: FAIL` as suspicious will silently eat
+legitimate customer mail on day one. That is what this section exists to
+prevent, and why §5's `arrival` is mandatory: at stage 2 the same verdicts
+on the same wire become meaningful, and only `arrival` distinguishes them.
+
+**Virus is the exception.** `virusVerdict: FAIL` is a platform-side drop at
+both stages: counted, visible in status, never delivered — the one verdict
+whose meaning does not depend on the arrival path.
Junk-versus-ticket classification belongs to the app. A `support@` address
-is published on purpose, so genuine spam volume is high and the call is
-product judgement. The platform hands over the raw message and the
-verdicts, caps abuse, and does not decide.
+is published on purpose, so spam volume is high and the call is product
+judgement. The platform hands over the message and the verdicts, caps abuse,
+and does not decide.
-### 7b. Never bounce after accepting
+---
-SES accepts, then delivers. An unknown recipient is therefore a **silent
-drop** (§3a's terminal catch-all `Stop` rule), never an outbound bounce: a
-bounce to a forged sender is backscatter against the deployment's own
-reputation, and a `support@`-shaped surface attracts forged senders
-specifically.
+## 9. Caps, pause, backlog, retention
----
+### 9a. The cap must not have a hard stop as its trip action
+
+§1a's asymmetry, stated plainly: an outbound cap counts the tenant's **own**
+actions, so stopping the tenant is right. An inbound cap counts
+**strangers'** actions — which turns the same mechanism from a brake on the
+tenant into a weapon against them.
+
+Revision 1 paused delivery outright on trip. That is a cheap, targeted,
+indefinitely repeatable shutdown: send `DailyCap` messages at 00:01, and a
+human-attended shared inbox is dark for the rest of the day; `PauseKind:
+cap` clears on the day roll, so it re-trips daily forever for the price of N
+emails, with §4b guaranteeing nobody outside is told.
-## 8. Caps, pause, and retention
+So the cap trips into a **floor interval** — delivery coalesced to one wake
+per 15 minutes — which bounds cost while keeping the product alive. The hard
+stop is reserved for an operator's manual pause.
-**Counting.** Per-app, per-UTC-day, the shape `internal/console/mailcounts.go`
-already uses for sending. SES exposes no per-recipient inbound rate limit,
-so a cap can only be enforced by counting what has already arrived — the
-same reasoning `internal/mail/events.go` records for the outbound side.
+Alongside it, and doing the work a volume cap cannot:
-**On trip, delivery pauses; receiving does not.** Messages continue to land
-in S3 and count; what stops is the wake and the POST. This is the divergence
-§3c predicted, and the reason is that the two failure modes are not
-symmetrical: a tripped outbound cap protects a shared reputation, so it must
-stop the thing itself, while a tripped inbound cap protects wake budget and
-spend — and refusing the mail would lose a customer's email permanently to
-protect a few cents. When the pause clears, the backlog delivers, coalesced.
+- **Per-source rate limiting.** A flood from one envelope sender stops
+ *that sender*, not the app's inbox. This is what distinguishes "busy" from
+ "attacked".
+- **Virus drops and unroutable drops consume no budget.** Otherwise an
+ attacker spends the app's allowance on mail it never receives.
-`PauseKind` follows `mail.Provisioned.PauseKind` exactly: `cap` clears itself
-on the UTC day roll, `rate` and manual (`""`) never auto-resume, and every
-resume clears the field whatever resumed it.
+`PauseKind` follows `mail.Provisioned.PauseKind`: `cap` clears on the UTC day
+roll, manual (`""`) never auto-resumes, every resume clears the field.
+**`rate` is dropped** — it means nothing on the receive side, where there is
+no bounce or complaint rate to trip it.
-**Retention.** Inbound objects get a bucket lifecycle expiry — **30 days**,
+### 9b. Counting
+
+**At arrival, never at delivery.** Counting at delivery makes a backlog
+drain re-trip the cap and never catch up.
+
+The counter lives where `mailcounts.go`'s does — the console — but arrivals
+are seen by the **box**, which cannot write that table. The deliverer
+reports through a bucket status object on the `schedule.WriteStatus` model,
+**batched**, and the batching is not an optimisation: one email from a
+stranger becoming one unauthenticated-at-source write, fleet-wide, is its
+own amplifier.
+
+The deliverer learns `Provisioned.Active` by reading `inbound.json` on
+serial moves, as the schedule converger does, and **the pause is evaluated
+on the box before the wake** — a check after the wake stops nothing that
+matters.
+
+### 9c. The backlog is derived from the bucket
+
+Revision 1 promised "the backlog delivers when the pause clears" with the
+backlog in SQS. It cannot be: retention caps at 14 days against a 30-day
+lifecycle, and a manual pause never auto-resumes — so a pause longer than 14
+days silently destroys the backlog, with the member promised delivery and
+given a hole.
+
+Instead: for a paused app the deliverer writes the pointer into a **per-app
+pending index** in the bucket and deletes the SQS message. On resume it
+drains the index. The index is bucket-durable, so it outlives the queue and
+is bounded only by the object lifecycle.
+
+### 9d. Retention
+
+Staging objects expire on a **30-day** bucket lifecycle,
deployment-configurable. The platform is a delivery path, not the app's
-archive; an app that wants the message kept copies it into its own store on
-receipt. Without an expiry this bucket only grows, and it grows fastest with
-exactly the spam nobody wants to keep.
+archive; an app wanting the message kept copies it into its own store on
+receipt. Without an expiry the bucket only grows, fastest with exactly the
+spam nobody wants.
---
-## 9. What "no outbound" buys, and the one bug it creates
+## 10. What "no outbound" buys, and the one bug it creates
Ruling 3 removes real work: no envelope rewriting, no per-correspondent
reply cap, no auto-reply loop protection at the platform layer, no risk to
the shared SES sending reputation, and no sub-tenant dimension on the domain
-record (ruling 4). An auto-reply loop becomes a product bug in the app
-rather than a platform incident.
+record. An auto-reply loop becomes a product bug, not a platform incident.
It creates one bug, and it is the one that bites shared inboxes built this
way:
@@ -400,93 +745,110 @@ way:
> Gmail API — so every reply the app sends is forwarded straight back into
> the inbox it came from, and every thread doubles.
-This is the app's to fix, but the platform must make it *fixable*, which is
-why §5's payload carries the full envelope and the threading headers rather
-than a summary. Two distinct dedup keys are needed and they are not
-interchangeable:
+The app's to fix; the platform must make it *fixable*, which is why §5
+carries the full envelope and threading headers rather than a summary. Two
+dedup keys, not interchangeable:
-- **`message_id`** (SES's) — deduplicates *SQS redelivery* of one message.
-- **`headers.message_id`** (the mail's own RFC 5322 `Message-ID`) —
- deduplicates *the same mail arriving twice*, including the app's own sent
- mail arriving back through the forward.
+- **`message_id`** (SES's) — deduplicates **SQS redelivery**, which is
+ at-least-once by design.
+- **`headers.message_id`** (RFC 5322) — deduplicates **the same mail
+ arriving twice**, including the app's own sent mail returning through the
+ forward.
-An implementation that dedups on only the first will double every thread; on
-only the second, it will drop a legitimate resend. Worth designing in before
-James's first pilot rather than discovering during it.
+Dedup on only the first and every thread doubles; on only the second and a
+legitimate resend is dropped.
---
-## 10. Stage 2, sketched only
+## 11. Stage 2, sketched only
Direct MX: the customer points `theircompany.com`'s MX at CARLOS.
It belongs in **`internal/domains`**, extending the managed custom-domain
connection surface (2026-08-27) with an MX-receiving capability and its own
-verification state — *not* in a parallel claim model inside
-`internal/inbound`. That layer already solves "an app's customers connect
-their own domains and the platform reports verification status" for A
-records and certificates; direct-MX inbound is the same problem with a
-different record type and a different verifier.
+verification state — not a parallel claim model inside `internal/inbound`.
+That layer already solves "an app's customers connect their own domains and
+the platform reports verification status" for A records and certificates;
+direct-MX inbound is the same problem with a different record type.
-What stage 2 must confront and stage 1 does not:
+What stage 2 must confront:
- **The apex.** A customer domain may carry an MX for us and an A record
- elsewhere. §3b's invariant holds only because stage 1's receiving domain
+ elsewhere. §3c's invariant holds only because stage 1's receiving domain
is ours alone.
-- **Rule set at N.** §3a's wholesale rewrite is cheap for a handful of
- platform addresses and needs re-examining against thousands of customer
- domains — SES's receipt-rule limits are the binding constraint, and a
- domain-condition rule per customer may have to replace a recipient
- condition per address.
-- **Verdicts become meaningful.** `arrival: "direct"` flips §7a: SPF and
- DMARC now say something true, and the app's classification can use them.
-
-Not specced here. Stage 1 ships without any of it.
+- **Rule count.** §3b's single domain-condition rule is what makes stage 1's
+ estate constant. Thousands of customer domains means either a rule per
+ domain — against a 200-rules-per-set quota and 1 req/s — or a fundamentally
+ different mechanism. **This is the stage 2 design question**, and it should
+ be answered before anything about stage 2 is promised.
+- **Verdicts become meaningful.** `arrival: "direct"` flips §8a.
+- **A second receiving domain** makes §2c's full-address claim key earn its
+ keep.
---
-## 11. Open decisions
+## 12. Open decisions
-1. **Queue topology** (§6) — one deployment queue with an ownership check,
- versus one per box. Recommended: shared, revisit at fleet scale.
-2. **Receiving domain name** — `in.<PlatformSuffix>` versus a dedicated
+1. **Receiving-domain name** — `in.<PlatformSuffix>` versus a dedicated
registrable domain. A separate domain isolates receiving reputation and
- costs a registration; the subdomain costs nothing and is one zone.
- Recommended: subdomain.
-3. **Address minting UX** — does `carlos inbound enable` mint one address
- (`<app>@in.<suffix>`) or does the member choose every local part? The
- claim model (§2b) supports either.
-4. **Default `DailyCap`** — needs a number from the tier model, not from
- this spec.
+ costs a registration. Recommended: subdomain.
+2. **Address minting UX** — does `carlos inbound enable` mint
+ `<app>@in.<suffix>`, or does the member choose every local part? The
+ claim model supports either. Note §2e: a derivable default is what makes
+ a torn-down app's address guessable, which the tombstone handles but
+ which a member-chosen name avoids entirely.
+3. **Default `DailyCap` and the floor interval** — need numbers from the
+ tier model.
+4. **Presign versus door-fetch** (§5b) — kept as presign with mitigations;
+ revisit if apps log request bodies.
---
-## 12. Surfaces to build
-
-- `internal/inbound` — records, claim, `Provisioner` seam + AWS
- implementation, idempotent walks, rule-set renderer and reconciler.
-- `internal/console/apiinbound.go` — the door family, mirroring
- `apimail.go`'s registration and its `svcrole` classification: the pause
- door is a POLICY door, and there is no MINT door here at all (§5's
- presigned URL is why).
+## 13. Surfaces to build
+
+- `internal/inbound` — records, claims and tombstones, canonicalisation,
+ the `Provisioner` seam over **SES v1** (`service/ses`, a new dependency),
+ deployment-setup walk, drift check, the deliverer's routing.
+- `internal/console/apiinbound.go` — the door family. **The pause door must
+ not use `apiAccountApp`'s shape**: `cliUser → store.IsOperator → bare
+ byte-identical 404`, account resolved afterward without requiring
+ membership (the `mailPauseTarget` shape). `apimail.go`'s file comment
+ records why — *"a pause is applied AGAINST a tenant, not asked for by
+ one"*, and member-then-allowlist deadlocks the abuse brake in exactly the
+ scenario it exists for, since the abusive tenant has no reason to add the
+ investigating operator as a member. Mirroring `apimail.go`'s *registration*
+ without its *shape* reproduces the pre-I3 deadlock.
- `internal/activator` — `ServeInbound`, sibling of `ServeScheduled`.
-- `internal/host` — the deliverer's wiring, mirroring `schedule_wiring.go`'s
- converger/runner split and its build-once discipline.
+- `internal/host` — the deliverer's wiring, mirroring
+ `schedule_wiring.go`'s build-once discipline. It polls, routes, delivers,
+ counts. **It never calls the SES receipt API.**
- `inbound_cli.go` — `carlos inbound <enable|status|addresses|pause|resume|test>`.
+ `status` carries the diagnosis surfaces §4b and §6c depend on: recent
+ unroutable recipients, DLQ depth, backlog age, today's count against cap.
`test` sends a real message to a real address and reports what arrived, on
- the same argument `carlos email test` makes for existing at all: every
- other verb reports what the record claims, and one verb must prove the
- path actually works.
+ the argument `carlos email test` makes for existing at all — every other
+ verb reports what a record claims, and one verb must prove the path works.
+ It is the bounded exception to ruling 3, and it sends through the
+ deployment's own sending identity via `internal/mail`'s estate, from the
+ sending region.
+
+**Before anything else is built**, confirm empirically that a tagged
+recipient reaches a domain-condition rule and that an exact-address
+condition does not match it. §3b's whole shape rests on it, and it is one
+test message.
+
+---
-## 13. Repo rules this change is bound by
+## 14. Repo rules this change is bound by
-- **`docs/site/cli.md` must name every `carlos inbound` verb** before the
- gate passes — `internal/docsite` reads main.go's dispatch switch and fails
- over a command with no heading.
+- **`docs/site/cli.md` must name every `carlos inbound` verb** — `internal/docsite`
+ reads main.go's dispatch switch and fails the gate over a command with no
+ heading.
- **Wire shapes are additive.** §5's payload is a published contract the
- moment one app parses it.
+ moment one app parses it — which is why §5c's list-shaped `recipients` and
+ the bearer are settled now rather than after the first ship.
- **The consumer list is everything that PARSES a shape**, not everything
- that calls the API — learned three times, per AGENTS.md.
+ that calls the API.
- `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .`
- clean; the branch pushed to `amadan` and the run read before it is called
+ clean; branch pushed to `amadan` and the run read before it is called
green.