Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Build plan

This is the order of work for building Pylota Mail from an empty repository to v1.0, written for a coding agent (or a team) working through it in one pass. Each milestone lists:

  • the files it creates;
  • the requirements it implements;
  • the tests that prove it;
  • the gate that must be green before the next milestone starts.

Design documents are binding. When a milestone shows a design is wrong, stop, write an ADR, update the design, then continue. Never let code and docs drift. When two pages disagree, openapi.yaml wins for wire behaviour and the design page wins for internal behaviour (Design › Precedence).

How to work through it

  1. Test first. For each item, write the test named in the edge-case register or the milestone’s acceptance list, watch it fail, then implement.

  2. Gate after every milestone:

    cargo fmt --all --check
    cargo clippy --workspace --all-targets -- -D warnings
    cargo test --workspace
    cargo xtask build-worker   # wasm build + size budget
    cargo xtask itest          # from M5 onwards
    mdbook build docs
    
  3. One pull request per milestone (or per track once tracks run in parallel). The PR description lists the FR IDs and edge rows covered. main is protected. Nothing merges red.

  4. Contracts are frozen once written: API paths and shapes, error codes, event types, MCP tool names, CLI commands. Additive changes need a docs update in the same PR. Breaking changes need an ADR.

  5. Record spike outcomes in the design document they affect, under a “Spike result” note with the date.

Timeline

PhaseWhatElapsed time with AI coding agents
A. Foundation and spikesM0–M10.5–1 day. Spikes need a real Cloudflare account and DNS
B. BuildM2–M19, M21–M26, run as parallel tracks after M53–5 days of agent time, depending on parallelism and review speed
C. Live proofM20: staging deploy, live end-to-end suite, deliverability checks2–5 days. DNS propagation, Email Sending onboarding and real-mailbox tests are wall-clock bound
D. Hardening before production trafficDMARC ramp (p=none → quarantine → reject), Postmaster Tools enrolment, external review4–6 weeks of calendar time, mostly waiting, run in parallel with early use

Writing the code is the fast part. The time that cannot be compressed is the live proof:

  • real inbound from Gmail and Outlook;
  • bounces and complaints;
  • a domain change;
  • a domain on an external DNS host;
  • a domain failure with fallback;
  • erasure with probes.

None of this is optional. Each check guards against a failure mode that has already happened in production with the previous provider.

Dependency graph

An arrow means “must be done before”.

M0 skeleton ─▶ M1 spikes ─▶ M2 core ─▶ M3 api-types ─▶ M4 platform ─▶ M5 worker base
                                                                         │
            ┌──────────────┬──────────────┬──────────────┬──────────────┼──────────────┐
            ▼              ▼              ▼              ▼              ▼              ▼
     M6 identities   M8 webhooks    M16 SDK/CLI    M17 observability  M19 site/release  │
     and outbox            │                                                          │
            │              │                                                          │
            ▼              │                                                          │
     M7 inbound ◀──────────┘                                                          │
     and wait                                                                         │
            │                                                                         │
     ┌──────┴───────┬───────────────┐                                                 │
     ▼              ▼               ▼                                                 │
 M9 outbound   M10 search      M12 triage                                             │
     │              │               │                                                 │
     ▼              ▼               │                                                 │
 M13 domains   M11 agentic          │                                                 │
     │              │               │                                                 │
     └──────┬───────┴───────┬───────┘                                                 │
            ▼               ▼                                                         │
     M14 privacy      M15 MCP ◀───────────────────────────────────────────────────────┘
            │               │
            └───────┬───────┘
                    ▼
            M18 quality gates ─▶ M20 staging + live proof ─▶ v1.0

The console, billing, domain-method, agent-key and notification milestones join the graph like this. Each also feeds M20:

