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

Edge-case register

Every row is a required behaviour. The register has 213 rows in 15 sections (A–L, N, O and W). The 207 rows that Pylota Mail owns (S or S+I) each name a test; the 6 integrator-owned rows (C5, E6, E7, K1, K2 and K4) are tested in the integrator’s own suites. Section W was section M; it was renamed so that its rows (W1–W34) can never be mistaken for build-plan milestones (M0–M26). The Owner column says where the behaviour is enforced:

  • S: Pylota Mail;
  • I: the integrating application (for example Pylota). The service gives it what it needs, and the integrator’s own suites test it;
  • S+I: both.

Each Test cell names the planned test:

  • core:: tests are native unit tests in crates/core;
  • conf:: tests are corpus cases in crates/conformance;
  • cli:: tests are native tests in crates/cli, against recorded provider API fakes;
  • it:: tests are integration tests against a local workerd (cargo xtask itest);
  • live:: tests run against staging with real mailboxes.

Pull requests that change a behaviour here must update the row and its test in the same change.

A · Addresses and identity

#CaseRequired behaviourOwnerTest
A1Case and dots in the local partMatched case-insensitively and stored lower case. Dots are significant (no provider-style folding)Score::address::a1_case_and_dots
A2Plus sub-address bookings.acme+t03k.9f2mq7xa@Routes to the identity. The tag is a thread token only if its HMAC verifies; otherwise it is ignored and the message threads by headers. Tags never change the identityScore::thread_token::a2_*, it::inbound::a2_forged_token_ignored
A3Internationalised (SMTPUTF8) local partsCreation refused with address_unsupported. Unicode display names allowedScore::address::a3_smtputf8_refused
A4Reserved or confusable usernames: postmaster, abuse, security, support, sales, info, marketing, noreply, mailer-daemon, hostmaster, webmaster, homoglyphs (rn/m, Cyrillic а), mixed scriptsRefused with address_reserved. On the shared platform domain every RFC 2142 role name is reserved. Mail to the operational names (postmaster, abuse, security, hostmaster, webmaster, noc) routes to the operator’s PM_SECURITY_CONTACT (550 5.1.1 when it is unset); mail to the other role names (info, sales, support, marketing and the rest) is rejected 550 5.1.1, because no identity can own them. On a tenant’s own domain only postmaster and abuse are reserved, and their mail routes to the tenant’s owner contact; support, sales, info, marketing and the other role names are allowed there. noreply, mailer-daemon and the service’s own names are reserved everywhereScore::address::a4_reserved_and_confusable, core::address::a4_role_names_by_domain, it::inbound::a4_role_mail_routing
A5Reusing a deleted addressTombstoned permanently (keyed hash), across tenants. Only the original identity may reclaim it, and only while that identity existsSit::identities::a5_tombstone_blocks_reuse
A6Mail to unknown, retired, suspended-tenant or erased addressesUnknown: 550 5.1.1. Retired: 550 5.1.6. Suspended tenant: a temporary failure for up to 5 days (email() throws, because setReject only sends permanent errors; spike S2 records the reply the sender sees), then 550 5.2.1. Erased: 550 5.1.1, indistinguishable from unknown. Domains that receive through SES differ: N6, N7, and a suspended tenant’s SES mail is held for 5 days, then dropped without a bounceSit::inbound::a6_reject_codes, it::ses::suspended_tenant_held
A7Paused identity (manual, abuse, tenant)Inbound still stored. Outbound refused with identity_pausedS+Iit::send::a7_paused_refuses_send
A8Identity with no accountable humanCannot send (identity_owner_required)Sit::send::a8_owner_required
A9One message to two identities in the same tenant (To: bookings, Cc: compliance)One linked copy per identity, same raw_sha256. The delivered_to and is_primary_recipient fields let the integrator act only on the primary recipient’s copyS+Iit::inbound::a9_two_identities_two_copies
A10Identity BCC’d (envelope recipient not in headers)Delivered with flag bcc. reply-all never includes BCC recipients, and never exposes that the identity was BCC’dS+Iit::inbound::a10_bcc_copy_flagged, it::send::a10_reply_all_excludes_bcc
A11A second domain change while the first is still verifyingOne pending address per identity and domain. The newer request cancels the older pending oneSit::addresses::a11_newer_pending_replaces
A12Username and tenant suffix leave no room for a thread tokenRefused with local_part_too_long (combined maximum 40 characters)Score::address::a12_local_part_budget
A13Deleting an identity whose address is the Reply-To of in-flight threadsAddresses tombstoned. Later replies get 550 5.1.1Sit::identities::a13_delete_then_reply_rejected
A14Promoting a custom address away from the platform address, then trying to retire the platform addressThe platform address becomes an active alias, not retiring. Retiring or deleting it is refused with 409 address_in_use, because it is the fallback address for domain failures. Promoting it again rolls backSit::addresses::a14_platform_address_kept

B · Inbound content

