Evidence lifecycle
How stored mail is keyed, re-keyed, and reconciled against its receipts.
Decisions: ADR 28 (where the keys live), ADR 31 (why there is no queue yet), ADR 32 (why the two
reconciliation directions are not symmetric). Numbers:
receipts/evidence-lifecycle.md.
Keys belong to the Node
Section titled “Keys belong to the Node”Both root keys are generated by the Node into a Durable Object on first use. No binding to configure, no secret to provision, no way to install a Node that is accidentally unprotected.
Secrets Store was the original home and lost the argument on two counts. It is not account-portable:
removing the secrets_store_secrets block does not relink the binding the way D1 and R2 do, it
drops it silently. And #7 had already established that with the key in Secrets Store, Cloudflare holds
ciphertext and key, so the only threat it defended was a D1 dump or a config leak, which Durable
Object storage sits outside of just as well.
await env.NAME.get() was never the point. Nothing sensitive on env was the point, and a key
arriving over RPC satisfies it.
Generations
Section titled “Generations”Keys are numbered, and generation 0 is the constant published in this repository, what evidence on Nodes deployed before the vault was sealed under.
sealingKey() | Always the highest generation. Never returns generation 0, so a Node cannot write new evidence under a published key even by mistake. |
openingKey(n) | Whatever the object itself records. Generation 0 included; deleting the ability to read it would make existing mail unreadable. |
An object’s generation lives in its R2 customMetadata, and that is authoritative. Absent means
generation 0. A wrapped credential in D1 has no metadata to hang it on, so it carries a v<n>.
prefix instead.
ingress_receipts.key_generation is an index, not the truth. It exists so the re-seal driver can
query “what might still need work”; R2 metadata decides what can actually decrypt.
Re-sealing
Section titled “Re-sealing”The operation that makes key rotation real. Without it a leaked key has no remedy, and a key you cannot rotate is not much better than one you never held.
POST /api/maintenance/reseal → { resealed, alreadyCurrent, failed[], remaining, targetGeneration }Four properties, each preventing a specific failure:
- Resumable. A shard holds ~8.5M messages, so no invocation finishes; ~85,000 calls at 100 per batch. Progress is a durable indexed column, not memory.
- Verified. Recomputes the plaintext SHA-256 against the receipt and refuses to advance on mismatch. This is why the receipt stores the plaintext hash rather than the ciphertext’s. Decided when evidence storage was built, and this is the payoff.
- Safe to interrupt. Both generations open, so a half-finished run leaves everything readable. R2 is written before D1: a crash costs one redundant pass, never an unreadable message.
- Never destructive on failure. A message that fails is reported and left readable under its old key, not skipped silently and not deleted.
Reconciliation
Section titled “Reconciliation”POST /api/maintenance/reconcile[?collect=1][&format=text]The two directions are not symmetric, and treating them alike is the mistake to avoid. Direction 1 has four referent rules, one per scanned prefix, because “no receipt” is the test for only one of them:
| Found | Meaning | Action |
|---|---|---|
raw/ object, no ingress_receipts row, past grace | A write that lost its transaction | Delete, only when explicitly asked, and only while no legal hold stands anywhere in the organization (#64) |
drafts/ object, no drafts row keyed by body_key, past grace | The body of a message already sent, or of a draft somebody abandoned | Same: delete only when asked, and never while a hold stands |
exports/ object, no exports row for the id in the key’s second segment, past grace | Material somebody was authorized to copy, whose record has gone (#65) | Same |
sent/ object, no send_manifests row for the id in the key’s second segment, past grace | Composition or submission evidence whose manifest is gone; only reachable through a lost transaction (#74) | Same |
| Any of them, inside grace | Possibly a write mid-flight: a delivery, an autosave, an export page, a seal | Count it, touch nothing |
| Receipt, no object | Lost mail | Enumerate and report. Never repaired |
The four collectable rows are counted and reported separately and never summed, each on its own line. An
orphan means a transaction was lost; a stranded draft body means somebody used the composer; a stranded export
object means an investigator’s copy has lost its record; a stranded sent/ object means the evidence of what
this Node sent has lost its manifest. One total would be a number nobody could act on, and it would make the
orphan finding’s “N object(s) have no receipt” false of three quarters of its own count.
Deleting a receipt whose evidence is gone would turn a detectable data loss into an undetectable one. It is also the tempting option, because it is the one that makes the report go green.
doctor calls this read-only. A diagnostic must never be the thing that deletes data, however
safe the deletion looks.
The grace period is an hour, and it is sized by asymmetry rather than measurement: being slow costs an hour of R2 storage for a few kilobytes, being fast destroys mail that was about to be accepted.
The report names the prefixes it scanned, and the set is now complete
Section titled “The report names the prefixes it scanned, and the set is now complete”scanned.prefixes is in the structured report and in the text form, and it is the loop’s own input
rather than a second description of it. Until it existed the report could not distinguish “nothing to
collect” from “did not look” (both printed 0 orphans), and one prefix made that expensive.
This pass lists four prefixes, and those four listings are the only things its EVIDENCE.delete ever
sees:
| Prefix | Written by | Referent |
|---|---|---|
${orgId}/raw/ | src/ingress.ts | a row in ingress_receipts |
${orgId}/drafts/ | src/drafts.ts | a row in drafts keyed by body_key (#67) |
${orgId}/exports/ | src/exports.ts | a row in exports, id in the key’s second segment (#65) |
${orgId}/sent/ | src/outbound/manifest.ts and src/outbound/dispatch.ts | a row in send_manifests, id in the key’s second segment (#74) |
Those four are every prefix this Worker writes, and that is checked rather than claimed.
test/node/evidence-prefix-world.test.ts derives the written set from src/, every ${orgId}/<segment>/ a
file containing an evidence write spells, and fails if scannedPrefixes does not cover it, in both
directions. It exists because the same defect happened twice: #67 was ${orgId}/drafts/ and #74 was
${orgId}/sent/, each a prefix this Worker wrote and no listing covered. Neither was found by remembering, so
the third is prevented by a test instead. It is also what lets the text report say “every prefix this Worker
writes for this organization” instead of hedging about objects it did not list. That is a claim about the whole
source tree, and therefore a claim that has to be enforced somewhere other than in the sentence making it.
#67 is the shape both instances have. Draft bodies live at ${orgId}/drafts/{draftId}.txt, deleteDraft
removes only the row, and a draft is deleted when its message is sealed, the ordinary send path. So for as
long as the pass listed one prefix, every message ever sent from the composer left an unreferenced copy of its
draft behind, and there was no figure anywhere that could reveal it: a scan of one prefix printed 0 orphans
exactly as a scan of the whole bucket would.
The draft prefix, and why it is here rather than anywhere else
Section titled “The draft prefix, and why it is here rather than anywhere else”Three properties made the reconciler the right owner, and each one rules out an alternative:
deleteDraftstays row-only. ADR 32 makes reconciliation deliberately asymmetric (a reference with no blob may only be reported), so an inline delete that failed after the row was gone would create precisely the unreachable orphan #67 was filed about.- No new R2 delete site. Both prefixes fill one list and one loop drains it, so
EVIDENCE.deleteis still the only call in the product that destroys content bytes, and the closed world intest/node/content-deletion-world.test.tsstays at one entry. A separate sweep would have been a second place to remember to put a hold in front of. - One predicate, two readers.
scanDraftBodiesinreconcile.tsdecides which objects are stranded, anddoctor’sdraft_bodies_strandedfinding reports that scan rather than listing the prefix again. Two definitions that can disagree is the same defect one layer up, and the disagreement would be silent in the worst direction: a diagnostic reporting a count the collector declines to act on.
“No receipt” is not the test for a draft body, whose referent is a drafts row keyed by body_key, but the
budgets carry over unchanged: reconcile.list_limit bounds the page, reconcile.orphan_grace_seconds bounds
the judgement, and the grace window applies for the identical reason, because saveDraft also writes R2
before the row. Collecting inside it would delete the body of a draft mid-save. The measured cost of the
second prefix, and the worst case a full pass can now reach, are in
docs/receipts/evidence-lifecycle.md’s 19 August 2026 correction; the pass spends two fixed subrequests
on it however many objects are under the prefix, because the referents come back in one bulk query.
That bulk query is a correctness requirement before it is a cost one, and the correctness half was the
claim in this change that nothing enforced. It carries no LIMIT: a partial set of referents does not
under-count residue, it names a live draft’s body as stranded, and under collect that is not a wrong
number, it is the deletion of somebody’s unfinished writing. LIMIT 1 on that one line passed all 481 tests,
because every fixture had exactly one live draft and so could not tell a first row from every row. That is the
vacuity mode AGENTS.md warns about, in the property with the worst consequence in the file. It is now
collected against three live drafts and one residue
(test/stranded-draft-bodies.test.ts, “spares every live draft, not just one”). What that bounds is stated
in reconcile.ts beside the claim: any limit below three, not the absence of every limit, which no fixture
count can settle.
Collection is suppressed org-wide while any hold stands (#64), and the draft prefix inherits that rule
for the same reason as an orphan rather than by analogy: a stranded body has no drafts row, so there is no
mailbox to test a hold against, and the key’s own prefix is the organization. Enumeration and reporting are
unaffected. The per-mailbox hold is consulted earlier, in deleteDraft, which is the last moment at which
the mailbox is still known.
The send prefix, its referent, and the one query in this pass that is bounded (#74)
Section titled “The send prefix, its referent, and the one query in this pass that is bounded (#74)”A send stages three objects under ${orgId}/sent/${manifestId}/: typed.txt and normalized.txt written by
sealManifest, and submitted.eml written by dispatchOne, one object each per manifest, however many
recipients, because the same bytes go to all of them. All three are spelled through sentObjectKey in
src/outbound/manifest.ts, which is the one spelling shared with the reconciler; a second one is what makes
a writer and a listing disagree silently, and it is what the prefix world test above forbids.
The referent is a send_manifests row keyed by the id in the key’s second segment, so the lookup is per
manifest rather than per object. That is the same key shape as exports/ and deliberately not the same
argument:
- Nothing in this product deletes a
send_manifestsrow. So an object here with no row is only reachable through a lost transaction.sealManifestwrites both bodies before itsINSERT, foringress.ts’s reason. That is theraw/story rather than thedrafts/one, which is why this prefix takes the orphan rule: the grace window, and the org-wide hold suppression. - A cancelled or withheld send is not residue at all.
cancelSendmovesstateand touches neither R2 nor the row’s existence, so its staged objects are referenced and the scan never reaches them. Asserted intest/sent-evidence.test.tsrather than assumed, because it is the assumption whose failure would destroy the composition evidence §12 invariant 2 calls immutable.
The referent read is bounded, and it is the only one on this pass that is. scanDraftBodies and
scanExportObjects each read a whole column with no LIMIT, justified by what their table is: working state
deleted at seal, and one row per investigation. Neither reason survives for send_manifests, which grows with
every message this Node has ever sent, for ever. So the referents come back as one BETWEEN over the smallest
and largest manifest id in the page: still one query, still flat in the object count, and a stronger
completeness argument than a whole-column read rather than a weaker one, because every id the page will judge
lies between the minimum and maximum of that same set by construction. Its stated limit is in reconcile.ts
beside the claim: a page whose ids span the whole table reads the whole column, so it is never worse than the
other two and usually far better, and it is not a constant.
Sizing a bucket, which is the one thing this prefix asks of an operator (#76). sent/ is the only term in
the product that grows monotonically with use and is never collected. Every object here is referenced for the
life of the Node (nothing deletes a manifest row), and the reconciler is not supposed to take them: two of the
three are the composition evidence §12 invariant 2 calls immutable. Measured, in
docs/receipts/evidence-lifecycle.md: three objects and about three copies of the message plus its headers
plus 144 bytes, per send, linear in sends rather than deliveries, and two objects per send that never hands
over.
There is deliberately no runtime gauge, and the absence is the decision rather than an oversight. A
doctor finding would list a prefix bounded by reconcile.list_limit, 150, so past a Node’s first hundred
and fifty staged objects it could only honestly report a sample, and “at least 150 objects” is not a growth
figure. A number on a diagnostic screen reads as a reading, so that is worse than silence. A maintained counter
would be exact and can drift from the bytes it describes, which is the failure class #66’s breakers were
designed to avoid by deriving from rows instead of keeping a count.
So: multiply your send volume by the figure above. R2 storage is the customer’s bill under ADR 2, they own the account, and the honest position is that this Node tells them the rate and does not pretend to know their total.
The hold rule was re-argued rather than inherited, and came out identical. A sent/ orphan looks more
attributable than a raw/ one, since it carries a manifest id, so a per-hold check is the tempting move. It is
unavailable for exactly the reason it is unavailable elsewhere on this pass: the mailbox lives in
send_manifests.mailbox_id, and the absence of that row is the definition of the state, so the id in the key
names a record that is gone and resolves to no mailbox. #64’s org-wide rule stands, neither widened nor
narrowed.
How much this prefix grows, measured. Three objects per handed-over send and two per send that never hands
over, linear in sends rather than in deliveries; the body is carried three times, so the rule of thumb is three
copies of the message plus headers plus 144 bytes of framing. Every one of those objects is referenced for
the life of the Node, because nothing deletes a manifest row, so the reconciler will never collect them and is
not supposed to. The figures and the method are in docs/receipts/evidence-lifecycle.md’s second 20 August
2026 correction; the finding that this growth has no observable anywhere (no doctor reading, no meter term)
is #76.
What draft_bodies_stranded means now
Section titled “What draft_bodies_stranded means now”Residue no longer means “nothing can ever collect these”. It means one of exactly two things, and the
finding’s fix says both: the collector has not been run (it runs on
POST /api/maintenance/reconcile?collect=1 and nowhere else, since #67 deliberately added no cron) or a
legal hold is suppressing it.
It still reports at report severity, not degraded, and the argument was re-made rather than
inherited, because the old one had an expiry date on it: “promote it when a collector exists”. A collector
exists, and that turns out to have been the wrong condition. degraded has to mean something is wrong
here, and a Node that has sent a message from the composer and has not been swept since is healthy.
Degrading it would put a permanent WARN on the ordinary state of the product, and worse under a hold, where
collection is refused on purpose and no operator action can close the finding at all. evidence_orphans is
degraded for the opposite reason, and the contrast is the argument: a raw orphan exists only because a
write lost its transaction. The condition that would justify degraded is residue that survives a
collection run, and nothing in the report can know that today, because no collection run is recorded
anywhere. That missing input is stated rather than approximated: a guessed one would degrade exactly the
healthy Nodes described above.
Its success line also carries what it did not judge: the count held back by the grace window and, if
the listing was truncated, that more objects remain unexamined. A pass that skipped objects may not say
“every draft body has a row”, which is the same overclaim, a partial scan reading as a complete one,
that made this residue invisible in the first place. A prefix that could not be read says so, on its own
line, rather than contributing a 0: read: "unreadable" is a separate arm of the scan’s union, so no
caller can reach a count without having narrowed on a completed read.
Legal hold
Section titled “Legal hold”Decisions: #64. Schema: migrations/0018_legal_hold.sql. Code: src/holds.ts. The closed world over what may
destroy content: test/node/content-deletion-world.test.ts. Behaviour: test/legal-hold.test.ts for the
functions, and test/legal-hold-routes.test.ts for the HTTP surface. Every claim about what a caller is
told (the 409 and its body, draftRetained, who may place a hold) is checked against a real response there,
because none of those are checkable from a function-level test.
POST /api/holds { mailboxId, matterId?, fromDate?, toDate? } → { hold }A hold is a predicate, not a list. One requirement decided the shape: a hold placed on Tuesday must cover Wednesday’s mail. So the scope is a mailbox plus two optional bounds, evaluated at the instant of the destroying act, and nothing anywhere materialises a set of ids. A frozen list needs maintenance to stay right, and a hold that needs maintenance to keep covering things will quietly stop. It is deliberately coarser than any matter it serves, because over-holding costs storage and under-holding is unrecoverable.
matter_id is nullable and has no foreign key: the realistic first act is an urgent preservation before
anybody has opened a matter, and there is no matters table on this Node at all (#63 is charted, not built).
Both bounds are stored as full ISO-8601 instants. A bare 2026-08-31 given as toDate is widened to that
day’s last millisecond, because coverage is a string comparison and the un-widened form sorts below
everything that happened during 31 August, an under-hold at exactly the boundary somebody chose deliberately.
Placing, refusing and lifting
Section titled “Placing, refusing and lifting”| Act | Who | Recorded as |
|---|---|---|
| Place | one org.admin, alone, effective immediately | hold.placed, in the same transaction as the row |
| Refuse a deletion | nobody: the Node does it | hold.blocked, standalone; nothing was written |
| Request a lift | one org.admin, with a mandatory reason | approval.requested, with the reason in its detail |
| Lift | two distinct approval.decide holders on the held mailbox, neither of them the requester | hold.lifted, in the same transaction as the UPDATE holds |
Placing and lifting are asymmetric because the two acts have opposite risk. Placing only ever preserves; its worst case is wasted bytes, and ceremony in front of it is how evidence is lost in the hour after somebody realises they need it. Lifting re-permits destruction and is irreversible in effect, so #64 gave it dual control: a hold nobody can lift is an operational trap; a hold one person can lift quietly is not a hold.
The lift is an approval (docs/approvals.md), not a second approval mechanism. The requester is excluded from
deciding by the same separation-of-duty rule that stops an author approving their own send. Reused rather than
rewritten, because all three of #61’s defects were in that race logic. What it cost was one schema decision:
approvals now points at a (subject_kind, subject_id) subject, and a lift’s subject is the request rather
than the hold, so a refused lift can be asked again without the hold becoming permanent.
Three columns arrive with it, and each says what its NULL means: lifted_at (NULL = in force),
lifted_reason (a copy of the request’s reason, taken at the instant of the lift, so a reader of a hold
meets it without a join and nothing can rewrite what the lift said) and lift_id (which request took effect;
several may cite one hold, because a denied one may be followed by another).
lifted_at IS NULL is part of the coverage predicate, not a filter a caller applies: coveringHolds,
anyActiveHold and the hold report all carry it, because the first of those is the only thing standing between
a held mailbox and a deletion.
That leaves exactly one UPDATE holds in the product, and the test that used to require zero was
inverted rather than deleted. It now proves there is one, that it sets lifted_at, and that that
statement’s own SQL carries both halves of the gate: the approval having reached approved, and the hold not
already being lifted. Against the statement rather than the function around it, because the first version of the
check read a window of the enclosing function including its comments: the doc comment naming lifted_at IS NULL
satisfied the check for it, and state = 'approved' was satisfied by a constant the send branch uses. Both
mutations passed. A tripwire a comment can satisfy protects the comment. Narrowing a window (UPDATE holds SET to_date = …) still fails, because that was always the
silent lift and building the loud one does not make it safe. DELETE FROM holds still fails as an undeclared
call site.
An organization where fewer than two people hold approval.decide on the held mailbox cannot lift, and
that is refused at request time with the shortfall named rather than discovered by an approver. doctor
reports the state before anybody tries, as legal_hold_unliftable.
What consults it
Section titled “What consults it”| Site | Target | Content | Guard |
|---|---|---|---|
auth/session.ts | login_attempts | no | none |
auth/session.ts | refresh_tokens | no | none |
access.ts | relationship_tuples | no | none |
audit.ts | log_entries | no; telemetry, detail is never content | none |
drafts.ts | drafts | yes | assertNotHeld |
merge.ts | cases | yes | assertNotHeld |
reconcile.ts | R2 objects | yes | anyActiveHold, org-wide |
merge.ts is the judgement call. The merged messages survive; what DELETE FROM cases destroys is the source
case’s history (who held it, when it was first answered, whether its target was met), which is exactly the
class of fact an investigation asks about.
The table above is not maintained by hand. The test derives every call site from src/ and migrations/ by
scanning for DELETE FROM and EVIDENCE.delete, fails on an undeclared one, fails on a declared one that no
longer exists, and, for the sites carrying content, asserts the guard is called in the same function as
the destroying statement. Migrations are held to a stricter rule, zero matches, because a migration is raw SQL
inside batch() and no Worker code can stand between its statements and a hold.
Its blind spots are declared in the test itself, because a tripwire that hides its boundary is the thing it
replaces: dynamically constructed SQL, wrangler d1 execute, the Cloudflare dashboard, and whether the guard
is reached on every branch. The last is what test/legal-hold.test.ts is for; the middle two are not fixable
from inside a Worker, since the customer owns the database (ADR 2).
Collection stops org-wide, and the report says so
Section titled “Collection stops org-wide, and the report says so”While any hold stands anywhere in the organization, ?collect=1 deletes nothing, neither raw orphans nor
stranded draft bodies (#67). Both are still enumerated and still reported.
The reason is not cost. An unreferenced object is unattributable by definition. This pass finds it
because its referent is missing, so nothing can establish which mailbox it belonged to, and therefore
nothing can prove it is not responsive. A per-hold check is not expensive here, it is unimplementable: it
would require inferring a mailbox from exactly the data whose absence defines the state. That is as true of a
draft body as of an orphan: the drafts row carried the mailbox, and the row is what is gone.
reconcile prints a hold line on every branch, because suppression nobody can see is indistinguishable from a
reconciler that has stopped working, and this pass is what an operator reaches for when they suspect exactly
that. anyActiveHold is asked once per pass and only when collection was requested, so doctor’s
read-only call spends nothing on it.
That last point makes the line three-valued, not two, and the third value is the one worth stating: a
read-only pass has not consulted a hold, so it reports that it did not look rather than that nothing was in the
way. The first version of this line printed “collection was not requested; nothing suppresses it”. Measured
false against a Node with a hold standing, and false in the permissive direction. requested is in the report
precisely to stop suppressed: false being read as “nothing was in the way” by somebody who never asked; the
text form must not commit that misreading on the report’s behalf, and test/legal-hold.test.ts now fails on
the sentence rather than on a value.
Consequences of a hold, for a person using the product
Section titled “Consequences of a hold, for a person using the product”- Discarding a draft in a held mailbox answers 409 naming the hold. Sending from one succeeds and keeps the
draft: the seal already happened, so the send route reports
draftRetainedrather than failing a message that has left. - A merge that would delete a held case refuses, all-or-nothing, and nothing is changed.
doctorreports every hold in force with its mailbox, window, matter, who placed it and how long ago; any pending lift with the reason it was asked for; adegradedfinding for any hold whose mailbox no longer exists (a hold enforcing nothing while reporting as active); and adegradedfinding for any hold nobody could lift. A lifted hold drops out of all of them. It preserves nothing, so a warning about it would be true and useless.- Once the last hold is lifted, orphan collection resumes. That is asserted rather than assumed: lifting every hold while collection stayed suppressed would leave a reconciler that never collects again and nothing saying why, which is the inverse of the defect this whole mechanism exists to prevent.
There is deliberately no UI and no list endpoint. doctor is the read, and a second projection of the same
rows would be a parity surface to keep honest for no new answer.
The sweep this check was waiting for has since landed on it rather than beside it: #67’s draft-body
collection is gated on anyActiveHold and declared in the same closed world, which is why the R2 row of the
table above still names one guard and one call site.
The processing pipeline
Section titled “The processing pipeline”§13 accepts synchronously and defers everything else to an outbox event. The outbox and its sweeper shipped with Layer 1; the consumer did not, so events were marked published and nothing happened.
The outbox is not Cloudflare Queues, and still isn’t. The outbox row is the durability. The sweeper marks an event published only after its handler returns, so a failing handler leaves it pending and the alarm retries. That is at-least-once with retry, which is the property that mattered. Queues adds decoupling, and for this pipeline the trigger is still ahead: it arrives when a handler needs to be slow: scanning, an LLM call, an outbound webhook.
There is now a queue on this Worker, and it is worth being precise about why it does not contradict
the above. It carries delivery outcomes inbound from Cloudflare, which are not
this Node’s own work items: a Node cannot receive its own bounces, and Queues event subscriptions are
the only channel by which accepted and bounced become observable at all (receipt:
email-sending-events.md). It was not adopted to decouple anything. It was adopted because there was
no alternative source for the fact. The outbox’s own events remain in D1.
That queue has no name in this repository, and cannot. It used to be the constant
mailda-sending-events, and a queue name is account-scoped, so the second Node installed into one
Cloudflare account bound its producer to the first Node’s queue and had its sending events drained by the
first Node’s consumer, across two D1 catalogs (#72). The producer binding now names no queue, and the deploy
is expected to provision one per Worker exactly as it does for D1 and R2. Documented by Cloudflare, not
measured here, which is why the attach step below discovers the queue and refuses when it finds none
rather than assuming one exists. The consequence is that the consumer cannot
be declared either (a consumers block is refused without a queue name, and the derived name is not
knowable in committed config), so it is attached out of band by
pnpm --filter @mailda/worker run queue:attach-consumer, which discovers the queue from the deployed
binding rather than deriving its name. Until that runs, a Node observes no delivery outcomes at all;
doctor’s sending_events_consumer says so and delivery_visibility fails on the evidence.
What ships instead is the structural part: every topic must be registered.
- An unregistered topic throws, so its event stays unpublished and shows up in
doctoras a stalled outbox. Adding a topic without deciding what consumes it fails loudly. - A topic that needs no work at this layer registers
nothingToDoYetexplicitly, so “handled” and “forgotten” are distinguishable in one place. - Handlers must be idempotent, because delivery is at-least-once.
Today there is one topic, mail.ingress.accepted, and it is explicitly inert. What replaces it:
parsing MIME into messages and mailbox_items so the ledger reads from message metadata rather than
ingress receipts, threading, and Butler events at Layer 4, each its own decision.
Verified against the deployed Node
Section titled “Verified against the deployed Node”4 August 2026, on the Node holding a real Gmail message received through Cloudflare Email Routing:
doctorreportedevidence_key_generation: 1 receipt(s)behind after the vault initialised.- One
resealcall:{ resealed: 1, failed: [], remaining: 0, targetGeneration: 1 }. - The message downloaded byte-identically, SHA-256
cca56240…2df62unchanged, with its GoogleReceivedchain and DKIM signature intact. doctorverdict went to ok for the first time.reconcilefound no missing evidence and no collectable orphans.
One thing observed rather than designed: re-sealing rewrites the object, which resets its R2
uploaded timestamp, so a freshly re-sealed object reads as “too fresh to judge” for the orphan
check. Harmless, since it only delays orphan collection, but worth knowing before reading a report.
Producing what was sent
Section titled “Producing what was sent”GET /api/sends/:id/submitted → message/rfc822, streamed frame by frame§12 invariant 2 makes a materialized provider-submission representation immutable evidence. Storing it and providing no way to read it back is barely better than not storing it. The point of the record is that an operator can produce it, in a dispute, an audit, or an eDiscovery export.
Present only for the authored path. The structured API assembles the MIME itself, so there are no
submitted bytes to produce, and the endpoint says exactly that rather than 404-ing as though the send
did not exist (ADR 33).
An absent send and one belonging to another organization answer identically, per §5C.
Verified on the deployed Node, 5 August 2026
Section titled “Verified on the deployed Node, 5 August 2026”A reply to the real Gmail message was sealed, dispatched, and its submitted bytes produced back:
From: inbox@mailda-test.whymelabs.comTo: wmhy.tech@gmail.comSubject: Re: Hello worldMessage-ID: <snd_01KZ8G3YTZK16NDK4RPTTZY38J@mailda-test.whymelabs.com>In-Reply-To: <CAHdC3ON6OaSRTDaWz=fetrA_gwdOx_Dxy3bS8iS0fY4u3inq7Q@mail.gmail.com>References: <CAHdC3ON6OaSRTDaWz=fetrA_gwdOx_Dxy3bS8iS0fY4u3inq7Q@mail.gmail.com>sha256 of the produced bytes matched the hash recorded at dispatch exactly, so the record is the
thing rather than a re-rendering of it. Threading anchors point at Gmail’s Message-ID, taken from
the original’s stored evidence, which is why a reply threads correctly even though Cloudflare rewrites
our Message-ID on the way out.
An attachment on an authored send (0060) is evidence from the seal: stored under the manifest’s prefix
as att-<n> with its plaintext hash in send_attachments, bound by the effect envelope, verified against
the row at render before it leaves and again by the recheck on the approved path, and inventoried and
verified with the bodies. reconcile.ts needs nothing new: any object under a manifest’s prefix has the
manifest as its referent.
A forward (0059) is the same evidence going out whole. renderRfc822 emits multipart/mixed with the
typed text and a message/rfc822 part whose bytes are the original as this Node received it (not quoted,
not re-encoded, not re-signed), so a recipient has the message with its own headers, and the manifest’s
forward_of_message_id says which. The author must be able to read it (the same door as a reply’s parent),
and a message this Node judged to carry a dangerous attachment (0057) is not forwarded under its name; the
.eml download exists for the person who needs to hand it on regardless.