M7, M8, M9, M10, M11, M12, M13, M14 ─▶ M21 console ─▶ M22 billing ─▶ M24 cloud sign-up and sign-in
   (the console's pages show what these build)        (M22 also needs M9 and M12)
M9 outbound ─▶ M13 domains ─▶ M23 domains on any DNS host   (S10, S11, S12 gate its methods)
M5, M6 ─▶ M25 agent signing keys, assertions and signed requests ─▶ M15 MCP (two signing tools)
                                                                  ─▶ M21 console (keys on the identity page)
   (S13 gates signed HTTP requests only)
M9, M10, M21, M22 (and M6's system identity) ─▶ M26 notifications and usage alerts

After M5, these tracks can run in parallel, each in its own branch and worktree:

  • Track 1: M6 → M7 → M9 → M13 (domains need the send path for fallback sends and transport/ses.rs);
  • Track 2: M8;
  • Track 3: M16 + M17;
  • Track 4: M19;
  • Track 5: M25, once M6 lands (it needs only M5 and M6).

Once M7 lands, M10 and M12 also run in parallel with M9. M23 follows M13 on the domains track. M21 starts only after M7–M14 and M25, because its screens (inboxes, search, quarantine, triage, domains, webhooks, erasure, identity keys) call their services; M22 starts after M9, M12 and M21, M24 after M21 and M22, and M26 after M9, M10, M21 and M22 (it sends through M6’s system identity and is fed by M8’s webhook dispatcher and M12’s triage, which come before M21). M15 also waits for M25, whose two signing tools it registers. No milestone depends on one that comes later in this graph. Tracks never edit the same files. Shared files (router.rs, wrangler.toml template, 0001_init.sql) are changed only by the track that owns them, as listed per milestone.


M0 · Repository skeleton and CI

Files: Cargo.toml (workspace, [workspace.dependencies] pinned per Rust workspace), rust-toolchain.toml, crates/{core,platform,api-types,worker,sdk,cli,conformance}/, xtask/, .github/workflows/ci.yml, deny.toml, .cargo/config.toml, migrations/d1/, deploy/wrangler.toml.tmpl.

Implements: the workspace and dependency rules in AGENTS.md, and the size check of NFR-SEC-2.

Human prerequisites

A coding agent cannot create these. A person provides each one before the milestone or spike that needs it, and stores the credential under the name in the last column. Local values go in the shell or in spikes/.env (git-ignored); CI values are GitHub Actions secrets on PILOTAAI/pylota-mail, in the environment named in brackets. Nothing in this table is ever committed.

ItemWho provides itNeeded bySecret or config name
A Cloudflare account on the Workers Paid planOwner (TREFT LTD)M1 (every spike except S10 and S13), M18 nightly evaluations, M20CLOUDFLARE_ACCOUNT_ID (local); PM_CF_ACCOUNT_ID (written by pmail setup); Actions: PM_EVAL_CF_ACCOUNT_ID, STAGING_CLOUDFLARE_ACCOUNT_ID [staging]
The pylotamail.com zone in that account (bought 2026-10-09, Cloud sign-up §2), plus a separate staging zone apexOwnerM1 (S2, S7, S9 use a scratch zone or the staging zone), M20PM_PLATFORM_DOMAIN, PM_API_HOST, PM_CONSOLE_HOST in deploy/wrangler.toml
The setup API token, with the permissions in Deploy › step 2OwnerM1, M20CLOUDFLARE_API_TOKEN (local, used by pmail and Wrangler); PM_CF_API_TOKEN (Worker secret, a separate token with the “Worker token” permissions of that table, stored by the operator with wrangler secret put, Deploy › Domains on Cloudflare); Actions: STAGING_CLOUDFLARE_API_TOKEN [staging]
A Workers AI API token for the nightly evaluationsOwnerM18Actions: PM_EVAL_CF_API_TOKEN (repository secret, Workers AI read only)
A Cloudflare Enterprise account (optional)Owner, through Cloudflare salesS10 onlyS10_CLOUDFLARE_ACCOUNT_ID, S10_CLOUDFLARE_API_TOKEN in spikes/.env. S10 may be skipped: without it delegated_subdomain stays off (PM_CF_SUBDOMAIN_SETUP=off) and the spike result says “skipped, no Enterprise account”
An AWS account with SES production access in eu-west-2 (London, decided 2026-10-09), on the à la carte planOwner; production access is requested in the AWS console and approved by AWS, which can take a dayS8, S11, then M23 and M20 step 5AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or AWS_PROFILE (local); PM_SES_ACCESS_KEY_ID and PM_SES_SECRET_ACCESS_KEY (Worker secrets, written by pmail setup ses); Actions: STAGING_AWS_ACCESS_KEY_ID, STAGING_AWS_SECRET_ACCESS_KEY [staging]
Two real SMTP submission providers (for example a Google Workspace mailbox and a Microsoft 365 mailbox), each with a sending account on a test domainOwnerS12S12_SMTP_A_HOST, S12_SMTP_A_USERNAME, S12_SMTP_A_PASSWORD, and the same for S12_SMTP_B_*, in spikes/.env
Gmail (Google Workspace) and Microsoft 365 test mailboxes holding only synthetic mail, with API access for the harnessOwnerM20 (the live suite)Actions: STAGING_GMAIL_CLIENT_ID, STAGING_GMAIL_CLIENT_SECRET, STAGING_GMAIL_REFRESH_TOKEN, STAGING_M365_TENANT_ID, STAGING_M365_CLIENT_ID, STAGING_M365_CLIENT_SECRET [staging]
A domain at an external DNS provider (not Cloudflare) and that provider’s API tokenOwnerM20 step 5 (live::domains::dns_records_external_host)Actions: STAGING_EXTERNAL_DNS_TOKEN, STAGING_EXTERNAL_DOMAIN [staging]
A staging platform key (90-day expiry)Created by the agent with pmail keys create on staging; stored by a personM20Actions: STAGING_PLATFORM_KEY [staging]
A Stripe account in test modeOwnerM22 (recorded fixtures), M20 step 11PM_STRIPE_SECRET_KEY (a sk_test_ restricted key) and PM_STRIPE_WEBHOOK_SECRET (Worker secrets on staging); Actions: STAGING_STRIPE_SECRET_KEY, STAGING_STRIPE_WEBHOOK_SECRET [staging]
A Google OAuth client and a GitHub OAuth app, with redirect URLs on the staging and production console hostsOwnerM24 (its gate re-reads both providers’ documentation), M20 step 10PM_OAUTH_GOOGLE_CLIENT_ID, PM_OAUTH_GITHUB_CLIENT_ID (variables); PM_OAUTH_GOOGLE_CLIENT_SECRET, PM_OAUTH_GITHUB_CLIENT_SECRET (Worker secrets)
The minisign release key pair, generated offline by a person (minisign -G)OwnerM19Secret key: Actions MINISIGN_SECRET_KEY and MINISIGN_PASSWORD [release]. Public key: compiled into pmail as the current key (CLI and setup §8.2); a second key pair becomes next before the first rotation
Optional: registration of the production deployment’s Web Bot Auth key directory (https://{PM_API_HOST}/.well-known/http-message-signatures-directory) with Cloudflare’s verified-bot programme (dashboard, “Bot Submission Form”, verification method “Request Signature”; Deploy › Signed HTTP requests)Owner, after M25 ships with S13 passed and PM_WEB_BOT_AUTH=onNo milestone or test: signatures verify for any Web Bot Auth verifier without it, and S13 expects the unregistered 401None (a dashboard form; nothing to store)
The GitHub repository PILOTAAI/pylota-mail (created 2026-10-09), with Actions enabled, the environments staging (one required reviewer, main and v* tags only) and release (v* tags only), and branch protection on mainOwnerM0Actions: CARGO_REGISTRY_TOKEN [release], for cargo publish; every other secret above

A milestone whose prerequisite is missing stops and reports which row is missing. It never substitutes a fake for a spike’s real provider.

Acceptance:

  • cargo test --workspace passes on an empty test per crate.
  • cargo xtask build-worker builds a “hello” Worker to wasm. The size check runs and passes, and from here on enforces NFR-SEC-2 on every pull request: the compressed bundle stays ≤ 10 MiB (xtask::size_budget).
  • CI runs these jobs: fmt, clippy, native tests, wasm build, cargo deny check, mdbook build docs.
  • A CI check fails if any crate other than platform depends on worker (cargo xtask check-layering).

M1 · Spikes (each one gates design choices)

Run against a scratch Cloudflare account and zone. Each spike is a small program under spikes/ (not shipped), plus a written result.

SpikeProvePass criteriaIf it fails
S1 Bindings smokeFrom Rust with worker 0.8.7, through the platform::export_worker! entry glue (which replaces #[event] and #[durable_object], see Rust workspace): receive an email event and read from, to, headers and the raw stream; send with the send_email binding’s structured send() including replyTo, headers (In-Reply-To, References, Auto-Submitted, X-*) and attachments (attachment and inline with contentId); produce and consume Queues with delay_seconds and retry_with_options, and confirm that Message::timestamp() is unchanged across retries; DO SQLite with the transactionSync extern (a thrown error rolls back) and alarms; D1 batch; a multi-statement request to the D1 query API (POST /accounts/{a}/d1/database/{id}/query); and the response of the local wrangler dev email endpoint to a setReject (Testing §6.4)Every call works from Rust. The returned messageId is captured. A rolled-back transaction leaves no rows. A multi-statement D1 query-API request is atomic: when its last statement fails, none of the earlier statements’ rows remainRaw MIME send (EmailMessage) built with mail-builder for any missing structured field. If the entry glue cannot replace the macros: an ADR allowing exactly one file, crates/worker/src/entry.rs, to use them (Rust workspace §2). If the D1 query API is not atomic: every migration file is made re-runnable and a CI lint enforces it (CLI and setup §8.5). transactionSync has no fallback; this is an accepted risk. The planned path is a wasm-bindgen call to ctx.storage.transactionSync, which Cloudflare documents with no restriction on the calling method beyond a SQLite-backed object (SQLite storage API, read 2026-10-09), so any JS method of the class, including the glue’s, may call it. If S1 shows otherwise, the build stops and an ADR is written before M4 continues. The owner accepted this risk on 2026-10-09
S2 Inbound failure semanticsWhat the sending MTA sees when email() throws, versus setReject (documented as a permanent error); which Authentication-Results headers reach the handlerThrowing yields a 4xx temporary failure and the sender retries. The exact SMTP reply text for both cases is recorded. Record which Authentication-Results authserv-id Cloudflare stamps on delivered mail (setup later writes it to PM_TRUSTED_AUTHSERV_ID, Inbound › Authentication verdict)Throw only. The handler keeps its in-handler R2 retries (three attempts) and then throws, as designed, whatever the sender is shown; the spike result records the observed reply in Inbound. There is no forward() to a backup address: setup registers no Email Routing destination address (Identities and domains › Cloudflare API token), so none exists to forward to
S3 FTS5 in DO SQLitecontent='', contentless_delete=1, the trigram tokenizer, bm25() with six column weights, DELETE FROM fts WHERE rowid = ?, and renaming an FTS5 table (for the index swap in Search)All work on the deployed runtime, not only on local workerdExternal-content table fts_docs (Data model); disable trigram and rely on reference normalisation plus semantic fallback; without rename, the swap rebuilds fts in place
S4 Wasm budgetBundle size, cold start, CPU time and peak memory when parsing and verifying a 25 MiB message and a 40 MB message from the SES source (N5), and mail-auth verdict parity on the corpusCompressed bundle ≤ 10 MiB (NFR-SEC-2), cold start under 1 s, both messages parsed under 128 MB peak and inside the CPU limit, DKIM and DMARC verdicts equal the reference implementation on every corpus messageWrite attachments to R2 before parsing bodies; move heavy features behind cargo features; switch the release profile to opt-level = "z"
S5 MCP over rmcpStreamable HTTP served from fetch using rmcp 3.5.1 protocol types, without a tokio runtime. Note: rmcp 3.5.1 declares tokio (features sync, macros, rt, time) as a non-optional dependency (crates.io metadata, read 2026-10-09)MCP Inspector and Claude Code connect, list tools and call one; the wasm build never starts a tokio runtime or timer, and the bundle stays inside the S4 budgetImplement the JSON-RPC types locally in worker; keep rmcp as a native dev-dependency for client tests. Taken in advance (ADR 0009): M1 still runs S5 against the local types to record the Inspector and Claude Code result
S6 Externs and jurisdictionVectorize upsert, query (namespace and metadata filter), deleteByIds, getByIds and describe() (the vector count); AI.run with the gateway option, including the bge-m3 output shape, the reranker’s score form and the agent model’s chat-completions schema (Search); AI.toMarkdown (including whether PDF output marks page boundaries) — all through wasm-bindgen externs; DO IDs from unique_id_with_jurisdiction("eu") stored as strings and re-addressed with id_from_stringEvery call works and each recorded shape matches the design, or the design is updated with the observed one. An EU object reports the EU jurisdiction (ctx.id.jurisdiction)REST fallbacks (/vectorize/v2/…, /ai/run, /ai/tomarkdown) using PM_CF_API_TOKEN, which then becomes required (Rust workspace §7); one page per document when toMarkdown does not mark pages. If an EU object does not report the EU jurisdiction, the build stops for an owner decision, because FR-PRV-1 depends on it
S7 Outbound Message-IDThe relationship between the messageId that send() returns and the Message-ID header recipients seeEither a deterministic mapping (strategy A), or the header learned from a journal copy (strategy B)Strategy B: a hidden journal BCC to journal+{message ulid}.{identity ulid}@{PM_PLATFORM_DOMAIN}; the email handler records the header and drops the copy (Outbound). If a journal copy never arrives, that message matches replies by thread token and provider ID only
S8 SES in wasmSigV4 signing for SES v2 SendEmail with raw content, and SNS message signature verification (SignatureVersion 2; version 1 is refused), from Rust in wasmA real send through SES in eu-west-2; a real SNS notification verified, and a tampered one rejectedSES leaves v1.0: an ADR moves send_only, dns_records, smtp_relay with inbound: ses and the SES failover to v1.1
S9 Event subscriptions and onboarding APIsCreate an Email Sending event subscription to pm-delivery-events through the API for one domain (source type email.sending with zone_id and domain; this source shape appears in Wrangler’s source, not yet in the API reference), receive all six event types, delete it. Onboard a zone apex and a subdomain through POST /zones/{zone_id}/email/sending/subdomains, and enable routing on a subdomain through POST /zones/{zone_id}/email/routing/dns with name. A literal routing rule whose worker action value is the script name pylota-mail delivers to the WorkerPayload fields match Outbound › Delivery events; subscriptions can be created per domain at runtime with PM_CF_API_TOKEN; apex and subdomain onboarding both work through the APIFor cloudflare_zone and nameservers, the API creates the domain without a subscription, marks it delivery_events: "manual", and returns its records with details.action = "run pmail domains subscribe <domain>"; delivery events start once that command has run (Identities and domains › Kind zone, test it::domains::s9_manual_delivery_events). Any onboarding step the API cannot do is listed by pmail domains add as a dashboard step and checked by pmail doctor
S10 Child zonesOn an Enterprise account, a subdomain-setup child zone accepts Email Routing catch-all to the Worker and Email Sending onboarding, and both work end to end. Optional: skipped when no Enterprise account is available (Build plan › Human prerequisites)Mail to any address at the child apex reaches email(); a send is DKIM-aligneddelegated_subdomain stays off; dns_records covers the case
S11 SES receivingRule set, S3 action and topic as specified; the notification shape, including the objectKey form; S3 GetObject with SigV4 from a Worker; a 39 MB message (N5); user+tag@ routing; the retired-address bounce; the backstop picks up a message whose push failedAll pass in eu-west-2dns_records and smtp_relay with inbound: ses do not ship in v1.0; send_only still does
S12 SMTP from a WorkerPorts 465 and 587 with StartTls against two real providers; the certificate host name is checked (a wrong-name certificate is refused); timeouts and the uncertain window behave as designedAll passsmtp_relay does not ship in v1.0
S13 Web Bot Auth formatA request signed by core::httpsig with the deployment key (Signature-Agent as a quoted structured-field string; Signature-Input covering @authority, signature-agent and from, with tag="web-bot-auth", keyid = the JWK thumbprint, created, expires and a 64-byte nonce), sent to https://crawltest.com/cdn-cgi/web-bot-auth, which answers 401 for a well-formed message with an unknown key, 200 for a known key that verifies and 400 otherwise (Web Bot Auth, read 2026-10-09). Needs no Cloudflare account401 before the key directory is registered (well-formed, unknown key), never 400Signed HTTP requests stay off in v1.0: PM_WEB_BOT_AUTH cannot be turned on (Agent signing keys). Agent assertions are unaffected

Gate: every spike has a written result. Design documents are updated where a fallback was taken.


M2 · Core logic (crates/core, no I/O)

Files: crates/core/src/{ids.rs, address.rs, thread_token.rs, thread.rs, reply.rs, mime/, sanitize.rs, text.rs, quote.rs, refs/, classify.rs, auth.rs, trust.rs, attach.rs, query/, fusion.rs, citations.rs, triage_rules.rs, policy.rs, dns.rs, domain_fsm.rs, injection.rs}, crates/conformance/corpus/, fuzz/.

Implements: the parsing and decision logic behind FR-ADR-6/7, FR-IN-3, 6, 7 and 9, FR-THR-1, FR-SRCH-3/4/8 (verifier), FR-TRI-2, FR-DOM-4/5 (the pure state machine).

Acceptance:

  • Unit tests for every core:: row in sections A–H of the edge-case register (section N’s are in M23): A1–A4, A12, B2, B4–B11, B13, C1, C2, C8, D1–D3, D6, D8, D9, E1, F1, F5, F9, F11, H2, H3, H7. (D10’s test is it::inbound::d10_token_bruteforce, which needs the mailbox’s rate_windows, so it belongs to M7.)
  • A property test for the query parser: every input parses or returns invalid_query, and every compiled FTS expression contains only quoted terms.
  • A property test for thread tokens: round-trip works, and any bit flip fails verification.
  • A conformance corpus of at least 300 messages, covering Gmail, Outlook, Apple Mail, Thunderbird, mailing lists, DSNs (RFC 3464), MDNs, calendar, TNEF, S/MIME, PGP, charsets and malformed input. Each message has an expected JSON output.
  • Fuzz targets mime_parse, query_parse, address_parse, sanitize and dsn_parse each run for 60 seconds in CI without a crash.
  • crates/core builds for wasm32-unknown-unknown.

M3 · API types and OpenAPI

Files: crates/api-types/src/{lib.rs, errors.rs, objects/*.rs, requests/*.rs, events/*.rs, openapi.rs}.

Implements: FR-API-1/2, plus the types for every object in REST API and events.

Acceptance:

  • cargo test -p pylota-mail-api-types generates openapi.json with utoipa, and a test compares it semantically (paths, methods, schemas, enums, required fields) with docs/src/reference/openapi.yaml. Any difference fails.
  • Every error code in Errors exists in the ErrorCode enum with its HTTP status and retryable flag, checked by a table test.
  • Serde round-trip tests for every object, using the examples from api.md.

M4 · Platform crate

Files: crates/platform/src/{lib.rs, clock.rs, rng.rs, d1.rs, durable.rs, r2.rs, queues.rs, ai.rs, vectorize.rs (extern), email.rs, ratelimit.rs, dns.rs (DoH), http.rs, fakes/}.

Implements: the trait set in Rust workspace and platform, with Cloudflare implementations and in-memory fakes for native tests.

Acceptance:

  • Each trait has a fake used by native tests in worker logic modules.
  • The DoH resolver parses the JSON answers from both configured resolvers. A test uses canned responses for TXT, MX, NS and CNAME, including NXDOMAIN and SERVFAIL.
  • Only this crate depends on worker, enforced by check-layering.

M5 · Worker base: routing, auth, tenants, keys

Files: crates/worker/src/{lib.rs, router.rs, auth.rs, errors.rs, ratelimit.rs, request_id.rs, keyring.rs, handlers/{meta.rs, tenants.rs, keys.rs, audit.rs}, db/{mod.rs, tenants.rs, keys.rs, audit.rs, idempotency.rs, signing_keys.rs}, quota/mod.rs}, crates/core/src/{keys.rs, crypto.rs} (key format and the sealing envelope, pure) (TenantQuota as a stub class: Init, the owner check and schema_version only; later milestones add its requests), migrations/d1/0001_init.sql (every D1 table and index in Data model, including those that later milestones use: the console, billing, sign-up and domain-method tables and columns), crates/worker/tests/ harness (cargo xtask itest).

Implements: FR-TEN-1/2/3, FR-KEY-1/2/3, NFR-SEC-1 (the cross-tenant suite), the error envelope, rate limits, request IDs, idempotency for non-mail POSTs, and the thread and link keyring (signing_keys, created on first use; Security).

Tenants without owner or billing behaviour. POST /v1/tenants accepts owner and billing as REST API specifies, validates them, and stores them: the owner’s users and members rows and the billing_accounts row. Nothing acts on them yet. The owner’s sign-in link is sent once M21 lands, and plan checks run once M22 lands; until then holds always succeed, as in billing mode disabled. Each tenant gets its TenantQuota object (quota_do_id, then QuotaRequest::Init).

Acceptance:

  • it::auth::*: unknown, expired and revoked keys; missing permission; key scope exceeded.
  • A cross-tenant suite skeleton: for every registered route, a key from another tenant gets an indistinguishable 404. The suite enumerates the router table, so a new route without a test fails.
  • it::keys::j6_revoke_rotate, with GET /v1/audit-events?actor_key_id=.
  • The keyring creates one key per purpose under concurrency and opens it with PM_MASTER_KEY (core::keys::format_round_trip, core::crypto::envelope_round_trip).
  • Idempotent POST /v1/tenants replays and conflicts; owner and billing are stored but send no mail and change no limit; the tenant’s TenantQuota answers Init and refuses a mismatched owner.
  • /health, /v1/me, /openapi.json and /.well-known/security.txt are served (the last as Security specifies, from PM_SECURITY_CONTACT).
  • J6 (it::keys::j6_revoke_rotate, above).
  • NFR-SEC-1: the cross-tenant suite (it::security::cross_tenant_matrix) finds 0 cross-tenant reads or writes. Every later milestone extends it with its routes, and it must stay at 0.

One migration until v1.0. 0001_init.sql holds every table until v1.0 is released. No later milestone adds a D1 migration: M6–M26 change code only. A milestone that finds a missing column or table fixes 0001_init.sql itself (no deployed database exists before M20) and updates Data model in the same pull request. Migrations 0002_… onwards start after v1.0, under the expand-then-contract rule (CLI and setup §8.5).

Owner of shared files from here: Track 1 owns router.rs and 0001_init.sql. Other tracks add routes through handlers/<area>.rs plus one registration line, reviewed by Track 1.


M6 · Identities, addresses, platform domain (Track 1)

Files: handlers/{identities.rs, addresses.rs, domains.rs (platform domain read only)}, db/{identities.rs, addresses.rs, domains.rs}, mailbox/mod.rs (IdentityMailbox shell with schema-on-wake), mailbox/outbox.rs (the transactional outbox, its dispatch alarm, the event_index writes and the pm-webhooks producer; Webhooks and events), crons/retire.rs, and the system identity’s mailbox minting in the every-minute cron.

Implements: FR-IDN-1–4, FR-ADR-1–7, FR-DOM-1 (platform), the outbox and event index, and the system identity (Identities and domains).

Acceptance: A5, A7 (pause part), A8, A11, A12, A13, A14, J9, plus promote, retire and rollback flows whose identity.* events reach the outbox, event_index and a pm-webhooks message (consumed once M8 lands); a crash between commit and dispatch repeats the dispatch, never loses it. The system identity is never listed and refuses tenant keys.


M7 · Inbound (Track 1)

Files: email.rs (handler), consumers/inbound.rs, mailbox/{ingest.rs, threads.rs, messages.rs, attachments.rs, schema/v1.sql}, handlers/{threads.rs, messages.rs, quarantine.rs, wait.rs}, consumers/index.rs (attachment text only at this stage).

Implements: FR-IN-1–9, FR-THR-1/2, NFR-REL-1/2, read APIs, quarantine and release, and wait (Inbound › The wait handler), which is P0 because quarantine rule 5 (E5) depends on its registrations.

Acceptance: A2, A6, A9, A10 (inbound part), B1 (documented), B3, B12, B14, C1, C3, C7 (inbound matching), D4, D5, D7, D9, D10, E4 (it::wait::e4_*), E5, J1, J2, J7, plus loopback L3, and every conf:: corpus case ingested end to end through workerd. NFR-REL-1: under the J1, J2 and J7 fault injections inbound_lost_total and inbound_raw_missing_total stay 0. NFR-REL-2: the inbound SLI counters of Observability §4 are emitted for accepted, staged and temporarily failed mail.


M8 · Webhooks (Track 2)

Files: handlers/webhooks.rs, consumers/webhooks.rs, webhooks/{sign.rs, client.rs, replay.rs, payloads.rs}, crons/outbox_sweep.rs, the SSRF guard crates/core/src/ssrf.rs (pure) and crates/worker/src/net.rs (guarded HTTP) (Webhooks, Security § 9).

Implements: FR-WH-1–5, NFR-REL-4.

Acceptance:

  • Signature vectors from the Standard Webhooks spec verify.
  • Rotation sends two signatures.
  • The SSRF table refuses loopback, RFC 1918, link-local, CGNAT, ::1, fc00::/7 and 169.254.169.254, and does not follow redirects (core::ssrf::refuses_private_ranges).
  • J4: a time-controlled harness checks the retry schedule.
  • Replay.
  • Auto-disable on 410 and on 100 consecutive failures.
  • NFR-REL-4: the retry schedule reaches 24 hours within its 13 attempts, and webhook_delivery_latency_ms and webhook_dead_total are emitted for the SLI.

M9 · Outbound and delivery (Track 1, after M7)

Files: handlers/send.rs, mailbox/{submit.rs, compose.rs, locks.rs, deliveries.rs, idempotency.rs}, crates/core/src/compose.rs (MIME composition, pure), transport/{mod.rs, cloudflare.rs, simulator.rs, loopback.rs}, consumers/{outbound.rs, delivery.rs}, quota/mod.rs (extends the M5 stub with Reserve, Release, RecordOutcome and RecordUsage), handlers/{suppressions.rs, links.rs} (signed links, GET /v1/links/{token}, with kid verification), db/suppressions.rs, mailbox/alarms.rs (the claim, dispatch, lock and reconciliation purposes; no cron is involved).

Implements: FR-OUT-1–12, FR-DLV-1–5, NFR-PERF-1/2.

Acceptance: A7, A8, A10, C2, C4, C6, D6 (exchange cap), E2, E3, E8, G1–G11 (G5 with signed links), K3, L1, L2, L4. Also: the simulator matrix drives every status, an uncertain send is reconciled by a later provider event, ?dry_run=true returns the recipient plan without storing anything, and cancel works only while a message is queued and unclaimed (it::send::cancel_queued, FR-OUT-11). NFR-PERF-1 (it::bench::send_api_p95) and NFR-PERF-2 (it::bench::queue_to_transport_p95) report their figures; CI warns above the targets.


M10 · Search (after M7)

Files: search/{mod.rs, keyword.rs, semantic.rs, hybrid.rs, rerank.rs, facets.rs, cursor.rs, tenant.rs, contacts.rs, related.rs}, mailbox/search.rs, consumers/index.rs (chunk, embed, upsert), handlers/{search.rs, contacts.rs}, crons/index_reconcile.rs.

Implements: FR-SRCH-1–7, 10 and 11 (index side), plus contacts and related; NFR-PERF-3/4/5.

Acceptance: F2 (it::auth::f2_permission, now that search exists), F3–F8, F14, F15. NFR-PERF-3: keyword p95 ≤ 200 ms on a 50,000-message synthetic mailbox in workerd (it::bench::keyword_p95, which reports the figure, with a CI warning threshold). NFR-PERF-4 (it::bench::hybrid_p95) and NFR-PERF-5 (it::bench::tenant_fanout_p95) report theirs the same way.


M11 · Agentic search (after M10)

Files: search/agentic/{mod.rs, planner.rs, tools.rs, judge.rs, answer.rs, sse.rs, prompts.rs}.

Implements: FR-SRCH-8/9, NFR-PERF-6.

Acceptance:

  • E1 (fenced), F10–F13.
  • Deterministic tests with a scripted fake model: plan, two searches, refine, answer, then a verifier removal.
  • An SSE stream test.
  • Budget enforcement by steps and by time, and the tenant daily cap through QuotaRequest::CountAgentic.
  • NFR-PERF-6: it::bench::agentic_p95 reports p95 and first-evidence time against 8 s and 1.5 s.

M12 · Triage (after M7)

Files: triage/{mod.rs, rules.rs, model.rs, schema.rs, prompts.rs}, consumers/index.rs (triage job).

Implements: FR-TRI-1–4.

Acceptance:

  • D8 and rule evaluation order.
  • Invalid model output ends failed and is never guessed.
  • message.triaged events.
  • The thread roll-up.

M13 · Domains (after M7 and M9; SES depends on S8)

Files: domains/{mod.rs, cloudflare_api.rs, ses_api.rs, ses_control.rs (SesControl DO, the SES token bucket), records.rs, monitor.rs (DomainMonitor DO), fallback.rs}, handlers/domains.rs (full, including PATCH /v1/domains/{domain_id} for the transport), transport/ses.rs, consumers/ses_events.rs (the POST /hooks/ses SNS endpoint), and CLI pmail domains subscribe.

Implements: FR-DOM-2–6.

Acceptance:

  • H1–H7, G7, and the API part of J5 (it::domains::transport_patch). J5’s live part, live::transport::j5_ses_failover, runs in M20.
  • The spike S9 fallback path, whatever S9’s result (it::domains::s9_manual_delivery_events, cli::domains::subscribe_manual), and the SES control-plane budget (it::ses::control_plane_rate).
  • it::domains::* with a DNS fake that can remove a record, add a conflicting record, or move the NS.
  • The fallback send carries sent_via_fallback and keeps the thread token.
  • Recovery leaves fallback threads pinned.

M23 · Domains on any DNS host (after M13 and M9; S11, S12 and S10 gate methods)

Files: crates/core/src/{connect.rs, smtp.rs, sns.rs} (and SES receipt parsing in ses.rs), handlers/domains.rs (methods, PATCH with smtp, probe), handlers/addresses.rs (test-forwarding), handlers/hooks_ses.rs (POST /hooks/ses/inbound), transport/smtp.rs, inbound/sources/{routing.rs, ses.rs}, consumers/inbound.rs (the SES source), crons/ses_backstop.rs, domains/monitor.rs (method health rows, retired-address rules, probe and forwarding tokens); no migration (the domains method columns, ses_ingest, addresses.ses_bounce_rule and addresses.forwarding are already in 0001_init.sql); CLI pmail setup ses, pmail domains add --method, pmail domains update --smtp-…, pmail domains probe and pmail addresses test-forwarding.

Implements: FR-DOM-7–12, Domains on any DNS host.

Acceptance:

  • N1–N30, with the tests named in the register (core::connect::method_matrix, core::sns::verify_v2_vectors, core::smtp::state_machine, core::dns::doubled_name_detected, it::ses::*, it::smtp::*, it::forwarding::*, cli::setup::ses_region_check and it::domains::{existing_mx_external, nameservers_dedicated_check, zone_expired, mx_wrong_region, zone_create_rate_limited, zone_hold, delegation_removed, ses_identity_limit}).
  • pmail setup ses is idempotent: it runs twice against a recorded AWS API fake with no duplicate resources, never deactivates an existing active rule set, and prints the IAM policy before applying it.
  • Every new error code and transport_unavailable reason in the design is returned by at least one test.
  • The cross-tenant suite covers /hooks/ses/inbound (no key) and the new routes.

Gate: each method ships only when its spike passed: S11 for dns_records, S12 for smtp_relay, S10 for delegated_subdomain (which also stays behind PM_CF_SUBDOMAIN_SETUP). smtp_relay with inbound: ses also needs S11; without it, smtp_relay ships with inbound: forward only. cloudflare_zone, nameservers and send_only do not wait for them. A method whose spike failed moves to v1.1 by ADR (PRD section 5).


M14 · Privacy (after M9, M10)

Files: jobs/{mod.rs (JobRunner DO), erasure.rs, retention.rs, export.rs, reembed.rs, reparse.rs, reindex.rs, backup.rs}, handlers/{erasure.rs, exports.rs, holds.rs}, crons/retention.rs.

Implements: FR-PRV-1–6, FR-IDN-4, NFR-PRV-1.

Acceptance: F6, I1–I7, J3, plus every erasure scope with receipt counts and empty probes (identity and tenant scope also delete identity_keys and write key_tombstones; with M25 this is O7), the optional backup copy (it::retention::backup_copy), and it::logs::i5_no_content_in_logs, which greps captured Worker logs for any test-message body string and any test address. NFR-PRV-1: in a time-controlled harness every erasure scope completes within 24 hours, and a step that keeps failing still produces a receipt (it::erasure::step_retry_and_fail).


M15 · MCP server (after M10, M11, M25)

Files: mcp/{mod.rs, transport.rs, tools.rs, schemas.rs, prompts.rs}.

Implements: FR-MCP-1 and the tool list in MCP reference, including the two signing tools of M25 (mail_sign_assertion, mail_sign_http_request).

Acceptance:

  • tools/list is filtered by permission.
  • Each tool’s call maps to its REST equivalent, checked by a table test.
  • Error mapping.
  • Revision 2026-07-28 has no sessions: the server never mints Mcp-Session-Id, and GET and DELETE on /mcp answer 405.
  • A recorded MCP Inspector session replays green.

M16 · Rust SDK and CLI (Track 3, from M5; commands land as their APIs land)

Files: crates/sdk/src/*, crates/cli/src/{main.rs, config.rs, output.rs, cloudflare/, commands/*}.

Implements: FR-SDK-1, FR-CLI-1, FR-OPS-1–3. The SDK’s verify_assertion and the CLI commands identity-keys, assertions and http-sign land with M25’s endpoints.

Acceptance:

  • SDK integration tests run against the workerd harness for every endpoint, and sdk::coverage::every_operation proves the SDK has one method per openapi.yaml operation (FR-SDK-1, Rust workspace §11).
  • pmail setup is idempotent: it runs twice against a recorded Cloudflare API fake with no duplicate resources.
  • pmail deploy verifies checksums and refuses a tampered bundle.
  • pmail doctor reports every check with a fix.
  • The landing-page CLI examples run as tests.

M17 · Observability and operations (Track 3)

Files: crates/worker/src/{log.rs, metrics.rs}, ops/alerts.rs, consumers/dlq.rs, crates/core/src/slo.rs (alert rules, pure), handlers/platform.rs (the platform API: GET /v1/platform/dlq, POST /v1/platform/dlq/{dlq_id}/redrive, POST /v1/platform/jobs, GET /v1/platform/jobs/{job_id}, POST /v1/platform/keys/{purpose}/rotate, all platform:ops), CLI dlq list|redrive. There is no internal-only handler: the CLI uses the public platform API.

Implements: FR-OPS-4, NFR-OPS-2, NFR-COST-1 and Observability.

Acceptance:

  • J8, J3 (job start through the API), it::secrets::signing_key_rotation, it::ops::provider_quota_80.
  • Log scrubbing (part of I5).
  • Every metric in the design is emitted by at least one test path.
  • Every SLO of Observability §4 is computed from emitted metrics (it::ops::slo_from_metrics): NFR-REL-1–4, NFR-PERF-1–6 and NFR-PRV-1.
  • NFR-OPS-2: it::ops::restore_rebuilds_ledger, and the restore runbook that live::ops::restore_drill runs in M20.
  • NFR-COST-1: the generated wrangler.toml declares no always-on compute (no Containers, no binding that bills while idle beyond storage), checked by xtask::template_no_idle_compute; the idle-cost review runs in M20.

M18 · Quality gates (after M10–M12)

Files: crates/conformance/golden/ (a generator for about 5,000 synthetic messages plus labelled queries), xtask eval-search, xtask eval-agentic, xtask eval-triage.

Implements: NFR-QUAL-1–3.

Acceptance: recall@10 ≥ 0.90 (hybrid), citation precision ≥ 0.98, and triage accuracy ≥ 0.85. These figures are measured on the golden set using real Workers AI models in a nightly CI job with an API token, and recorded in docs/src/project/quality.md (created by this milestone). CI fails on a regression of more than 1 point.


M19 · Site, docs and release pipeline (Track 4)

Files: site/ (exists), .github/workflows/release.yml, xtask release.

Acceptance:

  • mdbook build docs writes into site/public/docs, and the site Worker serves both. The landing page’s links to docs anchors resolve (link check in CI).
  • A tagged release produces pylota-mail-worker-<v>.tar.gz, CLI binaries for macOS (arm64, x64), Linux (x64, arm64) and Windows (x64), and signed SHA256SUMS.
  • pmail deploy --version <v> deploys that bundle.

M21 · Console and workspaces (after M7–M14 and M25, whose services its pages use)

Files: crates/worker/src/console/{mod.rs, router.rs, session.rs, signin.rs, csrf.rs, layout.rs (maud), pages/*.rs}, crates/worker/src/members/{mod.rs, invitations.rs, roles.rs}, handlers/members.rs. No migration: users, members, invitations, login_tokens and sessions are in 0001_init.sql.

Implements: FR-CON-1–7, NFR-CON-1, the console screens in Console design, including identity-key management on the identity page (Agent signing keys §6).

Acceptance:

  • Edge rows W9, W10, W15–W18.
  • Every console route works with JavaScript disabled, checked by a Playwright run with javaScriptEnabled: false.
  • An axe accessibility scan with no serious violations on every page.
  • NFR-CON-1: it::console::render_budget keeps server render time p95 ≤ 300 ms on every page.
  • Sign-in and invitation emails are sent through the system identity that setup creates (Identities and domains › The system identity), using the simulator in tests; invitations also work with PM_CONSOLE=off.

M22 · Plans, metering and billing (after M9, M12, M21)

Files: crates/worker/src/billing/{mod.rs, catalog.rs, quota.rs (TenantQuota allowances and holds), stripe.rs, webhook.rs, usage.rs}, handlers/{usage.rs, plans.rs, billing.rs} (GET /v1/usage and GET /v1/usage/daily), console pages plan.rs. No migration: billing_accounts and billing_events are in 0001_init.sql.

Implements: FR-BILL-1–12, NFR-BILL-1/2, the metering points in Billing design.

Acceptance:

  • Edge rows W1–W8, W11–W14, W19.
  • Every metered action is wired to a hold and a settlement, checked by a table test that lists each metering point. A new metered action without a row fails.
  • A Stripe test-mode run (CLI stripe trigger fixtures recorded as JSON) covers checkout completed, subscription updated, payment failed, and canceled.
  • GET /v1/usage matches the catalog and the TenantQuota state in property tests.
  • NFR-BILL-1: it::billing::w1_last_unit_race and the hold property tests allow 0 actions beyond a granted allowance. NFR-BILL-2: it::billing::w2_stripe_down_sends_ok fails no metered action while Stripe is unreachable.

M24 · Cloud sign-up and sign-in (after M21, M22)

Files: crates/worker/src/console/{signup.rs, oauth.rs, totp.rs, landing.rs, onboarding.rs, pages/overview.rs}, crates/core/src/totp.rs (RFC 6238 codes, pure; the console module only stores and checks them), handlers/platform.rs (POST /v1/platform/waitlist/invite), no migration (the users sign-in columns, oauth_identities, oauth_states, waitlist and tenants.{require_two_factor, onboarding_dismissed_at} are in 0001_init.sql), the host split for PM_CONSOLE_HOST in router.rs, and CLI pmail waitlist invite.

Implements: FR-CON-8–13, Cloud sign-up, sign-in and first run.

Acceptance:

  • Edge rows W20–W34, with the tests named in the register (it::oauth::*, it::signup::*, it::totp::*, it::landing::routing_table, it::checkout::*, it::abuse::free_ramp, it::console::delete_account_owner_required).
  • core::totp::rfc6238_vectors, it::signup::email_creates_account_only_on_use, it::onboarding::derived_steps and it::hosts::console_api_split.
  • The new pages pass the M21 checks: they work with JavaScript disabled and have no serious axe violations.
  • pmail secrets rotate-master re-seals users.totp_sealed and users.recovery_codes_sealed.

Gate: Google’s and GitHub’s endpoints and claim names are re-read from their current documentation and recorded in the design before the OAuth code is written (Cloud sign-up §4).


M25 · Agent signing keys, assertions and signed requests (after M5 and M6; S13 gates signed requests)

Files: crates/core/src/{jwk.rs, jwt.rs, httpsig.rs} (RFC 7638 and RFC 8037 thumbprints and JWS, RFC 9421 signature bases, pure), handlers/{identity_keys.rs, assertions.rs, http_signatures.rs, well_known.rs}, db/identity_keys.rs, keyring.rs (the web_bot_auth purpose: a 43-character thumbprint kid, public_jwk, the 7-day directory overlap), the RL_SIGN binding in the wrangler.toml template, crates/sdk/src/assertions.rs (verify_assertion), CLI pmail identity-keys list|create|rotate|revoke, pmail assertions create|verify and pmail http-sign; no migration (identity_keys, key_tombstones and the signing_keys columns are in 0001_init.sql). Route registrations go through Track 1 as usual.

Implements: FR-IDN-6–9 and edge rows O1–O13 (Agent signing keys and signed requests), and the cross-tenant suite’s new routes (NFR-SEC-1).

Tasks:

  1. Write the RFC vector tests first (core::jwk, core::jwt, core::httpsig), then the pure code.
  2. Identity keys: lazy creation on first sign, POST …/keys, rotation with the PM_IDENTITY_KEY_OVERLAP_DAYS overlap, revocation, the key_tombstones check at generation, and the three identity.key_* events through the identity’s mailbox (MailboxRequest::EmitEvent).
  3. The JWKS endpoint, with the pause and suspension kill switch.
  4. Assertions (identities:sign, RL_SIGN), never stored or logged; Idempotency-Key ignored. Each signature is counted through QuotaRequest::RecordUsage (usage:assertions, usage:http_signatures, flushed to usage_daily); if M25 lands before M9 adds that request, M9 wires the two calls.
  5. Signed HTTP requests and the signed directory behind PM_WEB_BOT_AUTH and tenant policy web_bot_auth.allowed; the web_bot_auth purpose of POST /v1/platform/keys/{purpose}/rotate.
  6. The SDK verifier and the CLI commands; the two MCP tools are added to M15’s table.

Acceptance:

  • Edge rows O1–O13, with the tests named in the register: core::httpsig::signature_base_rfc9421 (O10), it::identity_keys::{lazy_create_and_rotate, revoke_removes_from_jwks, paused_withdraws_jwks} (O2, O3, O1), it::assertions::claims_and_limits (O4–O6), it::secrets::rotate_master_reseals_identity_keys (O8: the existing re-seal sweep already covers identity_keys.private_enc and signing_keys.ciphertext), it::http_signatures::{disabled_and_policy, expiry_bounds} (O9, O13, O11) and it::well_known::directory_signed_per_key (O12). O7 (it::assertions::erasure_tombstones_kid) runs once M14 has landed too: M14’s identity- and tenant-scope erasure deletes the keys and writes key_tombstones.
  • core::jwk::thumbprint_rfc8037_vector, core::jwt::eddsa_rfc8037_vector and it::assertions::sdk_verifies (the SDK verifier accepts a fresh token and rejects a wrong audience, an expired token, an unknown kid and alg: none).
  • The cross-tenant suite covers the six new /v1 routes; another tenant’s key gets the same 404 as a missing identity, and the two /.well-known routes give the same 404 identity_not_found for unknown, paused and deleted identities.
  • No response, log line, event or idempotency record contains a private key, a seed, an assertion or a signature (it::logs::i5_no_content_in_logs is extended with them).

Gate: signed HTTP requests ship only when spike S13 passed. Otherwise PM_WEB_BOT_AUTH cannot be turned on (422 web_bot_auth_disabled, the directory 404), FR-IDN-8 moves to v1.1 by ADR, and the assertion half of the milestone ships unchanged.


M26 · Notifications and usage alerts (after M9, M10, M21 and M22; the system identity from M6)

Files: crates/worker/src/notify/{mod.rs, notifier.rs (the Notifier Durable Object), compose.rs, prefs.rs, unsubscribe.rs}, crates/core/src/notify.rs (windows, caps, schedules across time zones, and rendering that takes no mail content, pure), crates/worker/src/console/pages/notifications.rs (/console/settings/notifications and the unsubscribe pair), one hook each in consumers/webhooks.rs (M8’s dispatcher: NotifierRequest::Event) and quota/mod.rs (M22’s TenantQuota: NotifierRequest::UsageThreshold), the NOTIFY binding and the Notifier class in the wrangler.toml template and export_worker!, and tenants.notify_do_id minted with the tenant row; no migration (notification_prefs and the column are in 0001_init.sql). The hooks in other tracks’ files are reviewed by their owners.

Implements: FR-CON-14, FR-CON-15, FR-BILL-13 and edge rows O14–O26 (Notifications and usage alerts).

Tasks:

  1. core::notify first: coalescing windows, caps, the 09:00 schedule per time zone, and the content-free renderer, each unit-tested.
  2. The Notifier object (Init, pending, windows, sent, meta, one alarm) and its three inputs: the dispatcher hook for new_mail, UsageThreshold from TenantQuota (with the alerted:{feature}:{threshold}:{period} keys), and Account from the handlers that change security or billing state.
  3. Sending through the system identity with the notify: idempotency key; bounces and complaints set paused_reason; the platform-domain retry loop.
  4. The console settings page, the bounce banner and its confirmation, and the unsubscribe pair (no session, CSRF-exempt, served with PM_CONSOLE=off).
  5. Member removal and person and tenant erasure delete preferences and pending items.

Acceptance:

  • Edge rows O14–O26, with the tests named in the register (it::notify::*).
  • core::notify::no_content_in_body: a rendered notification contains no subject, sender, snippet or attachment name from the source message.
  • Notification emails go out through the system identity with the simulator in tests, as transactional sends carrying List-Unsubscribe and List-Unsubscribe-Post; a retried alarm never sends twice (the notify: idempotency key).
  • The new pages pass the M21 checks: they work with JavaScript disabled and have no serious axe violations. The unsubscribe pair works without a session and with PM_CONSOLE=off.
  • The cross-tenant suite covers the unsubscribe route: a token never changes another person’s or workspace’s preferences (O18).

M20 · Staging deploy and live proof

Implements: NFR-OPS-1 (the timed rehearsal, step 12) and the live measurements of NFR-REL-3, NFR-PERF-4, NFR-OPS-2 and NFR-COST-1 (step 13).

Deploy to staging with pmail setup and pmail deploy from the docs alone, as if you were a new self-hoster. Then run live::*:

  1. Inbound from Gmail and Outlook test mailboxes. The verdicts are correct, and HTML-only mail produces text.
  2. Outbound to both. Each reply threads correctly in the recipient’s client, and replies come back into the same thread.
  3. Bounce: a non-existent mailbox at a domain you control. Complaint: through the provider’s simulator if available, otherwise a manual test.
  4. A domain change: platform address, then zone subdomain, then zone apex, then rollback.
  5. A domain on an external DNS host with dns_records: publish the records at a DNS provider other than Cloudflare, wait for healthy, receive from Gmail through SES, and send with aligned DKIM and SPF.
  6. A domain failure: delete the DKIM record. After two checks the domain is failing, sends fall back, the operator is told. Restore the record and the domain recovers.
  7. Erasure of a counterparty with a held thread. The receipt is correct and the probes are empty.
  8. An MCP client (Claude Code) connects, searches and sends with an idempotency key.
  9. Console: sign in with a magic link, invite a second member, release a quarantined message, and see it in the audit log.
  10. Cloud sign-up with Google (PM_SIGNUP=open): a new account and workspace, the Overview with its first-run checklist, and Checkout from ?plan=developer.
  11. Billing in Stripe test mode: upgrade Free to Developer through Checkout, spend the send allowance to a 402, buy a top-up, and retry the same send successfully.
  12. NFR-OPS-1, a fresh-account rehearsal (live::ops::fresh_deploy_rehearsal): a person who did not build it deploys from self-hosting.md in under 15 minutes of hands-on time, timed and recorded.
  13. Measured on staging: NFR-REL-3 (live::slo::inbound_to_webhook), NFR-PERF-4 with the real models (the hybrid figure of it::bench::hybrid_p95 repeated against staging), NFR-OPS-2 (live::ops::restore_drill) and NFR-COST-1 (live::ops::idle_cost_review after a week of idling). NFR-REL-2 and NFR-REL-4 are read from the SLO dashboard over the live run.
  14. Agent keys: an assertion minted on staging verifies with pmail assertions verify against staging’s JWKS, and stops verifying within 5 minutes of pausing the identity. With S13 passed and PM_WEB_BOT_AUTH=on, a signed request to https://crawltest.com/cdn-cgi/web-bot-auth returns 401 (the directory is not registered on staging).
  15. Notifications: in Stripe test mode, sends cross 80% and an alert arrives once; a new_mail notification reaches the Gmail test mailbox with no content from the mail; Gmail’s one-click unsubscribe turns that kind off.

v1.0 release criteria: PRD §9.