#CaseRequired behaviourOwnerTest
B1Larger than 25 MiBRejected by Cloudflare before the Worker. Documented in limitsSlive::inbound::b1_oversize_rejected
B2Malformed MIME, missing boundaries, deep nestingRaw message kept. Best-effort parse with depth 32 and 500 parts. Flag parse_degraded. Never droppedSconf::mime::b2_* (corpus), core::mime::b2_caps
B3Missing Message-ID, or duplicate ID with a different bodyMissing: synthetic ID sha256(raw)@synthetic.invalid, flag set. Same ID and body: deduplicated. Same ID, different body: both kept, flag message_id_conflictSit::inbound::b3_*
B4HTML-only mailText derived from HTML. Sanitised HTML keptSconf::mime::b4_html_only
B5Charsets and encodings: Windows-1252, ISO-2022-JP, Shift-JIS, GB18030, encoded-word subjects, quoted-printable, base64 with bad paddingDecoded to UTF-8. Undecodable bytes replaced, flag parse_degradedSconf::mime::b5_*
B6TNEF winmail.dat; forwarded message/rfc822TNEF unpacked where possible (attachments extracted), else kept as an attachment. A forwarded message is parsed as nested, not merged into the outer threadSconf::mime::b6_*
B7Inline cid: images; remote imagesInline images kept with their Content-ID. Remote content is never fetched by the serviceSconf::mime::b7_cid, core::sanitize::b7_no_remote_fetch
B8Calendar invites; read-receipt requestsInvites become kind: calendar with a parsed summary and are never auto-accepted. Read receipts (MDNs) are never sentS+Iconf::mime::b8_ics, core::classify::b8_mdn_request_ignored
B9S/MIME or PGP encryptedStored, flag encrypted, body unavailable. Signed-only messages: content available. v1 stores the signature and never verifies it: auth_json.signature is {type: smime or pgp, status: present_unverified}Sconf::mime::b9_*
B10Executables, macro documents, encrypted archives, archive bombs, misleading extensionsrisk set and message quarantined (risky_attachment). The sniffed type wins over the declared type and the extension. Never passed to agents or extractionScore::attach::b10_*
B11Hidden text: zero-width characters, white-on-white, display:none, tiny fonts, HTML commentsStripped from agent-facing text (extracted_text, snippets, triage input), flag hidden_text, risk flag hidden_textScore::sanitize::b11_*
B12Attachment text extraction fails or times outThe attachment stays fetchable with text_status: unavailable. Search reports attachment_text_unavailable in why when relevantSit::index::b12_extraction_failure
B13Mail with no From, or several From addressesStored. from is the first parseable mailbox, flag parse_degraded. Several From addresses lower the trust verdictSconf::mime::b13_from_anomalies
B14Duplicate delivery of the same raw message (sender retry after a timeout)Deduplicated on raw_sha256 within the identity. No second eventSit::inbound::b14_redelivery_deduped

C · Threading

#CaseRequired behaviourOwnerTest
C1Reply with no In-Reply-To or ReferencesJoined by a valid thread token, else a new thread. The subject alone never joinsScore::thread::c1_*
C2More than 100 References entriesOur replies keep the first plus the 19 most recent. We always use send(), never message.reply(), so its 100-entry limit never appliesScore::thread::c2_trim_references
C3Reply to an old thread after the address movedAccepted through the retiring alias. We reply from the address the sender wrote to until it retires, then from the primarySit::addresses::c3_reply_from_retiring
C4Two sends into the same thread at oncePer-thread lock in the mailbox. The second waits up to 10 s, then gets thread_busyS+Iit::send::c4_thread_lock
C5New mail arrives while an integrator’s draft awaits approvalThe integrator marks the draft stale and re-validates. The service exposes thread.last_inbound_at and sequenceIintegrator
C6Hand-off between identities (bookings → compliance)forward keeps References and adds a transfer note. Or the integrator replies from the new identity in a new thread with an explicit noteS+Iit::send::c6_forward_keeps_refs
C7Outbound Message-ID is set by Cloudflare, not by usWe store the provider message ID and learn the header form (spike S7). Replies match by thread token first, then by header ID or provider IDSit::thread::c7_reply_to_cloudflare_message_id, live::thread::c7
C8Subject changed mid-thread, or Re:/AW:/SV:/Fwd: prefixesThread membership is unaffected. The normalised subject strips localised prefixes for display onlyScore::thread::c8_prefixes

D · Authentication, spoofing and abuse

#CaseRequired behaviourOwnerTest
D1The sender’s domain has no DMARC, or p=noneNo DMARC record: verdict none, with DKIM and SPF alignment recorded separately in auth_json (dmarc.aligned_by). p=none that fails alignment: unaligned. Never quarantined for that alone. Integrators refuse automations that need authenticity (payments, PCNs) unless the verdict is passS+Icore::auth::d1_*
D2Display-name spoofing; look-alike domainsFlags display_name_spoof / lookalike_domain (confusable skeleton compared against known contacts and the tenant’s own domains). Trust shows the real address and known_senderScore::trust::d2_*
D3Reply-To differs from FromFor unknown senders, replies go to From. Reply-To is used only when the sender is known, or it shares the organisational domain, or the identity has written to it. Flag reply_to_mismatchScore::reply::d3_reply_target
D4Backscatter: bounces for mail we never sentA DSN that matches no sent message is dropped and counted (backscatter_total)Sit::inbound::d4_backscatter_dropped
D5Inbound flood from one senderPer-sender limit per identity (default 60 per hour). The excess is stored throttled, hidden from agents, counted, alertedSit::inbound::d5_sender_throttle
D6Agent-to-agent ping-pong, auto-replies, out-of-office, mailing listsRFC 3834 classification plus an X-Pylota-Mail-Hop counter. Auto-replies to automated mail are refused. Automatic exchanges per thread are capped (default 2)S+Icore::classify::d6_*, it::send::d6_exchange_cap
D7Mail from a suppressed or receive-blocked addressStored hidden for audit, never shown to agents, never auto-replied toSit::inbound::d7_blocked_hidden
D8A request to change bank details or pay urgentlyRisk flag payment_change_request. The service never acts; the integrator requires human approvalS+Icore::triage_rules::d8_payment_change
D9Authentication-Results header forged by the senderOnly the authserv-id in PM_TRUSTED_AUTHSERV_ID is trusted, and only the topmost instance. Our own mail-auth result always runsScore::auth::d9_forged_ar_ignored
D10Thread-token brute forceTokens are 40-bit HMACs. Failed verifications are rate-limited per sender and flagged thread_join_unverified. A token never grants access to dataSit::inbound::d10_token_bruteforce

E · Agent behaviour and safety

