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
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. |