#CaseRequired behaviourOwnerTest
E1Prompt injection in the body, subject, display name, filename or attachment textContent is delivered as untrusted with trust metadata. Triage and the agentic planner receive it fenced. Risk flag prompt_injection_suspected from heuristics and the model. Sends go through the integrator’s approvalsS+Icore::injection::e1_*, it::agentic::e1_fenced, it::triage::e1_fenced
E2Mail asks the agent to send data to a new addressWith send_policy.require_known_recipient, a recipient with no contacts history is not sent to: the send is accepted and that recipient’s delivery is suppressed (policy: unknown_recipient), never an error. The integrator gates exfiltrationS+Iit::send::e2_require_known_recipient
E3Bulk or many-recipient sendsmax_recipients (default 10, maximum 49: Cloudflare’s 50 less the journal copy). Per-identity and tenant daily capsSit::send::e3_caps
E4Waiting for a verification codewait long-polls with a timeout and an expected-sender filter. Codes are released only for authenticated mail from the expected domainSit::wait::e4_*
E5Reset or OTP mail nobody asked forQuarantined (otp_unsolicited) when no wait for that sender domain was active in the previous 30 minutesSit::inbound::e5_unsolicited_otp
E6A human takes over mid-threadThe integrator pauses the thread. The service offers labels and identity pauseIintegrator
E7Approval expiryIntegrator-side. The service’s cancel covers queued mailIintegrator
E8AI disclosureTenant policy adds a footer or header. The integrator’s disclosure rules stay authoritativeS+Iit::send::e8_disclosure_footer
#CaseRequired behaviourOwnerTest
F1Query syntax injection (FTS5 operators, quotes, NEAR, column filters)Parsed into a typed tree. Every term is quoted for FTS5. Raw input never reaches MATCHScore::query::f1_* (property tests)
F2A renter- or customer-facing agent tries to searchOnly keys with search:read can search. Integrators never give such keys to public-facing agentsS+Iit::auth::f2_permission
F3An identity key asks for tenant scope403 scope_denied. Scope comes from the key, never from the bodySit::search::f3_tenant_scope_denied
F4The semantic index is behindsemantic_coverage is reported. Keyword search is never behindSit::search::f4_coverage
F5Typos, partial words, plates with or without spacesReference normalisation (AB12CDE = AB12 CDE). Trigram fallback when keyword hits are fewer than 3. Semantic fallback in hybrid modeScore::refs::f5_*, it::search::f5_trigram
F6Search after an erasureFTS rows, refs and vectors deleted together. A probe query returns nothing (recorded in the receipt)Sit::erasure::f6_probe_empty
F7Quarantined mail in resultsExcluded unless include_quarantined and quarantine:reviewSit::search::f7_quarantine_hidden
F8Huge result sets; context overflowlimit ≤ 50, snippet_chars, group_by=thread, a 256 KB cap that sets truncatedSit::search::f8_budget
F9Date filters across time zonesFilters resolve in the tenant time zone to UTC. Results show UTCScore::query::f9_timezone
F10Mail tries to steer the agentic plannerThe planner sees fenced snippets only, and its tools are read-only. Caller scope and filters cannot be widened. Steering attempts are flagged in the traceSit::agentic::f10_steering
F11An agentic answer cites a message that does not support itThe citation verifier removes the sentence and records it in the traceScore::citations::f11_*
F12Agentic budget exhausted, or the model is downbudget_exhausted with the evidence so far, or degraded hybrid results. Never a fabricated answerSit::agentic::f12_*
F13A question the mailbox cannot answerinsufficient_evidence, listing what was searchedSit::agentic::f13_insufficient
F14A Vectorize write fails or lagsRetried from the queue. Coverage reflects it. A nightly reconciliation compares chunk countsSit::index::f14_retry_and_reconcile
F15A tenant search where one identity’s mailbox is slow or unavailablePartial results with partial: true and failed_identities[] after a 900 ms per-identity deadlineSit::search::f15_partial

G · Outbound and delivery

#CaseRequired behaviourOwnerTest
G1Idempotency key reused with different content409 idempotency_conflict. Same content returns the original with deduplicated: trueSit::send::g1_*
G2Transport timeoutuncertain, never resent. Reconciled from provider events when possible. resolve lets a human decideS+Iit::send::g2_timeout_uncertain (simulator timeout@)
G3Provider quota exhausted or rate-limitedDefinitely not sent, so the queue backs off and retries for up to 24 h, then failed: quota_exhausted. Cloudflare does not expose the daily quota, so the 80% alert needs PM_DAILY_SEND_QUOTA; without it the alert fires on the first quota errorSit::send::g3_quota_backoff, it::ops::provider_quota_80
G4One suppressed recipient in a multi-recipient sendFiltered before sending. The rest are delivered, with per-recipient outcomes. When the provider’s own suppression rejects the send, we sync its list and resend to the others. This is safe because the rejection is definitiveSit::send::g4_partial_suppression
G5Attachments that make the composed message larger than 5 MiB less 8 KiB (5,234,688 bytes). Base64 with 76-character lines grows each attachment by about 37%, so the limit is about 3.6 MiB of attachment bytes, less the body413 message_too_large by default. Signed expiring links with large_attachments: linkSit::send::g5_large_attachment
G6Hard bounce, soft bounce, complaint, late bounceHard: suppression. Soft: provider retries, then bounced (soft). Complaint: permanent suppression and rate tracking, auto-pause at threshold. Late events match by provider message IDS+Iit::delivery::g6_*
G7Sending from a retiring, pending or failing domainRetiring: only on threads already using it. Pending: domain_not_ready. Failing: fallback to the platform address (or failed if fallback is off)Sit::send::g7_domain_states
G8Delivery event arrives before the send ledger records the provider IDDelivery consumer retries with a 30 s delay up to 10 times, then parks the event as orphaned and counts itSit::delivery::g8_race
G9Marketing vs transactionalEvery message is typed. Marketing needs consent, RFC 8058 headers and a visible linkS+Iit::send::g9_marketing_requirements
G10Provider rejects a header or content (E_HEADER_*, validation)rejected with provider_validation. Never retriedSit::send::g10_provider_validation
G11Send to an address on the same deploymentGoes out through the transport and back in through Email Routing like any other mail. Test tenants use loopback injectionSit::send::g11_loopback

H · Domains and DNS

#CaseRequired behaviourOwnerTest
H1A record removed after verificationTwo resolvers, two consecutive checks, then failing. Sending switches to the platform address with thread continuity. Exact fix sent, with remindersS+Iit::domains::h1_failing_fallback (DNS fake)
H2SPF near the 10-lookup limit, where the record Pylota Mail needs must be merged with an existing one: the zone apex (inbound = routing), and the custom MAIL FROM name pm-bounce.{domain} of external domains (dns_records, send_only)Preflight counts lookups and refuses with 400 spf_lookup_limit and guidance rather than publish an SPF that failsScore::dns::h2_spf_lookup_count, it::ses::h2_mail_from_spf_preflight
H3Strict DMARC alignment (adkim=s, aspf=s)Preflight checks the alignment tags against the transport’s DKIM domainScore::dns::h3_strict_alignment
H4The domain expires or changes handsWeekly NS and RDAP check. A change suspends the domain until ownership is re-provedSit::domains::h4_ownership_change
H5A conflicting MX or SPF at the apex (existing mail provider)Adding a zone apex domain with existing MX records refuses unless "replace_mx": true, and warns that existing mail would stop. For dns_records, see N9Sit::domains::h5_existing_mx
H6Literal routing rule creation fails (subdomain domain)The address stays pending with reason routing_rule_failed, retried with backoff. It is never marked active without its ruleSit::domains::h6_rule_failure
H7A single resolver is down or liesOne resolver’s error or disagreement never changes state. It records error and retriesScore::domain_fsm::h7_resolver_disagreement
H8A tenant or partner key adds a cloudflare_zone domain (or uses replace_mx) on a zone of the deployment’s Cloudflare account that its tenant does not own: another tenant’s zone, the zone of the platform domain, API host or console host, or any unassigned zone; or a nameservers or delegated_subdomain name inside one of the first two403 scope_denied, details.reason = "zone_not_allowed", before any Cloudflare call or write, with the same body whether or not the zone exists. Allowed only for a zone this deployment created for the tenant (zone_claims, written by nameservers and delegated_subdomain) or one in its platform-only policy domains.cloudflare_zones, which grants names strictly under the listed zone, never its apex or replace_mx there; a zone claimed by another tenant is refused even when listed. Platform keys may use any zoneSit::domains::h8_zone_permission, it::security::cross_tenant_matrix (foreign_zone)

I · Privacy, retention and erasure

#CaseRequired behaviourOwnerTest
I1Counterparty erasureEvery message to or from the address across the tenant’s identities: rows, attachments, extracted text, FTS, refs, vectors, raw R2 objects, sent copies, outbox events. Receipt with counts and probe resultsS+Iit::erasure::i1_counterparty
I2Legal holdHeld threads survive retention and erasure. The receipt lists each held item and its reasonS+Iit::erasure::i2_hold
I3Subject-access requestExport of every message to or from the address as .eml plus JSONS+Iit::export::i3_counterparty
I4Retention expiryRaw MIME purged at raw_days. Messages purged at message_days if set. Each purge is audit-loggedSit::retention::i4_*
I5Mail content in webhooks, dead-letter queues and logsEvents are thin. Dead-letter queues hold pointers only (14-day retention). Logs never hold bodies or clear addresses (a log-scrubbing test greps captured logs)Sit::logs::i5_no_content_in_logs
I6Backups after an erasureR2 has no versioning or replication. The optional backup bucket (PM_BACKUP_BUCKET, off by default) is purged in the same erasure step as the source. The 30-day point-in-time recovery for D1 and Durable Objects is documented as residual retention, and erasures are re-applied after a restoreSdocs + it::erasure::i6_backup_purge
I7Suppressions after counterparty erasureKept as a keyed hash and masked hint, to honour the objection to contact. DocumentedSit::erasure::i7_suppression_kept_hashed
I8A partner key writes to a tenant while it is being erased, or after it is erased (creates an identity, sends, adds a domain, mints a key, changes its status or policy), or requests its erasure againEvery write from a non-platform key, apart from a tenant-scope erasure request, gets 404 tenant_not_found (or the resource’s *_not_found); the tenant’s own tenant and identity keys are revoked by the erasure and get 401 key_revoked. The partner key can still read the tenant and its erasure requests with the receipt. Only the erasure job changes the tenant’s status (a platform PATCH with status gets 409 tenant_erased). A second tenant-scope erasure returns the existing request (200, same era_) while erasing, and 409 tenant_erased once erased. Tenant erasure also deletes the idempotency records whose stored response belongs to the tenantSit::erasure::i8_erasing_tenant_frozen

J · Operations and failure

#CaseRequired behaviourOwnerTest
J1R2 write fails inside email()Never accept mail without a durable copy. Two retries, then throw, so the sender gets a temporary failure (confirmed by spike S2)Sit::inbound::j1_r2_failure (fault injection)
J2Durable Object evicted or reset mid-writeWrites are transactional, the queue retries, ingest is idempotent on raw_sha256Sit::inbound::j2_retry_idempotent
J3A parser bug is foundA reparse job, started with POST /v1/platform/jobs (platform:ops), re-parses from raw with the new parser_version. Events are re-emitted with reprocessed: trueSit::jobs::j3_reparse
J4The integrator’s webhook endpoint is downRetries for about 72 h, then dead, and replayable while the event is younger than the tenant’s events_days (default 30 days)Sit::webhooks::j4_retry_schedule
J5Cloudflare Email Sending outageThe runbook switches affected domains to transport: ses with PATCH /v1/domains/{domain_id} (platform key). They are pre-verified with SES: when SES is configured, cloudflare_zone, nameservers and delegated_subdomain onboarding creates the domain’s SES identity and publishes its three DKIM CNAMEs through the Cloudflare DNS API; while the transport is cloudflare those records are informational and never change the domain’s state. Each transport’s DKIM alignment is documented (with SES, DKIM aligns and SPF does not, because no custom MAIL FROM is set up)Sit::domains::transport_patch, live::transport::j5_ses_failover
J6Compromised API keyRevoke immediately, or rotate with overlap. The audit trail shows the key’s actionsSit::keys::j6_revoke_rotate
J7The D1 directory lookup fails transiently in email()Accept to inbound-staging/, queue a pointer with the envelope, and route in the consumer. Never reject for our own outageSit::inbound::j7_d1_transient
J8A dead-letter queue receives messagesThe dead-letter consumer records each item in dlq_items, emits a metric, and alerts after 15 minutes non-empty. GET /v1/platform/dlq and POST /v1/platform/dlq/{dlq_id}/redrive (CLI pmail dlq list and redrive) list and redriveSit::ops::j8_dlq_consumer, cli::dlq::j8_list_redrive
J9Deploy with a new Durable Object schema while old instances are liveMigrations are idempotent and run on wake, inside a transaction, guarded by schema_versionSit::mailbox::j9_migration_on_wake
J10A partner key addresses a tenant another partner’s key created, a tenant no partner created, anything inside one (identity, message, domain, key, webhook endpoint), or another partner’s endpoints or keysThe same 404 …_not_found as for a missing ID, with no side effect. A partner key reaches only the tenants its own partner’s keys createdSit::security::cross_tenant_matrix, it::partners::j10_foreign_partner_not_found
J11A partner key tries to mint a partner or platform key, or a key holding platform:ops, partners:manage or identities:signA partner or platform key, or a key for a tenant outside its partner: 403 key_scope_exceeded. A permission partner keys can never hold: 400 invalid_request with details.reason = "permission_not_allowed_for_level". Only a platform key mints, rotates or revokes partner keysSit::keys::j11_partner_key_limits
J12A partner is deleted while it still has tenants409 partner_has_tenants while any tenant with its partner_id is not erased, and nothing changes. Once every one is erased, the deletion is soft: the partner stays with status: "deleted" and an empty name, its keys are revoked and deleted, its endpoints are deleted, and the erased tenants keep their partner_idSit::partners::j12_delete_with_tenants
J13A partner is suspendedIts keys, and every tenant and identity key of its tenants, get 403 partner_suspended on every route, so nothing can send for those tenants. The tenants’ status does not change and their inbound mail is still stored. Deliveries to the partner’s endpoints and its tenants’ endpoints are held, with no attempt used, and resume when the partner is active againSit::partners::j13_suspended_partner, it::webhooks::j13_held_while_partner_suspended
J14A self-serve tenant’s key tries to set quarantine.key_release on its own tenant403 permission_denied: PATCH /v1/tenants/{tenant_id} needs tenants:manage, which a tenant key can never hold. The policy is unchanged, and the console shows it read-only. Only a platform key, or the tenant’s own partner key, can set itSit::quarantine::j14_key_release_policy
J15A partner’s webhook endpoint, and events of another partner’s tenants or of a tenant no partner createdNever delivered, by fan-out or by replay: a partner endpoint matches only events of tenants with its partner_id, and replay selects on event_index.partner_id. webhook.disabled for an endpoint of a partner (a partner endpoint, or a tenant endpoint of one of its tenants) goes to that partner’s other endpoints and to platform endpoints, never to tenant endpointsSit::webhooks::j15_partner_scope_filter
J16A key with quarantine:review releases held mail where PM_QUARANTINE_KEY_RELEASE=off (Pylota Mail Cloud)Allowed only on a tenant whose policy has quarantine.key_release: true, its partner key included; on any other tenant every key gets 403 permission_denied and a person releases in the console. Each release is audit-logged with its keySit::quarantine::j16_key_release_override
J17A partner tries to undo what the platform operator enforced on one of its tenants: lifts a platform suspension, raises a lower-only policy field above the platform’s value or the deployment default, raises it one identity at a time through send_policy.daily_cap, or resumes an identity paused for abuse_threshold403 scope_denied (details.field for the field or status); nothing changes. tenants.suspended_by records who suspended; a platform key’s value on a lower-only field becomes that field’s ceiling (tenants.policy_ceilings_json), so the effective ceiling is min(deployment default, platform ceiling); an abuse_threshold pause on a partner’s tenant is resumed only by a platform keySit::partners::j17_operator_enforcement, it::partners::policy_caps_lower_only
J18A partner key creates tenants, or sends invitations, without limitAt most partners.max_tenants tenants that are not erased (default 25, platform-set): the next creation gets 403 partner_tenant_limit, checked in the insert so concurrent creations cannot overshoot. Tenant creations and invitations together are limited to 10 a minute per partner across all its keys (RL_PARTNER, 429 rate_limited)Sit::partners::j18_partner_limits
J19A request is retried with the same Idempotency-Key by another key, or the original response carried a one-time secretThe record is keyed by the calling key: another key gets no replay. A response carrying a secret (POST /v1/keys, key rotation, webhook create, webhook secret rotation) is stored without it, and a replay returns the stored body with "secret_replayed": false; the secret exists only in the first responseSit::idempotency::j19_per_key_no_secret

K · Integration and cutover (integrator side)

#CaseRequired behaviourOwnerTest
K1Webhook redeliveredDeduplicate on webhook-id. Process in a durable job, so a downstream failure never re-runs the agent turnIintegrator
K2Autonomy paused or conversation taken overThe integrator stops sends. The service keeps receivingIintegrator
K3A send fails after approvalmessage.failed / rejected carry a readable reason. The integrator allows a retry with a new keyS+Iit::send::k3_failure_reason
K4Mixed providers during migrationEach tenant is bound to one provider. No thread crosses providersIintegrator

L · Test mode

#CaseRequired behaviourOwnerTest
L1A test tenant sends to a real external addressRefused with test_mode_recipientSit::testmode::l1_refuse_external
L2A test tenant sends to *@simulator.invalidScripted outcomes: delivered@, bounce@, softbounce@, complaint@, deferred@, reject@ and timeout@ (which produces uncertain)Sit::testmode::l2_simulator_matrix
L3A test tenant sends to an identity on the same deploymentDelivered by loopback injection into the inbound pipeline, with verdict: pass and flag loopbackSit::testmode::l3_loopback
L4A live key used on a test tenant, or the reverseImpossible: a key’s mode follows its tenant. Platform keys, and partner keys on their own tenants, act on both and are loggedSit::testmode::l4_mode_binding

N · Domains on any DNS host

#CaseRequired behaviourOwnerTest
N1A forged or invalid SNS message on /hooks/ses or /hooks/ses/inbound: bad signature, SignatureVersion 1, a SigningCertURL off sns.{PM_SES_REGION}.amazonaws.com, another topic, or a stale Timestamp403 invalid_signature, ses_sns_rejected_total incremented, nothing enqueuedScore::sns::verify_v2_vectors, it::ses::invalid_signature_403
N2A SubscriptionConfirmation for another topicIgnored: never confirmedScore::sns::verify_v2_vectors, it::ses::invalid_signature_403
N3The same SES notification arrives by push, from the SQS backstop, or bothThe ses_ingest ledger admits it once per object and recipient; the repeat is a no-op. A row whose pointer was never enqueued is re-sent by the backstop cron after 15 minutes, and the consumer skips a pointer whose row is no longer queuedSit::ses::push_and_backstop_once, it::ses::stuck_queued_row_resent
N4The S3 object is gone before it is ingestedLedger row lost, ses_object_lost_total incremented, the ses_object_lost alert pagesSit::ses::object_lost
N5An SES message of up to 40 MBAccepted; the parser’s part and depth caps still applySit::ses::large_message_40mb
N6Mail to an unknown address on an SES domainDropped without a bounce (no backscatter); inbound_dropped_total{reason="unknown_recipient", source="ses"}Sit::ses::unknown_recipient_dropped
N7Mail to a retired address on an SES domainSES bounces it with 550 5.1.6 through a pm-retired-{n} receipt ruleSit::ses::retired_rule_sync
N8The domain’s MX points at another region’s SES inbound hostHealth issue mx_wrong_region (fail)Sit::domains::mx_wrong_region
N9An existing or extra MX at a dns_records domain (split mail)Without "replace_mx": true, 409 existing_mx. With it, created, and mx_unexpected (degraded) until the other MX records are goneSit::domains::existing_mx_external
N10SES DKIM verification fails for a domain, or SES sending is paused for the accountses_dkim_failed takes the domain to failing; a pause raises the ses_sending_paused platform alert. Either way sends fall back to the platform addressSit::ses::dkim_failed_or_paused
N11The MAIL FROM MX (pm-bounce.{domain}) is missingSES uses its default MAIL FROM; DKIM still aligns, so the domain is degraded with mail_from_failed, not failingSit::ses::mail_from_mx_missing
N12A send_only address whose forwarding rule is not set up yetThe address’s forwarding is unverified until a test or real message arrives through forwarding. test-forwarding sets it to ok or failedSit::forwarding::test_forwarding
N13A forwarding loop: an agent writes to its own external address, which forwards back to its platform addressCaught by loop detection: the X-Pylota-Mail-Hop counter and the automatic-exchange cap (D6); never an endless exchangeSit::forwarding::loop_capped
N14The SMTP relay answers 535 to AUTHAt create or PATCH: 422 smtp_auth_failed, nothing stored. On a send: rejected (sender_domain_unavailable), health issue smtp_auth_failed (fail), and later sends fall back once the domain is failingScore::smtp::state_machine, it::smtp::create_connect_check
N15The connection is lost after the final . and before the replyuncertain, never resentSit::smtp::uncertain_after_final_dot
N16The relay does not offer STARTTLS on 587 (or TLS on 465)Refused before AUTH; credentials are never sent. 422 smtp_tls_required at create or PATCH; health issue smtp_tls_required (fail)Score::smtp::state_machine, it::smtp::create_connect_check
N17The customer’s DNS host appends the zone name, so a record lands at agents.brightwell.example.brightwell.exampleRecords carry host (relative) next to name; health reports record_doubled_name (degraded) with a fixScore::dns::doubled_name_detected
N18The relay rewrites From or signs with an unaligned d=The alignment probe fails (smtp_from_rewritten or smtp_unaligned), the domain goes failing, and sends fall back to the platform addressSit::smtp::probe_unaligned_falls_back
N19A DSN bounce arrives for a message sent through an SMTP relayMatched by Message-ID: bounced (hard for 5.x.x, soft for 4.x.x), with a suppression for a hard bounceSit::smtp::dsn_to_bounce
N20The relay answers 4xx or 5xx to some RCPT TO commandsPer recipient: 4xx is retried later, 5xx is rejected with the code; the other recipients are sentScore::smtp::state_machine, it::smtp::partial_rcpt
N21nameservers on a domain that already has A, AAAA or MX records, or a www record409 domain_not_dedicated listing them, unless "confirm_dedicated": trueSit::domains::nameservers_dedicated_check
N22Cloudflare answers error 1105 when creating a zone429 upstream_rate_limited with Retry-After: 10800 (3 hours)Sit::domains::zone_create_rate_limited
N23A Free-plan zone from nameservers is not activated within 28 daysFinal domain.reminder on day 21. When Cloudflare deletes the zone: removed with zone_expired, and domain.removed with reason: "zone_expired"Sit::domains::zone_expired
N24A zone hold blocks creating the child zone409 zone_hold, with a fix asking the customer to release the hold for subdomainsSit::domains::zone_hold
N25The parent removes or changes the delegation of a delegated_subdomainnameservers_changed (ownership): the domain is suspendedSit::domains::delegation_removed
N26The SES region approaches or reaches 10,000 identitiesAt 9,000 the operator alert ses_identities_90pct fires and pmail doctor warns. At 10,000, a domain that needs an SES identity gets 422 transport_unavailable with details.reason = "ses_identity_limit"Sit::domains::ses_identity_limit
N27SES reports virus FAIL or spam FAILVirus: quarantined by the attachment-risk rule (quarantine_reason: risky_attachment). Spam: score 0.9, so the default threshold quarantines itSit::ses::verdict_mapping
N28One SES message has recipients in several tenantsOne pointer and one message per recipient, each resolved separately; no tenant sees another’s copySit::ses::cross_tenant_recipients
N29Retired addresses exceed the rule capacity (150 rules × 500 addresses)The oldest retired addresses leave the rules; their mail is then dropped like an unknown address’sSit::ses::retired_rule_sync
N30PM_SES_REGION cannot receive mail, or is outside the EU and the UK while PM_JURISDICTION=eupmail setup ses refuses it (the EU-or-UK check yields only to --allow-non-eu); eu-west-2 (London) is acceptedScli::setup::ses_region_check

O · Agent keys and notifications

Rows O1–O13 are specified in Agent signing keys and signed requests and built in M25; rows O14–O26 in Notifications and usage alerts, built in M26.

#CaseRequired behaviourOwnerTest
O1A paused identity, or an identity of a suspended tenant (paused with tenant_suspended), asks to sign, or its JWKS is fetchedAssertions and HTTP signatures are refused with 403 tenant_suspended for a suspended tenant (checked first, as on sends) and 409 identity_paused for a paused identity; the JWKS answers 404 identity_not_found until the identity resumes. A deleting or deleted identity gets 404 identity_not_found on bothSit::identity_keys::paused_withdraws_jwks
O2A verifier holds an assertion signed just before the identity key was rotatedThe previous key is retiring and stays in the JWKS until verify_until (PM_IDENTITY_KEY_OVERLAP_DAYS, default 7 days), so the assertion still verifies; new assertions use the new keySit::identity_keys::lazy_create_and_rotate
O3An identity key is revoked after a suspected leakThe key becomes retired at once and is absent from the next JWKS response. Verifiers cache the JWKS for at most 5 minutes (max-age=300), so they stop accepting it within that timeSit::identity_keys::revoke_removes_from_jwks
O4An assertion request with no audience, or one longer than 256 characters or not printable ASCII400 invalid_request naming audience; nothing is signedSit::assertions::claims_and_limits
O5An assertion expires_in below 60 or above 600 seconds400 invalid_request. The default is 300Sit::assertions::claims_and_limits
O6ext uses a registered or Pylota claim name (iss, sub, aud, exp, email, org and the rest), or is larger than 2 KB as JSON400 invalid_request. A claim the service sets is never overwrittenSit::assertions::claims_and_limits
O7An identity that has signing keys is deleted or erasedIts identity_keys rows are deleted and each thumbprint is written to key_tombstones; key generation refuses a tombstoned thumbprint, so that key ID is never published againSit::assertions::erasure_tombstones_kid
O8PM_MASTER_KEY is rotatedpmail secrets rotate-master re-seals identity_keys.private_enc and the web_bot_auth seed. Public keys and key IDs do not change, and tokens signed before and after the rotation verify with the same public keySit::secrets::rotate_master_reseals_identity_keys
O9A signed HTTP request, or a rotation of the web_bot_auth key, while PM_WEB_BOT_AUTH=off422 web_bot_auth_disabled. The key directory answers 404 key_not_foundSit::http_signatures::disabled_and_policy
O10The URL to sign has an internationalised host, or a signed component’s value is not ASCIIThe host is converted to its A-label for @authority. A component whose value is not ASCII is refused with 400 invalid_request, because RFC 9421 and Cloudflare reject non-ASCII valuesScore::httpsig::signature_base_rfc9421
O11An HTTP-signature expires_in below 30 or above 300 seconds400 invalid_request. The default is 60, because too short an expiry fails in transitSit::http_signatures::expiry_bounds
O12Someone mirrors the key directory to register it as theirs, or the deployment key was just rotatedThe directory response is signed once per listed key (tag http-message-signatures-directory, component @authority), so a copy served from another host does not verify. During an overlap it lists at most three keys: one active, two retiringSit::well_known::directory_signed_per_key
O13A signed HTTP request for an identity whose tenant has not opted in (web_bot_auth.allowed: false, the default)403 policy_denied; nothing is signedSit::http_signatures::disabled_and_policy
O14500 messages reach one inbox within a minute, for a person with new_mail notificationsOne email per person and inbox per window. instant: the first message opens a 2-minute hold and one email covers it all, then at most one email every 10 minutes. hourly and daily send one email per period, with countsSit::notify::new_mail_coalesces
O15Mail that is quarantined, hidden, marked spam, loopback, or on a test tenantNever counted in a new_mail notification. A message released from quarantine counts when it is releasedSit::notify::invisible_mail_never_notifies
O16A new_mail preference with filter = needs_replyEach message waits up to 5 minutes in the Notifier’s held table. It counts when message.triaged says needs_reply (score ≥ 0.5), is dropped on any other triage result, and counts when the 5 minutes pass with no triage event (skipped or disabled triage emits none)Sit::notify::needs_reply_filter_waits_for_triage
O17A notification email hard-bounces or draws a complaintThe address is suppressed as usual, and paused_reason is set on every preference of that person, so only account emails go out. The console shows a banner; confirming the address clears the pauseSit::notify::bounce_pauses_prefs
O18A one-click unsubscribe (RFC 8058 POST) from a notification email; or a request whose token is expired, belongs to another person or workspace, or is forgedA valid token turns that kind off for that person and workspace, without sign-in. Any other token changes nothing and shows a page that links to the settingsSit::notify::one_click_unsubscribe
O19A member is removed from a workspaceTheir notification_prefs rows for that workspace are deleted, and their pending notifications are droppedSit::notify::member_removed_drops_pending
O20Use of sends crosses 80% several times in one period, as holds are released and taken againOne email per threshold per billing period, recorded in the TenantQuota meta key alerted:{feature}:{threshold}:{period}Sit::notify::usage_once_per_threshold_per_period
O21A count that does not reset (seats, inboxes, custom domains, storage) moves 9 → 10 → 9 → 10 within a day, against a limit of 10An alert when a threshold is crossed upwards, then a 24-hour cooldown per feature and threshold: one emailSit::notify::count_feature_cooldown
O22The workspace’s time zone changesThe change takes effect from the next day. No daily email is sent twice or skipped, across daylight-saving changes tooSit::notify::timezone_change
O23Billing is off (PM_BILLING=off)No usage alert is sent: no feature has a limit. The daily caps in tenant policy still return 429; the identity and tenant send caps also emit quota.warning, the agentic-search cap does notSit::notify::billing_off_no_usage_alerts
O24A person would get a 51st notification email in a day, or a workspace a 201stFurther items that day are folded into the person’s digest, one email at the next 09:00 that lists counts, is not capped and can be unsubscribed from; the person’s settings page says so. account emails are not cappedSit::notify::daily_caps
O25The platform domain is failing when notifications are dueTheir sends fail like any send from it, because the platform domain has no fallback. The Notifier keeps the items and retries hourly for 24 hours, and the existing platform-domain alert tells the operatorSit::notify::platform_domain_failing_retries
O26The tenant is suspendedIts people get account emails onlySit::notify::suspended_tenant_account_only

W · Plans, billing, seats and the console

#CaseRequired behaviourOwnerTest
W1Two sends race for the last unit of the monthly allowanceHolds are atomic in the workspace’s TenantQuota object. Exactly one wins; the other gets 402 billing_limitSit::billing::w1_last_unit_race
W2Stripe is unreachable when an agent sendsMetering is local, so sends work normally. Only checkout and portal links fail (with a retryable error in the console)Sit::billing::w2_stripe_down_sends_ok
W3A send is denied with 402, the workspace upgrades, the agent retries with the same keyThe denial wrote no idempotency record, so the retry succeeds and sends onceSit::billing::w3_retry_after_upgrade
W4A completed send is replayed after the allowance is spentThe original result is returned (deduplicated: true); no hold is takenSit::billing::w4_replay_when_spent
W5A send ends uncertainThe hold is released. If reconciliation later shows it was sent, one unit is consumed thenSit::billing::w5_uncertain_release
W6A hold is never settled (Worker evicted mid-request)The TenantQuota alarm releases it after 10 minutesSit::billing::w6_hold_expiry
W7Inbound mail arrives when storage or triage allowance is exhaustedMail is always accepted and stored. Triage is skipped with triage_status: skipped and reason allowance; storage over-use blocks only new identities, domains and outbound attachmentsSit::billing::w7_inbound_never_refused
W8An invitation is sent with no seat left402 billing_limit with feature: seats. Pending invitations count as seatsSit::members::w8_seat_limit
W9A member is removed while signed inTheir sessions are revoked at once; the next request redirects to sign-inSit::members::w9_remove_revokes_sessions
W10The owner tries to leave, or is demotedRefused with owner_required until ownership is transferred to an adminSit::members::w10_owner_required
W11A downgrade leaves more identities, domains or members than the new plan allowsNothing is deleted. Creating more is refused until counts fitSit::billing::w11_downgrade_keeps_data
W12Stripe webhooks arrive late, twice or out of orderDeduplicated by event ID; subscription state is re-read from Stripe and applied only if newer than the stored stateSit::billing::w12_webhook_order
W13Payment failspast_due keeps the plan for the grace period (7 days), sends a billing.payment_failed event and console banner, then applies Free limits without deleting dataSit::billing::w13_grace_then_free
W14A forged or replayed Stripe webhookStripe-Signature verified (HMAC-SHA256 over t.payload, 5-minute tolerance, constant-time compare); failures return 400 and are loggedSit::billing::w14_webhook_signature
W15Magic-link or code brute force, or enumeration of registered emails3 link or code requests per 10 minutes per address; 10 attempts per code (the token is burned after 10 failures); RL_SIGNIN 10 requests per 60 s per client IP on the sign-in, sign-up and waitlist routes; identical responses for known and unknown addressesSit::console::w15_signin_limits
W16Cross-site request forgery against the consoleEvery POST needs the session’s CSRF token and a matching Origin; cookies are __Host-, Secure, HttpOnly, SameSite=LaxSit::console::w16_csrf
W17Rendering hostile HTML mail in the consoleSanitised HTML is shown inside a sandboxed iframe (srcdoc, no scripts, no same-origin, no remote images by default) under a strict CSP; text view is the defaultSit::console::w17_hostile_html
W18A viewer tries a write action, or any member reaches another workspaceRole checks on every console handler; workspace scope from the session, never from the formSit::console::w18_role_and_scope
W19Billing is off (self-hosted)No plan checks; GET /v1/usage reports billing: disabled, each feature with granted: null, unlimited: true and the real used; the daily caps in tenant policy still apply (429)Sit::billing::w19_disabled
W20Google or GitHub callback whose state is missing, reused, expired, or from another browser (no matching __Host-pm_oauth cookie)Refused before the code is exchanged. No session, no account; the page never reveals whether an account existsSit::oauth::state_cookie_binding
W21The provider’s email is not verified (Google email_verified: false, or GitHub has no verified primary address)Refused with a page asking the person to verify an address with the provider. No account is created or linkedSit::oauth::unverified_email_refused
W22A person signs up with Google, then signs in with an email link for the same addressOne user: the verified email links the methods (oauth_identities), and either method opens the same accountSit::oauth::link_by_verified_email
W23An invitation is accepted through Google or GitHub with a different verified emailRefused. No account is created or linked, and the invitation stays pendingSit::oauth::invitation_email_mismatch
W24Sign-up with a paid plan intent (?plan=team), then Checkout is cancelledThe workspace stays on Free. Checkout’s cancel_url is /console?upgrade=team, and the Overview shows a banner to finish upgrading; nothing is storedSit::signup::plan_intent_to_checkout
W25The person returns from Checkout before Stripe’s webhook arrivesThe return page waits (meta refresh, at most 7 times), then says the plan updates within a minute. Only the webhook changes the planSit::checkout::return_before_webhook
W26The Checkout return URL carries another workspace’s session IDThe retrieved session’s client_reference_id and metadata.tenant_id must name this workspace, and its customer must equal stripe_customer_id when that is already set. Otherwise a neutral “Nothing to show” page; nothing changesSit::checkout::return_wrong_workspace
W27A workspace requires two-step verification and a member has not enrolledThe member is sent to enrolment before entering that workspace. API keys are unaffectedSit::totp::workspace_requirement
W28A person loses their authenticatorEach recovery code works once; new codes invalidate the old. With none left, the support route (identity checked against billing details) is the only way backSit::totp::recovery_code_single_use, it::totp::recovery_codes_survive_key_rotation
W29Sign-up with a disposable email addressRefused before any mail is sent (PM_SIGNUP_BLOCKED_DOMAINS). No account is createdSit::signup::disposable_domain_refused
W30A Free workspace created to send spamNew-workspace ramp (billing on): the effective tenant daily cap is min(policy, 50) while ramp_lifted_at is unset, which covers the first 7 days on Free (429 daily_cap_reached on the 51st). A daily evaluation lifts it from day 7 if bounce and complaint rates are under the auto-pause thresholds; otherwise it stays, is evaluated daily, and the third failure alerts an operator (no automatic suspension). A paid plan lifts it at once. A tenant a partner’s key created is ramped the same way whatever its billing mode (exempt included) and whether billing is on, unless a platform key set the partner’s ramp_exemptSit::abuse::free_ramp, it::abuse::ramp_evaluator, it::abuse::partner_ramp
W31A hostile next (absolute URL, //host, a backslash, a scheme, or a path outside /console/)Ignored; the next landing rule applies. Never an open redirectSit::landing::routing_table
W32Someone without an invitation signs in while sign-up is closed or waitlistThe “No workspace yet” page; no account is createdSit::signup::closed_and_waitlist
W33The chosen address suffix is taken by another workspace created at the same momentThe form returns with suffix_taken; exactly one workspace gets the suffixSit::signup::suffix_taken_race
W34A person deletes their account while they own a workspace409 owner_required until ownership is transferred or the workspace is deletedSit::console::delete_account_owner_required