跳转到内容

History

此内容尚不支持你的语言。

What each change found on the way. These sections were the README’s running account, moved here on 16 September 2026 so the README could go back to describing the product; nothing was rewritten. The order is the order it was written in — newest last within each part.

The first part is the run of feature notes that sat under the status table; the second is the per-change sections that followed, each headed by the issue it closed.


It can send to more than one person, which it never could before. EmailMessage takes one address, so the old code joined recipients with commas into a single malformed one — a Cc refused the whole send. Mailda now submits the same stored bytes once per recipient, which costs nothing extra (measured: Cloudflare already counts one three-recipient send as three) and makes Bcc correct rather than merely possible, since a real Bcc needs its own envelope. Proven live: one send, three recipients, three submissions, three outcomes — accepted, bounced, bounced — each with its own message id.

Two people can work one queue without colliding, and the mechanism is one line of SQL. UPDATE cases SET assignee = ? WHERE assignee IS NULL — no lock, no Durable Object, no timeout. Reply performs that swap and opens the composer in one act, so a collision is unrepresentable rather than detected. Lose the race and you are told who holds it and for how long, because the Node re-reads the row instead of reporting a bare failure. There is no expiry: an expiry is a policy guess, a claim’s age is a fact, so the queue shows the age and a colleague decides. Taking a case from somebody is allowed and audited — the design prevents accidents, not takeover, and says so.

One clock, and it promises nothing until somebody says what to promise. Time to first response: from the oldest inbound message nobody has answered to the first outbound hand-over. No pause and no waiting-on-customer, because a pause needs to know whose turn it is and this Node cannot observe that — a clock that pauses wrongly measures nothing. The target is per mailbox with no default: NULL means no service level, which is the shipped state, because how fast a business answers its customers is not a platform limit and not ours to invent. It is set on the queue screen, beside the clocks it governs, so a promise nobody can see the source of is not one anybody has to trust. A breach is recorded by a cron sweep — a query over due rows, idempotent by construction, so a dropped invocation is repaired by the next minute’s rather than losing the breach — and it does not change the case’s state: a case that is late is not a different kind of case, so the fact is shown beside it rather than folded into it.

Merging two conversations mostly refuses, and the refusal is the feature. Twelve pair-states can occur when merging A into B; two are safe to automate. The rest mean a single-winner merge destroys a claim somebody is working, reopens something reported closed, or resets a breach that happened — and that last class is the deciding one, because the failure of merge-by-picking is not lost text but a system reporting something untrue about work it owes a customer. So it merges the two safe states and refuses the others naming the mailbox and the case pair to resolve first. One contested pair refuses the whole merge, since a partially merged conversation contradicts a conversation being one thing.

A delivery is what gets filed, not a message. §12 says a message may have many deliveries and access is evaluated per delivery — and that was assumed rather than implemented. The ingress derived key was the Message-ID alone, and Email Routing calls the handler once per recipient, so a customer who Cc’d two of your addresses had the first filed and the second discarded as a duplicate. The mailbox that was addressed received nothing, and nothing recorded that it hadn’t. It looked exactly like deduplication working, which is why it survived. Now the key is the Message-ID and the recipient: two deliveries file twice, the same delivery twice still files once, and the retry-safety the key existed for is untouched.

A Node that cannot see delivery outcomes says so. Two account-level objects carry them and neither is in the Worker’s config: the queue consumer and the event subscription. Both can be absent, deleted, or pointed at the wrong thing — and nothing about a Node in that state looks wrong: sends hand over, the outbox fills, and every recipient sits unobserved forever. doctor reports the capability it cannot check from inside a Worker, then compares what was handed over against what came back and names the silence, because “no bounces” must not be able to mean “nothing heard”.

The authenticated application is React now, and the screens you need when it is broken are not. ADR 30 put React at this layer with the composer, where client state first outlives a request. The split is the interesting half: sign-in, first-run claim and a locked-out doctor stay server-rendered with no framework and load zero bytes of the bundle, because they are the screens an operator sees when the Node has stopped working — once, literally, when a dropped binding made sign-in return 500 and left the diagnostic the only reachable surface. That is a measured property rather than an intention, and a test fails if the page ever references the bundle up front.

The shape is a rail, a list and reading pane, full-width ledgers, and a docked composer — docked because replying must not move the original off screen, which for invoice and shipment mail is a defect rather than a preference. The rail carries one mailbox today and exists so Layer 3 adds rows instead of a new chrome. Its draft label says where the bytes actually are, and now it can say the true thing: a draft is saved on your node, body encrypted in R2 like any other content rather than sitting in a database column, one object per draft, and replying twice resumes the draft you already started instead of forking it. Until that existed the label read this browser only · a reload loses it, which was the honest version and the argument for building this.

Building it found what the old shape was hiding. Accessibility is checked two ways that neither replaces — contrast computed from the tokens in CI, because axe cannot resolve a gradient background and reports that as a pass; structure and ARIA by axe, run per screen in both themes, with best-practice rules as advisories because the AA gate provably misses things. The first advisory run found the Inbox had no level-one heading at all.

And the outbox cannot summarise that silence away. The row a person actually reads showed the submission state — handed over, in green — and added the delivery outcome only when the recipients disagreed. Reasonable for the case anyone pictures, and wrong for the two that matter: a send whose every recipient bounced is unanimous, and a single-recipient send has nobody to disagree with. So a send that reached nobody rendered identically to one that arrived, with the correct per-recipient table one click underneath. It was found by rendering the page and looking at it. The suite had nothing to say, because the rule lived in the one file that touches document and therefore cannot be imported. That rule now lives in a module a test can evaluate, and the test fails by name when either guard returns.

The state nothing retries always carries a reason. A dispatch claims the send as outcome_unknown before submitting, so that an invocation dying mid-flight leaves it in the one state that forbids an automatic retry — the right pessimism, because a retry could deliver a second copy nobody can recall. But the claim wrote only the state, and every other route to a terminal state records its reason and an audit entry together. So anything that threw in between produced the only terminal state reachable with no account of itself: no reason, no audit entry, never retried. An operator read “we do not know whether it left” and could not find out why not. Now a throw records the cause against the send and appends the entry, without claiming anything about whether the mail actually left — which remains genuinely unknown. A killed isolate still runs no handler, and that limit is stated rather than papered over.

A bounce reaches the right recipient of the right send, by key. A Node cannot receive its own bounces — cf-bounce belongs to Cloudflare for the lifetime of the domain — so delivery outcomes arrive on a queue instead, one event per recipient. Proven end to end on the deployed Node: a send handed over, and about a minute later that recipient read bounced / hard with the provider’s own words (“Permanent: no available upstream: unknown public suffix”), while a recipient nobody reported on stayed unobserved. accepted comes the same way, from a 250 the receiving server actually returned — which is an observation, not a claim. (receipt)

Every recipient of a send has its own state. A send to three people used to have one state column and a JSON array of addresses, so “one bounced and two were accepted” was not representable — and that is the distinction Layer 2 is judged on. There is now a row per recipient, written in the same transaction as the manifest, with submission mirrored from the envelope and delivery left NULL until something is actually observed. That NULL is load-bearing: any default would make “we have heard nothing” look like an outcome.

Sending is authorized, and the authority is re-checked before the message leaves. Layer 2’s first named requirement was absent: any authenticated member could seal a send as any mailbox in the organisation, including one they cannot read. Sealing now requires send.propose on that mailbox — a distinct relation from reading it, because a shared mailbox several people read is exactly the kind whose outbound identity should be held by fewer of them. The check runs again before hand-over, because a send waits out a hold window and the sweeper that releases it has no principal in scope: revoking authority mid-window now produces withheld, a state that says the Node declined rather than blaming a mail service that was never asked.

Being able to reply is not permission to read. The queue was gated on send.propose alone and its rows carry the subject line and the sender address, so anybody who could reply read the metadata of every message in the mailbox — with no relation permitting it and nothing recording it. Found by asking what a single relation discloses: every existing queue test granted send.propose because claiming needs it, and none asked what somebody holding only that could see. mailbox.metadata.read — named in the blueprint’s own permission catalogue and implemented nowhere, one mention in a test seed — now gates those two columns, either it or mailbox.content.read satisfies them, and a responder holding neither is shown the restricted-content placeholder the contract already specified. The withheld columns are NULL in the SQL rather than read and discarded, because a value in the result set is one line of code from being returned.

A choice with consequences for every recipient is not made by a timestamp. A mailbox may have several addresses — addresses is unique on the address, not on the mailbox — and From was picked by ORDER BY created_at LIMIT 1. So adding billing@ to a support mailbox sent billing replies as support@, silently. The authority is still the mailbox (ADR 36) and the finer sender: grain is still deferred; what changed is that a mailbox with more than one address now refuses a send that does not name which, listing them, and the composer offers the choice. Refusing rather than picking is the same move merge makes.

Replying stopped the clock only in the tests. The dispatch path joined messages.rfc_message_id against send_manifests.in_reply_to_message_id — one is a <…@domain> header, the other our own msg_ id, and their schema comments say so. It could never match, so every case in a mailbox with a response target was reported breached however fast the reply left. Eighteen tests passed over it, because every one called the clock function directly with a conversation id it already knew; the only real caller is the one that has to find the conversation. That is the shape of most defects found this way: the mechanism is tested exhaustively and the wiring is not tested at all.

A diagnostic that counted evidence of its own blindness as evidence of sight. doctor decides “this Node cannot see delivery outcomes” partly on there being no delivery events — and counted events it had failed to attribute to anything. One unattributable event, which is proof that attribution is broken, was enough to suppress the warning. Now blindness counts attributed events only, and unattributable ones raise their own finding, because a Node receiving events it cannot place is neither blind nor healthy.

“Deleted” meant dereferenced, and no report could say so. A draft body is an R2 object; deleteDraft removes the row and touches R2 not at all; a draft is deleted when its message is sealed, which is the ordinary send path. The code and the shell doc both said the object was “left for the reconciler, because ADR 32 makes an orphan blob collectable” — true of ADR 32, false of this prefix, because the reconciler listed ${orgId}/raw/ only and draft bodies are written under ${orgId}/drafts/. So every message ever sent from the composer left an unreferenced copy of its draft, and no figure anywhere could reveal it: a scan of one prefix printed 0 orphans exactly as a scan of the bucket would. reconcile now names the prefixes it scanned, so a prefix outside the scan appears in the output instead of being absent from it, and doctor counts the stranded bodies. The deletion waited for the legal hold every content-destroying call site must consult, because a cleanup sweep is itself one of those call sites — and now that the hold exists, the sweep landed on that check rather than beside it.

The collector was given the prefix, not a sweep of its own. deleteDraft is still row-only: ADR 32 makes reconciliation asymmetric on purpose, so an inline delete that failed after the row was gone would create exactly the unreachable object being fixed. The reconciler scans ${orgId}/drafts/ under its own referent rule — a drafts row keyed by body_key, not a receipt, past the same grace window, because saveDraft writes R2 before the row and collecting inside that gap would delete somebody’s writing mid-save — and both prefixes drain one delete loop, so EVIDENCE.delete is still the only call in the product that destroys content bytes and the closed world stays at one entry. Collection is refused org-wide while any hold stands, for the orphan reason rather than by analogy: a stranded body has no row, so there is no mailbox to test a hold against. The predicate is one function that doctor reads rather than reimplements, checked by counting listings at the binding, because two definitions of “which objects are stranded” can disagree silently in the direction where the diagnostic reports a count the collector declines to act on. The residue every existing Node already has is collected by the ordinary pass — no migration. Measured: the second prefix costs the pass 2 subrequests, flat at 0 and at 5 stranded bodies, and doctor’s per-run cost did not move at all (13 → 13) because the scan it used to perform itself is now the scan it reads. Two claims that had gone stale in these exact comments for the third time — “nothing collects the body”, “nothing to run yet” — are now sentences a test fails on. A third claim was found unenforced while verifying this one: the referent query “deliberately has no LIMIT”, because a partial set of referents names a live draft’s body as stranded and under collection deletes somebody’s unfinished writing — and LIMIT 1 on that line passed all 481 tests, since every fixture had exactly one live draft. It is now collected against three, and what that bounds (any limit below three, not the absence of every limit) is written beside the claim rather than implied. What did not change is the finding’s severity: residue means the collector has not run, not that something is broken, and the condition that would justify degraded is residue that survives a collection run, which nothing records yet and so is named as the missing input rather than guessed.

The same defect was sitting in a second place, and the class is now closed rather than the instance. The reconciler listed three R2 prefixes and this Worker wrote four: ${orgId}/sent/ holds the typed body, the normalized body and the exact bytes handed to the transport, and nothing listed it. Identical to the draft-body finding above and invisible for the identical reason — the cost of an unlisted prefix is invisible precisely because nothing reports it — and it had been in that state since before that finding landed. It is scanned now, under its own referent rule rather than a copied one: a send_manifests row keyed by the id in the key’s second segment, so three objects resolve to one row. Nothing deletes a manifest row, so an object with no row is only reachable through a lost transaction, which makes this the orphan rule (grace window, org-wide hold suppression) rather than the residue rule; and a cancelled send keeps its row, so its staged objects are referenced and are not residue at all — verified, because that assumption failing would destroy the composition evidence §12 calls immutable. The hold decision was re-argued rather than inherited, because a sent/ key looks more attributable than a raw orphan: it is not, since the mailbox lives in the row whose absence defines the state, so the org-wide rule stands unwidened. The report’s sentence changed with it — it used to hedge about objects under prefixes it had not listed, and a sentence describing a state the code has left is the defect this whole thread is about. What closes the class is a test, not the repair: a closed world derives every ${orgId}/<segment>/ any writing file in src/ spells and fails if the reconciler’s scanned set does not cover it, in both directions, so a fifth prefix cannot arrive unlisted and a listed prefix nothing writes cannot report itself clean for ever. The arithmetic needed no re-derivation, which was the point of the previous round pricing reconcile.list_limit at 150 for a fourth prefix rather than the 198 the inequality allowed: the worst case moved 758 → 910 against the Free ceiling of 1,000. The assertion that used to claim the next prefix would also fit was true at three prefixes and false at four, so it now asserts the opposite — a fifth costs 1,062 and has to re-derive the limit deliberately.

And the growth nobody had measured is a finding of its own. Three objects per handed-over send, two per send that never hands over, linear in sends rather than deliveries, with the body carried three times — so sent/ grows at roughly three copies of every message plus headers plus 144 bytes of framing, measured under miniflare rather than read off the source. 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: two of the three are immutable composition evidence. A Node that composes heavily and sends rarely accumulates for ever, doctor has no figure that names it, and the cost meter prices operations rather than stored bytes. Filed as #76 rather than recorded as an aside, because a growth term with no observable is the same shape of defect as a prefix with no listing.

The lockout report opened for one of the two lockouts. doctor is served unauthenticated, reduced to findings whose contents are already public here, when the Node cannot authenticate anyone — and the test for “cannot authenticate anyone” asked for a finding named credential_kek, which nothing has ever emitted. The credential checks emit credential_key. So the disjunct covering the scenario the function was written for — a credential key that cannot wrap while the signing key is fine — was permanently false, and the fix text sent a locked-out operator to a finding that would not be in the report. signing_key is real, which is why the commonest lockout worked and every test passed: one of two branches being dead is invisible to a test that exercises the other. The name is fixed, that lockout now has its own end-to-end test, and a tripwire derives the emitted check names from doctor.ts and fails on any name the file refers to — in a comparison or in a fix naming “the X finding” — that no check emits. A wrong identifier is worse than a wrong comment, because prose does not look like it is being checked.

A cap named in bytes counted something else. audit.max_detail_bytes and log.max_detail_bytes are 2,048, and the check was JSON.stringify(detail).length — UTF-16 code units. Measured: a detail of 700 CJK characters is 714 units and 2,114 bytes, so it passed a 2,048-byte cap untouched, and the truncation record then reported bytes: 714 — a wrong number under a key that ends the question a blank would have prompted. The cap is a disclosure bound, not a storage one: it exists because the audit table is read by a wider set of people than the mail it describes. One that can be exceeded threefold by writing in Chinese is not that bound. Both now measure UTF-8, the truncated head is cut between code points so no lone surrogate is stored, and the record’s own envelope is measured rather than allowed for with a magic constant. The 2,048 itself was a round number inside a receipt marked measured-tripwire; it now carries a derivation, and the frontmatter says which of its values were sized rather than measured.

Both binding guards were allowlists, and neither knew about Workflows. Two tests exist so that a new binding cannot arrive unpriced or unprovisionable: one asks whether the cost meter classifies every binding the Worker declares, the other whether a customer’s install can provision every binding block. Each hand-maintained its own idea of what a binding block is — one iterated five block names it listed itself, directly under a comment claiming it read them from the config; the other matched key suffixes plus six exact names. Neither recognised [[workflows]], which is the engine Layer 4 runs Butlers on and therefore the next block this config will gain: a Workflow step touching an unmetered binding would have been priced as free, with nothing firing. Both now share one closed world over the config’s top-level keys — every key is a declared binding block or a declared field that binds nothing, and anything else fails, naming the key and what to do with it. That is the version that catches the binding nobody has thought of, which was the only point of having the guards. An allowlist of the blocks somebody already thought of never could.

A legal hold that a call site can forget to ask is not a hold. Seven places in this Worker can destroy something, and until now nothing anywhere said which of them destroy content — so preservation was a property of whoever remembered. A hold is now a predicate, not a list: a mailbox and two optional date bounds, evaluated at the moment of the act, because a hold placed on Tuesday has to cover Wednesday’s mail and a frozen set of ids stops covering things the day nobody maintains it. Placing is one administrator, alone, immediate and audited — it only ever preserves, and ceremony in front of it is how evidence is lost in the hour after somebody realises they need it. A refused deletion is audited too, as hold.blocked, because an attempt to destroy held mail is evidence about the attempt. The tripwire is worth more than the mechanism: a test derives every DELETE FROM and EVIDENCE.delete in src/ and migrations/ from the source, fails on one nobody classified, and for the three that carry content asserts the guard is called in the same function as the statement that destroys. Migrations are held to zero, because a migration is raw SQL inside batch() and no code can stand between its statements and a hold. Its blind spots — dynamic SQL, wrangler d1 execute, the dashboard — are declared in the test, since a tripwire that hides its boundary is the thing it replaces. One consequence stated rather than discovered: orphan collection stops for the whole organization while any hold stands, because an orphan is unattributable by definition and nothing can prove one is not responsive.

A hold nobody can lift is an operational trap; a hold one person can lift quietly is not a hold. So the lift is the exact mirror of placing: an administrator asks, with a mandatory reason, and two other people holding approval.decide on the held mailbox have to agree. The requester is excluded from deciding by the same separation-of-duty rule that stops an author approving their own send — reused, not rewritten, because all three defects the approval machinery shipped with were in that race logic and a second copy is a second place for them. Reusing it cost one schema decision, taken on the day the second caller appeared rather than the day it hurt: approvals pointed at a manifest and nothing else, so it now points at a subject — a kind and an id, with a unique index over the pair — and a lift’s subject is the request itself. That is why a denied lift does not make a hold permanent: asking again mints a new request, exactly as re-sealing mints a new manifest. The tripwire that guarded the absent lift was inverted rather than deleted, which is the part worth copying: there is now exactly one UPDATE holds allowed in the product, a test proves it is the one that sets lifted_at and that that statement’s own SQL carries both halves of the gate — read from its string literal with its ${} holes resolved, because the first version of this check read a window of the enclosing function including its comments and passed with either half deleted — and narrowing a hold’s window still fails — because that was always the silent lift, and building the loud one does not make it safe. doctor dropped the finding that said lifting was impossible, since that sentence became false, and gained the one that matters instead: a hold over a mailbox where fewer than two people hold approval.decide cannot be lifted by anybody, and it says so before an administrator finds out by being refused.

“Mailbox administration alone does not imply content access” was true about the relation and false about the administrator. org.admin can grant any grantable relation to any subject, and nothing excluded itself as a subject — so an administrator could give themselves mailbox.content.read and read anybody’s mail in one audited call. Refusing that was the tempting fix and it sets a trap: in a two-person organization the only other approver is the person being examined, so the ceremony is either theatre or the read is impossible, and “impossible” for an administrator genuinely responsible for a mailbox is the wall that gets solved by editing the database directly — which leaves no record at all. So the door stays open and is made loud: an access.granted entry whose actor and subject are the same principal is now a doctor finding, costing one seek into the entries of the one action it is about — which is what it costs because the purpose-built index written for it was deleted: SQLite never chose it, and forced with INDEXED BY it was strictly worse, because its test for whether a query implies a partial index’s predicate does not credit a column-to-column comparison. An index nothing chooses, under a comment saying it is load-bearing, is the defect this repository keeps paying for, and the only reason it was caught is that the plan was printed rather than reasoned about. The same print caught the other new index having its range column ahead of an equality column, truncating its usable prefix at four of five — #11’s lesson arriving one table over. It is a report and not a degraded, because in that two-person organization the self-grant is the correct act and a permanent WARN on a legitimate act is how a check gets muted. Its own text says the thing that had to be said plainly: this does not prevent an administrator reading mail. It makes the front door and the back door distinguishable in the record.

The front door is supervised.read, and it is a relation no tuple carries. A time-boxed grant over one mailbox, at one of two scopes, citing a matter or nothing, live only once two people who are not the reader have approved it — and the reader is the requester, structurally, because a request on somebody else’s behalf would leave the reader outside the separation-of-duty exclusion and free to approve their own access. It is not a tuple because relationship_tuples has no expiry column and giving it one would put a time comparison into every authorization check in the product for the benefit of one relation. That is also the more honest shape: “who can read this mailbox” now has two answers with different structures, and collapsing them would make the answer uniform and wrong. The relation is nonetheless declared beside the others, with how it is conferred as a field — so Grantable is derived from the registry rather than listed twice, the ordinary grant route refuses it at compile time as well as at runtime, and the refusal a person reads names the door that works. An administrator told only “not grantable” is the person who grants themselves content.read instead.

Purpose had to become an object, and the notification requirement is what decided it. §7 requires telling the employee after the matter closes, and free text cannot close — one shape simply cannot satisfy the contract, so matters carries a type, a description, who opened it and closed_at. It pays for itself twice more: several grants belong to one investigation, and “widening scope requires a new approval” becomes a second grant citing the same matter rather than an edit to a live one, because an editable grant is an audit trail that can be rewritten in place. There is no UPDATE of a grant’s scope or deadline anywhere, and a test reads the one UPDATE supervised_grants that exists — from its string literal, with comments stripped, because this repository has already shipped a source scan that its own prose satisfied — to prove it sets granted_at, requires the approval to have become approved, refuses a grant already live, and does not touch expires_at. Recomputing the deadline at decision time would silently extend every grant by however long the decision took, which is the widening that is supposed to need its own approval.

A matter’s description is not org-wide reading, and that boundary is what §7’s deferred notice rests on. The listing was open to every member at first, justified by the fact that the two approvers read the text before deciding — an argument about approvers, implemented as an argument about everybody. A description says “suspected exfiltration by Dana”, and §7 makes the notice to Dana due after the matter closes; an org-wide listing delivers it on the day the matter opens, to the one person it must not reach first. So GET /api/matters shows an org.admin everything and anybody else the matters they opened, and the approvers’ real need is served where it belongs: the pending-approval row carries the cited matter’s type and description to the two people being asked, on the join that was already fetching the grant. An approver reads the matter they are deciding on, not every matter in the building.

Expiry needed no mechanism, and the enumeration that proves it came back empty. Nothing caches authorization, so the request after the deadline checks and finds the grant over; §7’s list of things an expiry must terminate — cursors, event streams, presigned attachment URLs, cached previews — has no members on this Node, because nothing presigns, nothing streams and the raw-evidence read is authorized per request. So the test proves the stop through the real .eml read rather than asserting the absence of a cache: sixty seconds authorizes at +30 s and is refused at +60.001 s with nothing having run in between. What was measured instead of assumed is the cost, because the authorization receipt’s stale_when names exactly this — a condition beginning to read additional rows on the request path. The grant lookup is a UNION ALL arm of the statement the check was already issuing, so it is two queries either way, priced through mayRead itself rather than through a copy of its query; and the index is partial on granted_at IS NOT NULL, so the arm costs one row on a check that misses the tuple arm and nothing on one that hits. The receipt’s values did not move; the dated correction says so, which is the point of having the clause.

A supervised read is the third approval subject, not a second approval mechanism. Adding it was a compile error in three places until handled, which is the design working: the per-kind wording, the per-kind deadline and the per-kind completion effect are all records keyed on the union. The hold lift had proved the generalisation; this ticket spent it, and the one refactor it cost is the shape worth knowing — the lift’s hand-written “strong predicate” branch, where a completing decision also requires that the effect has not already happened and a lost race records nothing rather than an entry claiming an act that did not occur, became per-kind data while the race logic stayed written once. All three defects the approval machinery shipped with were in that logic.

A supervised read that is not recorded is now unrepresentable, and the way that is done is a parameter. §7 wants every query, result opened and attachment read in the trail, and the tempting shape — an audit call beside each read — is correct on the day it is written and silent the day somebody adds a fourth read path. So the record lives where the authorization decision is made: mayRead takes the act it is about to authorize, cannot be called without one, and appends the entry inside itself before it returns true. The check returns which authority answered rather than a boolean, because an ordinary relation owes nothing and a grant owes an entry, and the guess that hides is the one that decides an unrecorded supervised read was ordinary. A listing cannot use that shape — its rows do not exist at the moment of the check, so an entry written there could not name the ids — so it records afterwards, and what keeps that structural is that a grant id reaches a listing from exactly one builder, whose callers a world test requires to emit the entry before they return. All of it fails closed: the append throws where audit swallows, because a record of something that already happened must not fail its request and a disclosure that has not happened yet must not proceed without one.

A query entry names the ids it returned, and the interesting decision was refusing to pick a page size. “A query matched forty things” understates what a person saw by forty subject lines, so the ids go in — and the cap that bounds them is about 57 of them, close enough to a real page to matter. Truncating would have been the silent version of the same understatement: the detail cap replaces an over-long value with a prefix, so an oversized list would have recorded the first fifty-seven ids and looked complete. The list is split across continuation entries instead, sized by asking the cap’s own measurement rather than by restating its arithmetic — so a sibling field added tomorrow lowers the fill and breaks nothing, and the number is printed rather than asserted because the design deliberately does not rest on it.

The employee’s notice is a row, and doctor counting rows is the whole reason. §7 wants a durable job the investigator cannot disable. A Workflow instance is not a durable record — retention is 30 days and a matter can stay open for months — and a sleeping instance and a culled one look identical from outside, which is exactly the question a report has to answer. So the obligation is a row written in the same transaction as the grant taking effect, delivered by the one-minute cron that already runs, and counted when overdue. Suppressing one therefore means deleting an audited row: the count of grants in the hash-linked trail is compared against the rows in the table, so removing the row shows up in doctor and removing the entry instead breaks verification at a nameable point. The product has no delete, no dismiss and no mark-read, and a world test forbids DELETE FROM notifications anywhere in the source — because a product that can clear a notice makes a missing one ordinary and the finding meaningless.

Closing a matter could tell somebody their mail was read while it still was, and the fix was to hold the notice rather than to block the close. §7 hangs the notice on the close; a closed matter deliberately does not revoke a live grant. Refusing the close while a grant is live was the other available shape and it inverts a control this design already made: closing is open to any administrator because the investigator is the one party with a reason to delay it, and a block any live grant could hold open hands that delay back to the person who asked for the grant and chose its duration. With no revocation path, the block could not even be cleared. So the notice waits instead — due at max(close, the grant's own expiry) — and “after the matter closes” comes to mean after the reading actually stopped. The residual is stated rather than hidden: a matter nobody ever closes defers its notices for ever, which is §7’s own shape, and the control is that closing is not the investigator’s to withhold.

Holding a notice means something has to un-hold it, and the first version of that had a hole the investigator could walk through. The date was written by the close, in the close’s own transaction — which can only reach notices that already exist, and a grant is asked for at one instant and takes effect two approvals later. Close the matter in between and the notice was left undated with nothing that would ever date it: no second close is permitted, the cron delivers only what is due, and the overdue count is has a due date by construction, so the row sat there for ever looking present and correct. The reader read, every act was recorded, and nobody was ever told — without deleting an audited row, arranged by whoever opened the matter, which is usually the investigator. Both orderings now write the same instant from the same expression, and doctor grew a third finding that counts notices which can never fall due — because the two checks it already had are both about a row being removed, and neither can see one that is present and inert.

What the notice says is a decision, and “your mailbox was accessed” is not it. It names the reader, the scope, the window, the matter’s type — and what was actually done: queries run, messages those queries listed, contents opened, raw messages read, counted off the trail so they cannot drift from the entries they describe. That is the difference between a grant nobody used and one under which four hundred messages were opened, and without it the notice is the compliance theatre §7 would otherwise be satisfied by. What it withholds is the matter’s description — free text naming an investigation and often a third party, which this Node cannot vouch for and must not publish as a system statement — and the ids themselves, which live in the trail where a record of what was read belongs. Delivery is in-product because outbound mail is not a dependable carrier for a legal obligation on a platform whose transport refuses unverified destinations, and the notice is addressed to the mailbox: its audience resolves from standing relations, and a supervised grant is never one, so the person who read the mail structurally cannot see the notice about having read it.

A cron expression in a doc comment cost a build, so the comment-hazard tripwire grew a third language. The guard has now been narrower than the hazard twice: it started as backticks in CSS, learned about SQL comments after three repeats, and did not know that a * and a / in prose end a TypeScript block comment — which is what a one-minute cron expression is made of. The prose after it parsed as code. The new check is the exact invariant the CSS one enforces, one language up: scanning left to right, every comment terminator must close a comment an opener opened, so a doc block that ends early leaves the terminator the author meant standing in code. It walks strings, template literals, line comments and regular expressions to get there, declares the regex heuristic as the one place it can produce a false positive, and is proved by putting the cron expression back and watching it name the line.

A policy that cannot be expressed is a policy that never fires, and so is one that can. The blueprint lists thirteen policy dimensions and six outcomes. This Node ships five conditions and four outcomes, and the arithmetic in that sentence is the whole of the decision: the five — mailbox, actor, external recipient, reply, org daily volume — are the ones answerable from a column that exists or one derivation over storage that exists, and every other dimension is named absent with its reason rather than given a column nothing fills. A condition backed by no data is worse than a missing feature, because it reads as governance. The conditions are five typed columns rather than a JSON bag for the same reason pointed the other way: a bag would have accepted dataClass, stored it, published it, and fired never. External recipients are exact rather than heuristic, and exact for a platform reason rather than a schema one — Email Routing only accepts addresses on domains in the customer’s own Cloudflare account, so every domain this Node has an address on is one the customer controls, and the internal set derives from those with no domains table and no cache. Which is a correctness argument resting on somebody else’s product, so it is the first clause of a receipt’s stale_when rather than a comment.

Four outcomes, totally ordered, so conflict resolution is one comparison and sixteen tests. allow < hold < require_approval < deny, and a send’s outcome is max over every rule that matched. There is no priority field, deliberately: a priority lets a narrow allow beat a broad deny, which is exactly how a policy system fails open, and it makes “why was this allowed” unanswerable from one row. The order between the two gates is the part that is not intuitive and is settled by who may clear them — anybody who may send as the mailbox releases a hold, only an approver approves — so a hold is the less restrictive gate. All sixteen ordered pairs are tested, three ways each: against the declared order, symmetrically, and through the real evaluator with two rules actually published, because a total order being right where it is defined proves nothing about the code that uses it. A precedence table would have been sixteen cells with a wrong one nobody notices; this is four numbers.

A denial goes to withheld, not to awaiting, and that overrules the ticket that specified it. The resolution said “held if allow, awaiting otherwise” in one sentence and named only the two gates as awaiting in another. The loose sentence is the one to distrust: a denied send parked in awaiting is a send nobody can ever clear — a hold gets released, an approval gets decided, a denial has no counterpart — so it would accumulate forever in a state that renders as pending. withheld already means precisely the right thing. The state machine now has two halves that are symmetric rather than accidental: gates are awaiting plus a reason, refusals are withheld plus a reason, and the reason is a machine token whose words live in the one client module a test can evaluate as the exact bytes a browser is served. awaiting was also given a drain, because the argument above applies to gates too until the approval act exists: an author can stop their own gated send, and until they can, “waiting for somebody to clear this” and “unstoppable” would have rendered identically.

Approvals are ordered stages with a count, which is one mechanism where the contract asked for three. The blueprint wants sequential, parallel and dual review. They are not three features: parallel is one stage of count 2, sequential is two stages of count 1, and dual control is whichever of those an organization means. The order is on the stages, not on the people — which is what makes an order expressible at all, because a set defined by a relation has no natural sequence and naming people in a policy would widen authority. Each stage’s membership stays derived from the approval.decide relation on the mailbox, minus the author, minus everybody who has already decided.

Distinctness is measured on the person, and that is the subtle one. A principal authorizes as themselves plus every team they belong to, so a relation can be held through a team — which means the holder set is a set of tuples while a decider is a human being. One person in two teams that both hold approval.decide would satisfy a count of 2 if the count were taken at the tuple layer: dual control defeated by something that looks exactly like working code. It is measured on user_id, and twice — the eligible-set query resolves teams to people and de-duplicates, and a UNIQUE index on (approval, decider) is what holds when two decisions race. The test for it constructs that person on purpose. Break the resolution and the suite reports expected [ 'tm_appr_a', 'tm_appr_b', …(1) ] to deeply equal [ 'usr_appr_dual' ] — team ids where people should be, which is the bug in one line.

Refused at publication, and re-checked at evaluation, because one check would have been silent. A policy requiring two approvers on a mailbox where one person holds the relation is refused when somebody tries to publish it, naming the mailbox, the stage and how many short. It is then checked again at the seal — and that second check is the point rather than belt-and-braces: publication cannot know who will write the message, and an author is never eligible to approve their own send, so a policy that passes publication can still be unsatisfiable for one particular person. More importantly, revoking approval.decide afterwards would otherwise make a live policy unsatisfiable in silence, with gated sends collecting in a state that reads as waiting for somebody. Unsatisfiable now means withheld with a reason naming the shortfall, which is a refusal a person can act on. What is not covered is named in docs/approvals.md rather than implied: a send already waiting when the last approver loses the relation is not re-checked, because nothing sweeps that state yet.

Withdrawing is allowed and denying is final, and the asymmetry is the design. An approver who learns something after approving can take their approval back while the request is incomplete. Without that, their only remedy is persuading a colleague to deny — which records somebody else’s judgement as the reason a message was stopped, in a trail whose entire value is that it does not do that. A denial needs no counterpart, because composing again mints a new manifest and a fresh approval. Withdrawal is terminal for the withdrawer, so the eligible set only ever shrinks and no amount of withdraw-then-approve lets one person fill two slots.

Cancelling the send settles the request it was waiting on, in the same transaction. Cancelling is the drain a waiting send has, and the author may use it while other people are being asked — so the request has to go with it. Not for tidiness: the queue an approver reads is built from open requests, and one whose send no longer exists is work nobody can clear; and every refusal on the decision path keys on the request’s state, so leaving it open let an approval of a cancelled send close the request, move nothing, and still report the send as released. A request already answered is left alone.

The withdraw-versus-final-approval race is a conditional UPDATE, and its zero is read carefully. Completion is “every stage satisfied and nothing withdrawn”, evaluated inside the database at the instant of the write. changes = 0 there does not mean somebody withdrew — every non-final approval leaves it zero, legitimately, which is why writing that down would have been a claim the code contradicts on its most ordinary path. The signal is “this decision should have closed the last stage and did not”, and that means a withdrawal, because a competing finalisation is refused by the predicate every statement in a decision shares rather than recorded.

One constraint was cut, with the reason recorded rather than a column added for it — and it has now been built. A stage was meant to be able to say “a member of team T”. team_members turned out to be read-only in the whole product — three SELECTs, nothing writing it — and there was no teams table at all, so a team had no name and no existence of its own. A team-scoped stage would have been expressible and unusable: no team could be created through any surface, and publication could not check that a named team exists, only that it currently has members. That is the same failure as a condition backed by no data, and a nullable team_id that is always NULL is that failure wearing a column. So the constraint was named absent and what would have to exist first was filed as an issue — and the issue was then taken, which is the rest of this paragraph.

A team is now a first-class object, and the substrate and its consumer shipped together on purpose. A subsystem with no consumer and a constraint with no subsystem are the two halves of one mistake, and shipping either alone is how one of them rots. So the same change adds a teams table with a name that is unique in the organization — because a team is granted to by id and picked by a human reading a name, and two teams called Finance is exactly how approval.decide reaches the wrong one — a writer for team_members, which had never had one, and the stage constraint that justified them. Membership needed nothing else in the schema, and that was checked rather than assumed: the UNIQUE index that stops one person joining a team twice has been there since the first migration, so an add is INSERT OR IGNORE behind an audit gate and a replay is a no-op that records nothing rather than a second entry claiming a second act.

Membership is authority, and that is what decided both the audit question and the Butler one. A principal authorizes as themselves plus every team they belong to, so a relation held by a team is held by every member — which means adding somebody to a team can hand them a mailbox’s contents and a vote on somebody else’s send with no access-granted entry anywhere. Un-audited, an administrator grants a team once, in the trail, and then changes who that grant reaches for ever, in silence. So team.member_added and team.member_removed are recorded, keyed on the person so “what authority did this person get, and when” stays one filter across both doors into it; the removal carries how many members are left, because emptying a team is what makes a rule unsatisfiable and that consequence is not otherwise attributable to an act. Creating and renaming are audited too, on their own merits and not by association: a team row has no neighbouring entry to answer for it the way a policy’s shell has, and a rename changes what the next administrator believes they are granting to. And a Butler cannot be put in a team — enforced by requiring the subject to be a row in users, which is a join that has to succeed rather than a test on the shape of an id, because a capability ceiling intersected with a set that moves whenever somebody edits a team is not a ceiling.

The constraint narrows and cannot widen, which is the property that let it exist at all. The eligible set for a stage is the approval.decide holders on the mailbox intersected with the named team’s members, minus the actor, minus everybody who has already decided — an intersection only ever removes people, so naming a team can never make somebody eligible who holds no relation. A team id naming nothing resolves to nobody rather than to everybody, which is the restrictive answer for the unclassified input. Publication verifies the team exists, which is the check that was impossible before there was a row to look for, and it is a different refusal from “that team is empty” because a misspelling and a quiet week need opposite answers.

A team that is emptied reaches the same answer as authority being revoked, because it is the same thing. Removing the last member of a team a live policy names is permitted — refusing it would put a policy in charge of who may leave a team, and would fail in the direction that leaves somebody in a team they should not be in. What happens instead is what already happens when the last approval.decide holder loses the relation: the next send is withheld with approval_unsatisfiable, naming which stage, which team and how many short, rather than parking in a state that reads as waiting for somebody. And it is re-checked a third time at the decision itself: the request freezes the team’s id and deliberately not its members, so somebody who leaves a team stops being able to decide on their next request rather than on the next send.

One thing about the fold needed deciding and is refused rather than guessed. Two live rules that both gate one send are folded by taking the greater count at each ordinal — which is the existing conflict resolution reused. Teams have no greater-of: “a member of Finance and a member of Legal” at one ordinal is a conjunction one stage cannot carry, and picking either would silently drop half of a rule somebody wrote. So publication refuses that pair, and only when the two rules could provably both match one send — Finance approving one mailbox and Legal approving another is untouched, because a tripwire a good policy trips is a wrong tripwire. The fold raises if it ever meets the conflict anyway, so the rule is enforced at both ends instead of claimed at one.

What it cost, measured rather than counted. The receipt for an approval decision had already named this change in its own staleness clause and reserved the headroom for it: “the eligible set gains a narrowing constraint — a team-scoped stage is the one #61 named absent.” It fired, the headroom was spent as predicted, and no number moved. One query, teams LEFT JOIN team_members, spent at the seal, at the decision, at the withdrawal and at publication — and only where a stage actually names a team: a send gated by an ordinary policy asks nothing, because the resolver short-circuits an empty request before it prepares a statement. A team-scoped seal is exactly one operation more than a team-less one, measured as a difference in one run rather than against a figure written down last week. On the authorization path it costs nothing at all, and that was measured too rather than argued from the fact that nothing there reads the new table: the same three checks, before and after every team in the benchmark corpus becomes a real object, read identical rows and identical queries.

The cost was owed as a receipt and came back one third of the estimate. The resolution counted “at most three queries” by reading the source, and said so in as many words — “counted by reading, not measured, and that is a hypothesis.” Measured with src/cost-meter.ts against real D1 in workerd: three is the ceiling and one is the ordinary cost, because the two conditions that need a derived input — the domain set, the daily counter — are fetched only when some published rule actually asks for them, and reading the rules first is what makes that possible. Which is also why the five conditions are columns and the matching is not pushed into SQL: a SQL predicate would have to bind both inputs before it could run, spending both on every send from every Node whether or not any rule mentions them. Thirty published policies cost the same as three. The seal that carries the decision went from 10 subrequests to 11, and to 17 at the worst realistic case against a published bound of 20 — so the receipt that divides that bound got a dated correction saying the headroom narrowed from 6 to 3 rather than a quietly raised number.

An approval now buys verification, and the assurance is what pays for it. The contract says a Node rechecks approval validity, authority, approver eligibility, policy and every bound hash immediately before the effect runs. An approved send gets all of it, one instruction before the transport is asked. An unapproved send gets the authority re-read it always got and nothing else — and that asymmetry is the design, not an optimisation somebody may tidy up later. Measured: the recheck is 8 subrequests against a 16-subrequest dispatch, so making it universal would add half again to every message this Node sends to buy a guarantee nobody requested. The tripwire is deliberately on the cheap path: a bound of 20 against a measured 16, which a unified path blows through immediately. Six ways it can fail closed, all of them withheld with a machine reason — because this state machine already had one convention for that and a second would read as an accident — and every one of the six is produced through the real dispatcher in the tests, not by calling a predicate. Changed evidence is produced by writing different bytes, because editing the recorded hash would have tested the comparison and left nobody able to tell the difference afterwards.

One of the six is not the system working, and it is the only one that raises. Authority withdrawn, policy tightened, a deadline passed, an approver who lost the relation — those are decisions and deadlines, and the person who needs to know is the one reading their own outbox. A body that no longer hashes to what its manifest recorded is different in kind: the archive disagrees with its own record, which is corruption or tampering. So it writes an operational log line and mailda doctor grew a finding for it, reading a partial index that is empty on a healthy Node — the query plan is asserted, not assumed, because this repository has already shipped one index that was written on reasoning and earned nothing when the plan was finally read.

The prediction the whole design rested on was wrong in both directions, and it is written down. The ticket estimated the recheck at “~6 extra subrequests” and that making it universal would take a Butler’s send step “from 10 to about 16”. The magnitude is 8, and 9 on a Node that can actually send: the estimate priced two body hashes at six operations when they are four, and its own cost table disagreed with its own prose by roughly a factor of two. The location was wrong too — the recheck runs at dispatch, which is a separate invocation with its own budget, so it never touches a Butler step’s pot at all and the receipt that owns that arithmetic now says so. The decision the estimate was drawn for survives on the measurement instead, which is the only reason to have measured it. Four times this month a number counted by reading has been wrong; this is the fourth.

An approval expires, and the deadline is a constant rather than a policy field. Four days: long enough that an approver working across a weekend and a public holiday is not defeated, short enough that an approval is not a standing permission. That is sized, not measured, and the receipt says so in those words rather than dressing a preference as a measurement. A per-policy deadline was refused for now with the reason recorded — the policy object has no expiry condition, and inventing one here would be a governance dimension backed by no interface, which is the exact failure that list of five conditions exists to avoid — and the refinement is named for whoever asks, including which way the fold has to run. Expiry is terminal: re-sealing is the invalidation mechanism, so the author composes again. Returning a lapsed send to “waiting” would make the deadline mean nothing and build a queue that never drains.

That is not the same call as the one above about case claims, where an expiry was refused outright as “a policy guess”. A claim’s age is a fact anybody can read and act on, and nothing downstream depends on the claim still being true. An approval is a statement about exact bytes that a later act relies on, and the contract binds an expiry into the envelope precisely so that reliance has an edge. Nothing sweeps it either way: a lapsed request still shows in an approver’s queue with its deadline beside it, and deciding it is honest work whose send is then withheld — one enforcement point rather than two, and the deadline visible before somebody answers rather than after.

Two kinds of circuit breaker, and the split decides every other question about them. A rate breaker is not a latch, it is a question re-asked on every send — too much, too fast, and the mail is still wanted — so volume, bounce rate and complaint rate gate a send to waiting with a reason and it goes when the window clears. An abuse breaker means this must not be sent at all, so a domain pause refuses outright and stays until a person removes it. Collapsing them fails in a specific way in each direction: all-hold lets a runaway build a backlog somebody eventually releases in bulk, which is how a loop finally sends its thousands; all-refuse throws away perfectly good invoices on a busy afternoon, and the sender’s only remedy is composing them again into the same breaker. The classification is written out per breaker rather than inferred from a threshold, because that is the one place this design could rot into whatever the last person assumed.

There is no counter, and that is the strongest property available: nothing is armed, so nothing can fail to re-arm. Each rate is a windowed COUNT(*) over rows that already exist — the shape this repository’s only working rate limiter already had, counting sign-in attempts. Nothing to increment means nothing to contend on, no compare-and-swap, and no cell that can drift from the events it claims to summarise: the number is derived, not maintained. Recovery happens because failures age out, so there is no timer, no cron dependency and no open/half-open state machine anybody must keep advancing. A Durable Object was permitted by the contract for exactly this and refused anyway: it adds a subrequest to every send, it is opaque to mailda doctor in a way a table is not, and a timer-based reset would inherit the alarm’s documented absorbing failure state — stop re-arming and nothing external notices, ever — inside the one component whose job is to notice things.

The trap was in the substrate, and it was found by reading rather than assumed. The event table a bounce rate would naturally count has a second writer: an inbound delivery report about somebody else’s mail lands in it with terminal = 1, which is indistinguishable from a real failure on any query that does not discriminate. A naive count trips this Node’s breaker on another system’s bounces — the read-a-wrong-number inversion a breaker exists to prevent, sitting in the one table a breaker would reach for. It turned out there are two kinds of foreign row, not one, and each is excluded by a different predicate: forwarded reports by the event-type filter, and Cloudflare’s own bounce events that this Node could not attribute by manifest_id IS NOT NULL. That second kind was found by deleting the attribution clause and watching which tests still passed — the first version of the test built only the corpus the ticket named, and it passed against a breaker with no attribution clause at all.

A gate that promises to clear itself needs a drain, and awaiting did not have one. Before this, a send in that state was unreachable by the dispatcher by omission — the predicate that lets a send move simply never admitted it, which is what made a policy gate a real gate. A rate gate parks a send there and promises it goes when the window clears, so the predicate gained one arm and now lives in one function with three call sites rather than three hand-written copies. The policy gates stay closed twice over: the admitted reasons are derived from the breaker list, and the seal will not write a breaker reason over a policy gate in the first place. Both halves are asserted, because a rate limiter that could release policy-gated mail is a governance bypass with a benign-looking name.

Pausing a domain inverts the legal hold’s asymmetry, for the same reason it held there. Placing a hold is one administrator and no justification, because placing only preserves and ceremony in front of it is how evidence is lost. Placing a domain pause stops a customer’s mail, so the safe direction reverses: two administrators and a mandatory reason to place, and one administrator alone to lift — because the harm of a wrongly-paused domain grows every minute it stands, and a lift waiting for a second person to wake up is an outage with a governance story attached. Same principle, opposite conclusion, which is what a principle looks like when it is real. What was removed from the lift is the ceremony, not the record.

It is the fifth approval subject, and the first one that is not about a mailbox — which made a column stop lying. Every other subject’s approvers are the approval.decide holders on some mailbox. A domain pause stops every mailbox sending from that domain, so no single mailbox’s holders have authority over it. The migration that generalised approvals to a subject named this exact case a year of tickets ago and deferred it in one sentence: that kind either names a mailbox or brings a second source for its eligible set. It brought one — the organization’s administrators — and mailbox_id became scope_id, because a column named for a mailbox holding an organization id produces a join that returns nothing, and a join that returns nothing is the one nobody notices.

A breaker nobody can see is the failure this repository keeps finding, so the reading is printed whether or not anything is wrong — and it is never a reassuring 0%. A bounce rate over too few observations reports armed: false, reason: no_observations and no percentage at all — the field is blank rather than 0, in the API as well as in the report — because 0% on a Node whose delivery channel is dead looks exactly like 0% on a Node whose mail all arrives, and an unverified number is worse than a blank: a blank prompts a question and a wrong number ends one. Whether that is a fault reads the existing blindness check rather than recomputing it: a Node sending and hearing nothing has breakers that cannot fire and gets a warning; a Node that simply has not sent much has nothing wrong and gets a figure. Failing closed on no observations was refused in one line — a Node that has never sent would refuse to send.

The thresholds are sized, not measured, and the receipt opens by saying which of its numbers are which. There is no corpus: this Node has never observed a real organization’s bounce rate, and doctor reports the reason it might never have. So eight of the nine figures are tripwires placed by arithmetic, each with what it trades off written out, and the first real corpus is the receipt’s first staleness clause. The ninth — what asking every breaker costs — is measured, at one subrequest, because all seven questions are scalar sub-selects in a single statement. Four statements would have consumed the entire headroom of the bound that exists to stop the cheap dispatch path becoming expensive. The seal went from 11 to 12 and the two dispatch paths from 16 and 24 to 17 and 25, all inside their published bounds.

What is not built is named with the evidence rather than stubbed. The resolution also specified a pause keyed on a Butler id, so that republishing a fixed Butler cannot silently clear a pause the machine placed — a good decision about an object that does not exist as a runtime thing. There are butlers and butler_versions tables now, so a Butler id can be validated — but nothing runs one, so no run can be recorded and a loop-detecting pause would have no denominator. That is the same failure eight policy dimensions and one team-scoped approval stage were already named absent for — a condition backed by no data is a policy that silently never fires, which reads as governance and is not. Filed as an issue with the evidence. Loop detection is excluded one layer down for the same reason: a breaker needs a denominator, and nothing records per-run outcomes at all. What shipped is not diminished by it — the three rates are the ones that stop a runaway sending, and a domain pause is a human act that needs no Butler.

A Butler can now be written down, checked and frozen. The node set is closed over storage that exists today, drawn by looking rather than by taste: fourteen nodes ship, and fifteen more are representable in the AST and refused at publication with a reason naming what is missing. That distinction is the point. llm.classify parses and is refused with “there is no LLM control plane”; an author who writes tomorrow’s node gets an answer rather than a parse error. template.render is on the refused side because the groundwork for the cost ticket went looking for the template subsystem and found there is none — no table, and every occurrence of the word in the Worker’s source incidental — which means the ticket caught itself putting a node on the shipping side of its own dividing line while drawing that line. What that costs is stated rather than glossed: the automation this layer ships is “assign it and draft a reply”, not “assign it and send the standard acknowledgement”.

And it can be written in YAML, which is the first format here that can hold the reason for a step. The old sentence — “§16’s YAML arrives when a YAML parser arrives in the bundle” — named no cost and no decider, so it survived three layers by being unfalsifiable. Priced instead: yaml 2.9 costs +246.2 KiB raw, +50.8 KiB gzip, about 1% of the Paid script ceiling, and it was spent because JSON structurally cannot carry a comment and a Butler is a program. Every other file in this repository puts the argument next to the code; the one that could not was the automation.

It goes one way only. There is no AST-to-YAML renderer and there must not be one: comments, blank lines and key order are not in the AST, so regenerating a document from one would delete every reason its author wrote down on the most ordinary act there is — open, change a field, save. The consequence is stated rather than left to be found: §16’s visual builder cannot edit a YAML Butler, because a graph editor writes an AST and writing one back out needs the renderer that does not exist.

The source_format column arrives in the same commit as the second value and not one earlier. The objection to it was correct for as long as it held — a column whose only value is 'json' is the placeholder shape this schema already carries two of, and a tripwire exists to catch a third. It is frozen with the text, for the reason migration 0031 was written: a published version whose format could be flipped is a frozen program that its own recorded source no longer describes.

And a Butler can be tried before it is trusted. POST /api/butlers/:id/simulate walks the draft over facts from a run this Node actually performed, causes nothing, and reports what a live run would have done — including the answer an author actually wants, which is who this reply would go to, derived by the same code the live path uses.

What makes that safe is a type rather than a flag. Every function in src/butler/simulate.ts takes a read-only environment, which is not assignable to the writable one — so nothing reachable from a dry run can seal a manifest or write a draft because it does not compile, for every write that exists and every write anybody adds. A proxy that threw would have been the effect-suppressing flag the map rejected: it fails at the write, in a branch a test has to reach.

This replaced the mechanism the map’s fifth Layer 4 answer specified, and the substitution is recorded rather than quiet. That answer withheld a transport capability. A Butler run has none to withhold — nothing under src/butler/ names the mail binding, and mail.send.propose writes a sealed manifest that a separate later invocation dispatches — so the property it wanted was already true for a stronger reason, and the capability that actually needed withholding was the database write. Ranked by danger the answer’s own example came last: a proposed send is parked behind an approval, while assigning a case is gated by nothing.

It is one walk, shared with the engine, because a simulation that diverges from the engine is worse than no simulation: it is a tool that tells authors their Butler is fine. And what it cannot know it says out loud — the send’s policy decision, breakers and approval gate happen when the manifest is sealed, so a report says a send would be proposed, never that it would be sent.

Publishing is the versioning event, and it dissolves a question rather than answering it. A published version cannot be edited at all — the edit goes to a draft, and publishing is a deliberate second act — so there is no dilemma about what a comment-only change does. Resubmitting the same bytes is refused: a version representing no decision is one a run would bind and a reader would be asked about for nothing. Reformatting the text does mint a version — the source text is half of what a version freezes — and the version says plainly that the program did not move, because the AST is stored canonically: sorted keys, no whitespace, null and absent identical, integers only. That canonical form is not what makes the refusal work, and the tempting sentence claiming it is has been struck: an AST is derived from its source, so identical bytes already give an identical AST. What it makes true is that ast_sha256 fingerprints the program rather than its formatting. And “frozen” is enforced by the database, not by the write path: a trigger aborts any update that touches a published version’s AST, its source text, either digest or its version number — or that tries to walk it back to a draft, which was the two-statement way round the first four. The test asserts it by trying rather than by reading the code. Deletion is deliberately not blocked, and the reason is written down — immutability and indestructibility are different properties, and an organization-deletion path is a good widget that a trigger would have trapped forever.

Every loop declares its own bound, and a bound that is exceeded fails. It never truncates: “replied to 100 of 340 customers and reported success” is not lost work, it is a system reporting something untrue about work owed to customers.

And a Butler that cannot afford to run is now refused before it can try. The checker used to verify a bound was present and well-formed and say nothing about whether it was affordable, because that arithmetic had moved twice in one week and the pot it divides is plan-scoped while nothing inside a Worker can detect the plan. The seam was named and left empty rather than filled with the wrong number. It is filled now, and four things about the filling are worth more than the feature.

A loop cannot be priced in isolation, which is why the answer is 498 and not 500. The receipt’s headline is 10,000 / 20 = 500 sending items per run. No loop is alone: in the worked example the guard, the assign, the draft and the propose around it have already spent 38, so the pot has room for 498 and the 499th is over by eighteen. The rule is therefore sum the graph — every non-loop node’s fixed cost, plus maxItems × per-item cost for each loop, with nested loops multiplying. That also catches the shape a per-node check would wave through: 3,334 case.close nodes, the cheapest effect in the set, no single one of them large, and together two subrequests over the pot.

The figures were re-measured before anything divided them, and one of them has run out of headroom. All four per-node costs were measured on 14 August and three Layer 5 changes had since added I/O to the send path, which is the receipt’s own first staleness clause. Re-run: case.assign 5, case.close 1 and draft 5 are unchanged, and mail.send.propose went from 10 to 12 on a bare new thread and from 14 to 16 on a reply. The worst realistic seal — a reply, both derived policy conditions, an approval gate and the breaker query — now measures 20 against a bound of 20. The bound holds and it is left alone; what is said plainly instead is that its headroom is zero, so it has stopped being a tripwire past where any good widget goes and the next operation added to a send breaks the measurement rather than sneaking past it. That is the intended behaviour of a receipt, not a nuisance. A fifth node was measured for the first time: lookup shipped with “its cost is unmeasured” written into its own declaration, at 1 subrequest for all five entities, and the price list is now exhaustive over the shipped set by construction — the next node that ships without a measurement does not compile.

Which plan’s pot to divide was a real choice and it is argued in the code. The pot is 10,000 subrequests per Workflow instance on Workers Paid and 1,000 on Workers Free, and a Node cannot ask which it is on — doctor reports that gap rather than guessing. Dividing the Free figure would refuse a foreach of 200 sending items, which is the fan-out this repository reaches for elsewhere, so the tripwire would sit before where a good Butler goes; and the permissive direction lands only on a plan ADR 25 already refuses at install. So it divides Paid — and every refusal prints both rows and the affordable maxItems under each, because the one-click install verifies no plan at all and an operator on Free should meet that arithmetic in a refusal rather than in a dead run.

The refusal names the arithmetic, and the boundary of what it can claim is asserted rather than assumed. An author reads which nodes outside a loop, which loop, its bound, its per-item cost, their product, and the bound that would have fitted. It also distinguishes the two failures it sits between: an exceeded maxItems fails the step and processes nothing, while an overspent pot is the platform killing the invocation wherever it had got to, after the effects it already performed. And a loop whose body performs no I/O is affordable at any bound, including a million — true in subrequests, the only currency with a measurement behind it, since CPU cannot be metered from inside a Worker at all. That case is a passing test rather than a footnote, so the limit of the claim is pinned in the same place as the claim. Writing an invented per-iteration cost to make the million look handled would have been the one thing this whole mechanism exists to prevent.

One defect fell out of needing a fixture. Pricing a lookup meant writing one, and a lookup could not be written: its schema declared a field called id for the row to read, which the spread quietly put over the node’s own identifier, so the node had four fields where it should have five and one id was doing both jobs. Its identifier had also escaped the node-id pattern entirely, making id: "${event.case_id}" a legal node name. The field is entityId now, and the collision is a compile error rather than a convention — no future node’s shape can declare id or type at all.

One divergence stopped being latent. The contract required a case id spelled case_; the Node minted cas_. A case id this Node produces could not pass its own contract’s validation, and it was invisible only because the field was optional and nothing populated it. Butler nodes name case ids, so it had to be settled: the runtime won, because its spelling is on every row of every installed Node while the contract’s had never matched anything — no data moved. The prefix now lives in one registry both sides read, with the pattern built from the ULID alphabet rather than beside it, and a closed-world test that refuses any hand-written identifier pattern anywhere in the repository. That test found a second divergence the day it was written: a field validating a sender identity against the send manifest’s prefix, for an object that has no table at all.

And a Butler now runs. Mail arrives, and every published Butler whose trigger names that mailbox starts — one generic Workflow class interpreting whatever ast_json it reads, so publishing one still needs no deploy. That genericity is not tidiness: a workflow outlives the Worker that declared it (measured — deleting the script left it behind, and it took wrangler workflows delete to remove), so a class per Butler would have left one orphaned account-level resource per published Butler, for ever, invisible to the Worker. There is exactly one, whatever comes and goes. Full account in docs/butler-engine.md.

The instance id is the Butler version plus the delivery, and that makes §16’s forbid free. create({ id }) throws on a duplicate, so the same message cannot start two runs and the refusal comes from the platform rather than from a check we wrote — the same conflict-is-the-signal shape already carrying the audit sequence and the claim CAS. It matters because the trigger hangs off an at-least-once pipeline that will see the same delivery twice. Three things kept separate because each is easy to conflate: the run id is not an ADR 9 effect key (it dedups the trigger; every send still mints its own), the dedup window is 30 days because that is the instance retention rather than our design, and createBatch is refused outright — it silently skips a duplicate id and drops it from the returned array, measured at 4 requested and 1 returned with no error.

A Butler’s principal is the Butler, and this is the decision with the most reach in the layer. Not the administrator who published it. Four reasons, and the fourth is the one that turns a constraint into a feature: borrowing the publisher’s identity would grant the program everything that person can ever do, put their name on mail they never saw, silently remove them from the approver pool for every send it proposes — and make it impossible to write a policy about a Butler at all, because the actor condition compares an id. As itself, a Butler holds only the tuples an administrator granted to its btl_ id, which means a freshly published one can do nothing until somebody says otherwise and revoking stops it on the next node. It cost the schema nothing: identifiers are typed-prefix ULIDs, so actor_kind = butler is derived from the id rather than passed by each call site — which is what stops attribution being a thing every future effect node has to remember.

A Butler cannot put mail on the wire. Every send it proposes is sealed awaiting a human release, and both halves of that are necessary: without the D1 state the outbox sweeper hands the bytes over the moment the hold window elapses, and without the parked waitForEvent a release cannot resume the program. So the gate is in the database and the waiting is in the Workflow — which costs no concurrency, so ten thousand proposed sends are ten thousand sleeping instances and no capacity. A send therefore stays releasable after its run has expired, because instance state lasts 30 days and a manifest lasts for ever. A timeout ends the run and never the send. The release takes send.propose — the authority composing it would have needed — and the trail names the person, while the seal names the Butler.

Being refused is the system working, so it is an outcome rather than an error. A policy denial, a paused domain, an approval nobody can give, a case somebody else is holding, a relation that was never granted: each is recorded against the node that met it and the run carries on. Three of those are tested against the real machinery rather than a stub — a real published deny policy, a real two-administrator domain pause, a real stage set whose approvers were revoked. What that costs is stated rather than glossed: the shipped AST has no failure edge, so a Butler cannot say “if the send was denied, assign it to a human instead”. It can only be read afterwards.

The one thing a Butler must not have is a path a person does not have. So case.assign is claim, case.close is close, draft is saveDraft and mail.send.propose is sealManifest — not copies. Which means case.close closes only a case the Butler is holding, exactly as it does for a human, and the shipped way to reach that is to assign it to ${butler.id} first. Widening close for the program’s convenience would have changed what closing means for everybody. The engine’s own additions are three: resolve the expressions, record what happened, and check the Butler’s authority where the function checks somebody else’s — which is the gap that mattered, because claim verifies the assignee and a Butler holding nothing anywhere could otherwise have assigned any case in the organization to anybody who may work it.

A stored AST is data, and data can be edited by whoever holds the database. So the checker runs again at run start, for free, and a reserved node written straight into the row makes the run refuse itself before any effect — with the checker’s own sentence, “there is no LLM control plane”, in the reason and the log. Not a crash and not silently skipped. The same pass catches the other three things a hand edit could introduce: a cycle, a dangling edge, and a graph that cannot afford itself.

Measuring a run against the checker’s prediction for the same AST found them disagreeing, and that is worth more than either number. #54 prices the functions a node calls; a run costs the nodes, and a node is the function plus the Butler’s own authority check plus its record row. Four of the five fit inside the headroom those bounds already carry. The fifth does not: mail.send.propose measures 23 against a bound of 20, because the node reads the draft back before sealing it. At two nodes that is a rounding error — 32 against a predicted 30. At loop scale it is not: a foreach of 500 sends prices at exactly the Paid pot and really costs 11,503, so the instance dies at about item 434 having already sealed 434 manifests, which is precisely the failure the publication-time refusal exists to prevent.

So #54’s arithmetic was left alone and the engine was given a live meter instead. Editing a closed ticket’s figures from inside another ticket’s work is how a receipt stops describing what it says it measured — its numbers are correct measurements of the functions they name. What changed is that the run now counts its own spend, carries the total across invocations on its record, and refuses an effect it cannot afford before performing it, with the budget, the limit, the ask and the receipt in the operational log. The 500-send loop stops at item 357 with something a person can read. Three numbers, and the distance between the first and the last is the whole finding: 500 admitted at publication, 434 actually affordable, 357 permitted by the guard. The guard being strictest is the right direction — refusing one send too early costs a run that could have finished, and refusing one too late means it has already gone.

A Butler that talks to itself gets stopped, and the thing that made that possible was already in the schema. #66 designed a Butler pause — a latched row, keyed on the Butler rather than on a version, so republishing a fixed one cannot silently clear a pause the machine placed — and then named it absent, because there was no butlers table to key one on and no run record to place one from. Both exist now, so the decision was implemented as written rather than re-argued. What did get re-argued was the loop, because #66 had said detecting one needed a causal record that did not exist. It did exist: a manifest carries the Message-ID this Node emits, an inbound reply stores what it quoted with the brackets already stripped, and a run’s effect row names the manifest it sealed — so “this delivery is a reply to a send this Butler made” is a join across three tables that have been there since Layer 2. Checked rather than inherited, which is the only reason this ticket built the loop that matters instead of the one that was easy.

So the loop that ships is causal, and the easy one is what is now named absent. A windowed count of runs would have been trivial — butler_runs supports it in one COUNT(*) — and it has no threshold anybody can defend, because a Butler’s legitimate run rate is its mailbox’s inbound mail rate and nothing here has measured that for even one organization. What is counted instead is self-provoked runs: links of a chain this Butler made itself, inside an hour, and over three of them it is paused before the next run starts. Three absences go with it and each has a reason rather than a shrug: an unthreaded reply has no link back and is invisible, a loop through two Butlers counts for neither, and — the one that bounds the whole feature today — a Butler’s send cannot leave this Node without a person releasing it, so a real chain needs an administrator clicking release at every hop. What this catches now is a human-assisted loop; what it exists for is the day that gate moves, because at that moment a chain with nothing counting it is a sending loop with no bound at all. Saying which of those two it is was the whole point.

Asking costs nothing, which is a design property rather than a small number. Both evaluation points ride on statements that were already being issued: the pause and both loop counts are sub-selects on the read of published versions the trigger already makes, and the run-side question is a sub-select on the read of accumulated spend the interpreter already makes once per invocation and already must not cache. That second one is not symmetry — a workflow outlives the Worker that declared it and a wait node reaches 365 days, so a pause that stopped new triggers and let ten thousand parked instances wake up and act would be a pause in name only. Measured: the trigger costs 3 subrequests with a live Butler and 2 with a paused one, because a paused Butler starts no run at all. A control that exists to stop a runaway makes the runaway’s own path cheaper.

The machine places it and one administrator resumes it, which inverts the domain pause’s ceremony and for the same reason it had ceremony. Stopping a customer’s mail takes two administrators and a written reason, because somebody should have decided it. Stopping a customer’s automation takes nobody, because a breaker that waits for a person is not a breaker — the mail still arrives, is still filed, and is still answerable by hand. The resume is one administrator alone, because an automatic pause nobody can resume is an outage and placement needed no administrators at all; it is org.admin and not anybody, because one anybody can resume is not a pause. And the reason is mandatory here where a domain lift’s is optional: a domain pause already had two people’s judgement in it, and this one has none anywhere except at the resume. Republishing does not resume, deliberately, and that is this feature’s loudest test — a fix needs an explicit decision that it is safe to run again, which is the act somebody should have to perform.

A paused Butler produces silence, and silence is what doctor exists to distinguish from health. So the harder finding is not “which Butlers are stopped” — it is “has one stopped producing runs”, which from the run record alone is indistinguishable from a Butler nothing has triggered. The discriminator is whether mail actually arrived at the address the trigger names: the address parsed out of the frozen AST, the arrivals from one grouped read. Mail arrived and no run started is a fault; no mail arrived is not; a stored AST that will not parse is a fault of its own. Anchored on the publication instant rather than on a window, because a window would have needed a figure for how long may a Butler legitimately go without running and a Butler on a quiet mailbox may honestly go a month. A third finding refuses to call the loop detector armed on a Node whose inbound mail carries no threading at all, rather than reporting a reassuring zero — which is the same refusal the bounce-rate breaker already makes about a dead delivery channel.

And an index for something else nearly turned a diagnostic into a table scan. The causal join needs send_manifests reachable by the Message-ID a reply quotes, and written the way every other index in this schema is written — leading on org_id — it displaced the partial index doctor’s evidence-mismatch check depends on, turning a seek into an empty B-tree into a scan of every manifest ever sealed. Found by a test that reads the query plan from the planner rather than trusting a comment, in the same change rather than six months later. It leads on the Message-ID instead, and is UNIQUE, so two manifests cannot claim one — which would have let a single reply attribute to two sends and counted a chain that never happened.

A Butler can no longer choose who mail goes to, and the reason that had to change is worth reading twice. §16 has always said untrusted content must not select or construct To/CC/BCC. The node set charted before that was settled gave draft a to list of expressions, an expression may read event.*, and event.* is the inbound message — so a published Butler could send to an address chosen by the mail it was answering. Nothing objected, because the thing that would have objected was the tripwire the same ticket asked for and had not built. The absent guard and the open sink were one omission, which is the shape this repository has now paid for four times.

So the parameter is gone rather than guarded. A guard has to stay right for ever; an absent parameter has nowhere for a value to arrive. The Node addresses a Butler’s reply itself, from the parent delivery — the envelope sender of the message that triggered the run, which is RFC 5321’s return path and what RFC 3834 requires an automatic responder to answer. Not the From: header and not Reply-To:, and that is the whole point rather than a detail: a header is content, so honouring one would be the same sink wearing a different name. A message forged to say From: finance@victim.example gets its reply at the address that actually sent it, and there is a test that sends exactly that message. A bounce — MAIL FROM:<>, no return path at all — is refused, because every available default is worse: the header reopens the sink, the mailbox is a loop, and a manifest with no recipients is not a send.

And the loop in that sentence turned out to be reachable, which is why driving a thing beats reading it. Nothing stopped the derived recipient from being the address the delivery arrived at — a message whose reverse path is support@acme.example, delivered to support@acme.example, sealed a reply from that address to that address, which comes back in and does it again, and forging MAIL FROM is all it takes to start one. The sentence had been a reason not to default to the mailbox; it was not a rule anything enforced. It is one now, and the honest limit is stated with it: the check compares one address against one address, so a loop that runs through a second mailbox or between two Nodes still passes. Breaking those needs Auto-Submitted, which nothing in this repository emits or reads.

What it costs is real and is written where authors meet it: a Butler cannot CC a colleague, add a supervisor, or forward anything. All three mean naming somebody who is not the correspondent, which needs a trusted recipient, and there is no contacts table, allowlist or suppression list anywhere in the schema. They arrive with that store rather than with a parameter that would accept whatever an expression produced. A person is not constrained by any of it — the draft store takes the recipients the request gives it and derives nothing, which was verified rather than assumed and is now pinned by a test, because a change that made the store clever would hand every Butler a recipient back through the same function.

And the checker that was planned for this became a tripwire instead, which is a reversal recorded rather than made quietly. A dataflow checker at this layer would have had nothing to refuse — the sink is closed by construction — so no test could prove it refuses, and a green suite would have established only that the analysis never fired. That is the exact shape of three defects found here in one day. What is built instead is structural and fails today if broken: a shipped node’s parameters are closed and strict, so to, cc, recipients, escalateTo and forwardTo are all refused at publication by one rule that knows none of their names — and a test pins the entire parameter surface of the node set, so a recipient cannot come back without something failing and naming §16. Both halves were proved by putting to back and watching them fail. The dataflow checker lands with connector.* and llm.*, at Layer 6, where there will be something to refuse.

Re-verifying the other ten sinks corrected two entries, and one of the corrections matters. “Sender identity is closed structurally” was half true: From is the mailbox’s address, and the mailbox is chosen by an expression, so untrusted content can reach it. It is closed by validation against trusted organization state — §16’s own escape clause — rather than by construction, and the asymmetry with the recipient is exactly why the two are handled differently: a recipient had nothing to be validated against, while a mailbox has the tuple table, which only an administrator writes. Both arms are now asserted: content naming a mailbox the Butler was not granted is refused, and content naming one it was granted works, which is the residual stated rather than implied by silence. The other correction is smaller and was simply stale — policy does have a table now, and recipient externality is one of its conditions, so policy selection had been riding on the recipient parameter all along and closing that closed this too. A third thing fell out of the same pass: a Butler on a multi-address mailbox cannot send at all, because it has no way to name which address it sends as and nothing will let a created_at decide what every recipient sees.

One thing is stated and not fixed. Deriving the recipient closes the selection — nothing an author wrote and nothing in the message decides who receives it — and it does not make the envelope sender trustworthy. A spoofed reverse path aims a reply at whoever it names. What stands between that and an unattended exfiltration path today is the human release gate on every Butler send, which is the gate the pause work already flagged as one that may later be outranked. The proper answer is the same missing trusted-recipient store, and saying so is better than a paragraph implying the problem is closed.

A run can now be replayed, and the rule that makes that safe is the one this ticket’s own body got backwards. §16 says a replay never reuses an old idempotency key for a materially new effect, and the tempting reading — materially new means a different manifest id — is exactly wrong. ADR 35’s property is directional: a manifest id is a time-and-random ULID with no content constraint behind it, so the same id implies the same content and a different id implies nothing at all. A replay that reproduces a message byte for byte always gets a new id, so an id-based rule would call it new, mint a fresh key and hand the same message over twice — the precise thing that sentence exists to prevent. So it is decided by content: the envelope plus the normalized body’s SHA-256, hashed by one function using hashes the seal already computes, derived rather than stored so it works on every manifest this Node has ever sealed. Same content means the old key is reused and nothing is sealed, written or sent; different content means a new key, and any approval bound to the old bytes is moot because approval binds an id. The headline test is a replay of an unchanged run producing one manifest where the rejected rule would have produced two.

retry-effect is offered only where non-acceptance is proven, and the unprovable case got its own name rather than a flag. §16’s precondition named a reconciler that does not exist — the only one here reconciles R2 evidence against ingress receipts — so the four things that can actually be proven are recorded outcomes: refused, throttled, suppressed, and an authored send whose submitted bytes were never written, because that column is set before the first submit. A recipient’s attempt count is not a proof and is not consulted: it is updated after the call resolves, so a dead isolate leaves it at zero with the bytes already gone. Where the proof is missing the mode is absent, not failing, and resend-may-duplicate is what exists instead: human-only, refusing without an explicit acceptance of the risk and without a reason, minting a new key deliberately because the old one may already have been handed over, and audited under an action naming the person rather than the author. Two names because two epistemic states; one button for both would have put the safe act and the duplicate-risking one behind the same click. The rule is a total map over the send states rather than a list of ones to exclude — outcome_unknown is the default for anything unrecognised, so the unprovable population is the one that grows and a denylist guards only the spellings its author thought of.

A replay inherits its input and re-asks its judgement, and every one of those was decided rather than inherited by default. The trigger facts are frozen on the run record and replayed unchanged, because re-deriving them describes now — a case created since, a conversation merged since — and a run over different input is not a replay of anything. Policy, authority, approvals, the rate breakers, the domain pause, the Butler pause and the version’s publication state are all re-asked, so a replay cannot do what the live path would now refuse; a policy published after the original ran refuses the replay, and that is a test rather than a claim. A legal hold is in neither list on purpose: it governs destruction, not sending, and inventing a coupling would be a control nobody asked for. The ledger itself is four columns on the two tables the engine already had rather than tables of its own, because a second account of one run is two truths that can disagree — and the replay’s cost counter starts at zero, since the pot is per instance, which is what makes a run killed for budget replayable at all.

Reading what a Butler did is not free of the mail it read, and inspect had to be gated for it. A run’s recorded input is the event.* root, and that carries the triggering message’s subject line and sender — mail content, which the fact set’s own declaration says of the From: header in as many words. The route is org.admin, which is a relation on the organization and appears nowhere in this Node’s table of who may read a mailbox, so the mode shipped as a way for an administrator holding nothing anywhere to read the subject and sender of every message any Butler ever processed, with nothing recorded. The fix classifies every fact once, as a total map beside the fact set so a tenth one cannot arrive unclassified and an unknown key in a stored blob is treated as content, and withholds the content half unless the reader holds the mailbox’s metadata or content relation — or a live supervised grant, which #63 built for exactly this investigation and which records itself before answering. Ids, states and tokens stay, so an auditor with no mailbox authority can still see which program ran over which delivery and what it did; what they get instead of a subject line is a named list of what was withheld and the authority that would open it, because a redaction a reader cannot see is a hole they read past. And the raw column is no longer on the run row at all: it had one careful gate and three responses serializing the row beside it, so it now has exactly one reader named for what it returns.

A replay of a send that was decided against says so, rather than reporting success. Reusing the original’s key is right whatever state it is in — that is what stops a duplicate — but “this message is on its way” is false of a send a policy withheld or a person cancelled. “A policy wrongly denied a Butler’s send; fix the policy and re-run” is the most obvious use of the mode there is, and it was a no-op answering ok. It now records a refusal naming the decision, still against the original key, because re-composing there would open a real duplicate path rather than close anything.

And one Layer 2 invariant turned out to assume a first attempt. drafts_one_per_reply says replying twice to the same message resumes the draft that exists — written about a person, and it binds a program too. A replay drafting the same reply died on the constraint before its first effect row, recording engine_fault and nothing else. The fix is the index’s own sentence: on a replay the draft is resumed rather than re-created, and the argument against upserting on the ordinary path survives untouched because the lookup is bound to the Butler’s own author id, so the widest thing it can find is a draft the same program wrote.

A published Butler now says what it may ever do, and its authority is capped by the person who published it. Layer 4’s shape says a step’s effective authority is pinned ceiling ∩ live tuples of the Butler ∩ live tuples of the sponsor, and until now only the middle term existed — so a Butler was exactly as powerful as the list of tuples naming it, and an administrator granting one a mailbox next month silently widened a program published in June. The ceiling is capabilities: inside the AST, which is where §16 always put it and which is what makes “frozen with the version” cost nothing: the trigger that already refuses to move a published program refuses to move its ceiling, and the digest that already fingerprints the program fingerprints its authority bound. Publication proves the ceiling’s action set is exactly what the graph needs — an action a node needs and the ceiling omits is refused, and so is an action the ceiling declares that no node needs, because a ceiling padded just in case is a ceiling that does not bind. What publication cannot prove is which mailbox, because a node’s mailbox is an expression and the AST package does not parse expressions; that half is enforced per step, in the statement that was already asking about tuples.

The sponsor is the publisher, and that is not the identity #50 rejected. #50 refused the publisher as a Butler’s principal on four counts, and every one of them is about identity: whose name is on the mail, who is excluded from approving it, what a policy can tell apart. An intersection can only ever subtract. So capping a Butler against its publisher’s live authority grants it nothing — it still needs its own tuple — while making a mailbox the publisher cannot reach unreachable to the program they published, which is the direction §7 asks for. Revoking the sponsor’s relation stops the Butler on its next node, visibly: the effect row says sponsor_lacks_it and names them, because a ceiling that quietly empties and a Butler that quietly does nothing look identical from outside. Migration 0031 froze published_by, closing a one-statement hole the freeze trigger did not know it had — a swappable sponsor is a ceiling that is not pinned, and swapping it left the AST untouched and every digest matching.

Three terms, two queries, and one line that must not be simplified. The ceiling is free — it rides on the version row the run already loaded — the sponsor’s teams are one query, and both tuple terms come back from one more. The trap is that subject_id IN (butler, sponsor, …teams) answers “does any of these hold it”, which is an OR, while the intersection needs an AND: written that way, a Butler holds whatever its sponsor holds and nothing notices. What converts one into the other is selecting DISTINCT subject_id rather than 1, so the query returns which subjects hold the relation and the conjunction is evaluated on the result. Measured rather than inherited: every node grew by exactly the round trips that derivation predicts — a send from 23 to 25, a lookup from 2 to 3 — and every bound already had the headroom, so no number moved.

What is still not enforced is said rather than implied. A loop whose body performs no I/O costs nothing, so it is affordable at a billion — true in subrequests, the only currency with a measurement behind it — and the engine does not refuse it either: it runs until the platform’s CPU limit kills the step. And a workflow’s name is account-scoped and cannot be omitted, so #72’s fix for the queue is unavailable here; what is enforced instead is that the name derives from the Worker’s own, which makes a rename one edit. The residual is named: Workers Builds overrides the Worker’s name, so a second install into one account gets a different Worker and the same workflow name. That has now been measured, and it is the worse of the two possibilities (receipt): deploying the second Node exits 0 with no warning and the Workflow’s ownership moves, so the first Node keeps a binding into code and bindings that are no longer its own. The queue case collided silently; so does this one. mailda deploy refuses when the Workflow already belongs to another Worker, which is a guard at the one moment the collision can be created rather than a naming rule the config enforces.

Mail older than the newest fifty could not be reached, and the fix is a cursor that carries no authority. listMessages ordered by arrival and took fifty, with no cursor, no offset and no mailbox filter — so the fifty-first message was not slow to reach, there was no parameter a caller could pass and no control the interface could render that would return it. On a mailbox taking twenty messages a day that is three days before the product that is meant to be a system of record stops showing its own contents. The bytes were never lost; the archive was present and unnavigable, which for mail is close enough to matter the same way.

The part that needed care is that a page is a position inside an authorization scope, not just a position. The listing authorizes in SQL and §7 requires the live relationship on every operation, so a reader’s scope moves between page one and page two: a supervised grant expires, a team membership is revoked, a mailbox relation is removed. A cursor that remembered the mailbox set page one resolved would be fast, obvious, and would disclose rows the reader may no longer see. So the cursor is (accepted_at, id) and nothing else, and every page re-runs the whole authorization — which is also why it needs no signature: forging one moves your own position in an ordering you are re-authorized against. The test the design exists for revokes access between the two pages and proves page two cannot return a row the revocation removed, against both structures that answer “who may read this mailbox” — a standing relation somebody deletes and a grant that runs out of time.

Keyset, not OFFSET, and the reason is arrivals rather than taste. Mail lands while somebody is reading, so an offset counting rows from the top would skip one message and repeat another on every page turn. Two things came out of measuring it. ingress_receipts had no index on the column the listing has ordered by since Layer 1 — so every inbox load already scanned the table and sorted it, 6,004 rows read on a 1,200-delivery corpus against a 1,000-row budget, invisible because the fixtures hold three messages. And the obvious one-line cursor — comparing accepted_at || ' ' || id as the export path does — is correct and not an index constraint, so it reproduced OFFSET’s cost curve inside the change made to avoid it: page twenty read 1,176 rows. Two predicates and one migration later, page one reads 208 and page twenty reads 210. The fifty is now messages.page_size with a measurement behind it, sized under the tighter of two ceilings — what the list budget allows, and what fits one audit entry, because §7 records each page as an act and a page that splits its record stops being one row per act. (receipt)

The page control is two buttons, and the parity work is the larger half. Older is rendered exactly when the Node says there is at least one more row this reader may see — an absent control rather than a disabled one — and newer is a pop of cursors already used, so nothing asks for a backwards cursor that does not exist. The heading says 50 shown rather than 50 messages, because nothing counted a total and printing the page size as one was the old wording’s quiet lie. What took longer: a fix that taught only the browser to page would have left getMessages() on page one for ever in the SDK, the Skill and MCP, which is #91’s own defect rebuilt in three surfaces. Query parameters are now part of the route registry, so all four learn them at once.

It sends and receives. Two Mailda mailboxes on the same domain exchanged mail through Cloudflare — sealed into an immutable manifest, dispatched, received, parsed and threaded. Both send APIs and both MIME forms were verified end to end.

It does now receive mail. One Worker, deployed to a real Cloudflare account, accepted a genuine Gmail message through Cloudflare Email Routing, stored it encrypted and framed, and served it back byte-identical to a signed-in human — verified by SHA-256 against the original, with the sender’s real Received: chain and DKIM signature intact. That is the whole of what works: one message, one mailbox, one authorized reader.

Sign-in is real too — email and password, ES256 access tokens, rotating refresh tokens, key rotation that does not sign anyone out. Everything else in the contract above is still unbuilt.


A machine can act in its own right now, under a named person’s authority

Section titled “A machine can act in its own right now, under a named person’s authority”

An agent holds its own credential: an opaque token, an action ceiling pinned when it is minted, a hard expiry with no refresh, and revocation that takes effect on the next request. Its acts land in the audit trail under the agent and under the human accountable for it.

Three things bound what one can reach, and two of them already existed:

effective(agent) = pinned action ceiling ∩ live tuples of the agent ∩ live tuples of the sponsor

The middle term is ordinary relationship tuples, so an agent’s resource ceiling is its tuples — conferred by an administrator through the same door as every other relation. The action ceiling is pinned at mint, derived from the route registry rather than restated, so a route reclassified as governed leaves every agent’s reachable set on the same commit. There is deliberately no route that widens one.

What a route requires is declared beside the route

Section titled “What a route requires is declared beside the route”

The tier answers may a machine do this. It does not answer can a machine be given what this needs, and treating one question as both was worth 22 tools an agent token could never satisfy. So each route declares its authority — public, member, recovery, organization, mailbox with the relations, filtered with what narrows the result, self-or-admin, or export — and everything downstream is derived from that one declaration: the capability vocabulary, what the mint refuses, the MCP catalogue, the Skill’s withheld list.

public and member were one value called none, documented as “reaches nothing scoped” and applied both to GET /health, which answers a stranger, and to GET /api/me, which answers 401 without a principal. Five routes were declared that way while requiring a session. Nothing leaked — the handler stayed stricter than the declaration — and it was still a registry saying something false, in the one scope the parity suite did not drive. A scope with no driver is a claim nobody checks.

A declaration is hand-written, so it is checked against the handler rather than trusted. test/route-authority-parity.test.ts drives every organization-declared route as an administrator and as an ordinary member, and only admin succeeds, member does not confirms one. It found five routes claiming to need org.admin whose handlers require nothing of the sort — GET /api/teams says so in its own comment — and each was withholding machine authority the Node would actually have allowed.

The distinction that took a second function is reachable versus useful. GET /api/approvals admits any authenticated caller and narrows its list to mailboxes where the caller holds approval.decide, which no mint confers. An agent is let in and shown an empty queue, for ever — worse than a refusal, because nothing says no and the operator sees an agent that looks broken rather than a catalogue that lied.

Minting checks the intersection, which nothing did: both halves of an agent’s authority were validated and their product was not, so capabilities: ["mail.read"], grants: [] produced a credential that authenticates and reaches nothing. A capability is satisfied when one mailbox carries all of its relations — summing them across mailboxes satisfies neither — and the same predicate disables the button and refuses the route.

Minting is administrator-only and the sponsor is named rather than assumed, so the person who authorises a machine identity need not be the person whose authority it borrows. All three agent routes are withheld from machines: an agent that could mint agents escapes its own ceiling in a single call.

The sponsor term was written down before it was enforced

Section titled “The sponsor term was written down before it was enforced”

The third term was the whole point and it constrained nothing. principalFor set the delegator, the trail recorded it, and no authorization query read it — so an agent kept reading a mailbox after its sponsor’s relation was revoked, after the sponsor left the team that granted it, and where the sponsor had never held it at all. The sentence a human cannot delegate more authority than they continue to hold was false in the header of the file responsible for it.

It is now one clause in one module, and the two decisions worth knowing:

  • The sponsor is derived from the identifier, not passed along with the request. A threaded field reaches as far as a Principal travels, and three of the places that needed it have no Principal to travel in — isAdmin takes a bare id that thirty callers feed, and the send path re-checks the author’s authority at seal time from a database column. Deriving it from the typed-prefix ULID is the same move that made actor attribution structural instead of remembered.
  • Fixing the one check fixed one route. Mailbox read, send and export all land in the same check, which made the fix look complete; the listing, both arms of search, the dispatch sweep, the case queues, the sends listing, the notifications feed and the admin check each had their own predicate, and every test passed. A tripwire now enumerates every predicate that reads the table and fails until each one is classified as intersected — with its source checked for the term, so registering a name is not enough — or exempt with a written reason. Three of the clause’s own terms were found by deleting them and watching nothing fail.

The delegator was recorded by four call sites and readable by none

Section titled “The delegator was recorded by four call sites and readable by none”

The column shipped inside the hash chain and then sat almost unused: four audited acts in the whole product populated it, and GET /api/audit never selected it. So an agent’s act named the machine and not the person, and the audit screen showed no actor at all — not the identifier, not the kind, not the delegator.

It is now derived from the actor’s typed prefix at write time and stored, rather than threaded through every audited operation as the review suggested. That argument was already in the file: deriving the actor’s kind from its prefix is what made attribution structural, because a design where each call site passes it “would be correct on the day it was written and wrong the first time a new effect node called a fifth function”. A delegator is the same kind of fact. An explicitly passed value still wins, since a Butler’s sponsor comes from its pinned ceiling — better information than a lookup, and the answer that must not drift. A person acting for themselves pays no query for any of it: the derivation returns on a prefix test.

Choosing what a machine may do, in the product’s words

Section titled “Choosing what a machine may do, in the product’s words”

The mint surface took route strings, so setting up an agent meant composing a routing table by hand — and a hand-assembled ceiling has no completeness: reading mail takes four routes, and granting three produces an agent that works until it needs the fourth. Named capabilities now cover every grantable route, each with a sentence saying what granting it does and a flag for whether it reaches the content of mail rather than only its metadata.

Capabilities are expanded at mint and the routes are what get stored. Storing the name and resolving it per request would mean adding a route to a capability silently widened every agent already holding it — §16’s rule broken — so the expansion is pinned. The consequence is visible rather than hidden: an agent minted before a capability grew shows 4 of 5 in the interface, because that is what it holds. A closed-world test requires every grantable route to belong to exactly one capability, so a new route cannot quietly become grantable-in-principle and unconferrable in practice.

There is a screen for it now — /agents — with no edit control, because there is no route behind one. Re-minting is how a ceiling changes, and it issues a new token with a new expiry.

This is not an AI capability. Whether the thing holding the credential is a language model, a script or a cron job is outside it. The llm.* nodes remain declared and refused, and calling Mailda AI-native today would still be a claim about intent.

An act a machine performed now names the human accountable for it

Section titled “An act a machine performed now names the human accountable for it”

The audit trail had one actor field. A Butler’s entry correctly said “btl_x sealed this” — the kind is derived from the identifier’s prefix, so that half has been structural since Layer 4 — and could not say who sponsored it. The sponsor was recoverable only by reading the Butler’s current sponsor_user_id, which can be reassigned, so the trail’s answer to “who was accountable for this act” changed months after the act, outside the hash chain that exists to stop exactly that.

Entries now carry a delegator, and it is inside the hashed form — a field the chain did not cover would be one an operator with database access could rewrite undetected, and “who was accountable” is precisely the answer somebody would want to change. Adding it to a live chain works because the delegator is appended only when present, so every hash written before the column existed still recomputes to itself.

This is the first layer of #109, and it is worth having on its own: it fixes a shipped gap for Butlers rather than preparing for something. It also removes the premise of the decision that withheld an agent credential — mcp.ts argued a token would make “every act land in the audit trail under a machine rather than under the person who set it going”, which is correct while there is one actor field and is not an argument once there are two.

The interface wears the brand, and three of the brand’s own numbers did not survive contact

Section titled “The interface wears the brand, and three of the brand’s own numbers did not survive contact”

The Mailda identity — Ink, Flow Blue, Sky, Mist, White; Satoshi and Inter; a continuous-line M with a blue dot — is applied to the shell. Light is the default theme now, which is the brand’s own ground, and dark follows the system. What is worth knowing is where the brand sheet and an accessible, custody-respecting product disagreed, because in each case the resolution is written down rather than fudged:

  • Flow Blue cannot carry body text on Mist or Sky. Measured: 4.53:1 on white — AA by three hundredths — then 4.11 and 3.87, which fail. So the accent is two tokens split by use: the brand hex for fills, borders, focus rings and the mark’s dot, and a five-percent-darker same-hue blue for anything a person reads. (receipt)
  • Satoshi is named in the type stack and never shipped. Its licence allows self-hosting and forbids modifying and redistributing — and this repository is how customers get updates, so committing it would redistribute it to every one of them from a public URL. A designer with it installed sees the brand exactly; everybody else gets Plus Jakarta Sans, served from this Node. (provenance)
  • The brand supplies no warning, error or healthy colour, and a mail product needs all three. Those are kept from the previous palette, verified against the new grounds, and labelled as an extension of the brand rather than part of it.

The interface now loads four webfonts where it previously loaded none, and the rule that forbade them is intact: it was never about webfonts, it was about third parties. All four are same-origin under a font-src 'self' policy that a test asserts is exactly that — adding a CDN host fails it.

The logo mark in this repository is a reconstruction traced from raster artwork, and src/brand.ts says so in its first paragraph. It is fine at interface sizes and should not be used for print or an app icon until the real vector replaces it, which is a one-line change.


What’s distinctive about how it’s built

Section titled “What’s distinctive about how it’s built”

Every number has a receipt. No limit, timeout, threshold or budget enters the codebase without a measurement behind it. The constants are generated from the receipt files, so you cannot write the number — only the measurement. See docs/receipts/ and the rule in AGENTS.md.

That has already earned its place. Measuring the authorization path found a full table scan on every request — 1,864 rows read where 7 were needed, growing linearly with organisation size. It would have shipped invisibly. (receipt)

And a measurement in a test is not a measurement in production. The Butler engine’s fixed cost had been measured at three subrequests under miniflare and asserted by two tests. The first run executed against real Cloudflare Workflows confirmed the three — and showed that the run record said zero. The statement that writes that column was batched with an effect and issued nowhere else, so every effect-free run had been closing with its INSERT default in a column an operator reads as a measurement. Deploying proves a binding provisions; only running proves a run. (receipt)

A shipped sentence saying a gap is covered is worse than the gap. Ten documents and two source files described mailda deploy and mailda doctor as the mechanism for install and plan enforcement. There was no CLI — no bin entry anywhere. And doctor shipped a finding reading workers_paid_plan: ok whose detail said “mailda deploy verifies the plan at install and refuses on Workers Free”, so a Node on the plan the design forbids read ok and was told the check had happened elsewhere. A test required those words to be in the detail, so the suite held the claim in place. The CLI exists now; the plan still cannot be checked by anything, and the finding says so.

The deploy order was wrong, and the reason given for it was false. mailda deploy deployed the Worker and then applied migrations, so new code served requests against a schema that did not yet have what it needed — and if the migration failed, the incompatible Worker stayed deployed while the health check was optional. The stated reason was that “the Worker bundles them”. It does not: wrangler d1 migrations apply reads the .sql files from migrations/ and needs no deployed Worker at all. But reversing the two is also unsafe, because a migration that drops, renames or narrows breaks the code currently serving — no order makes both safe. What does is splitting migrations by phase, and the convention for that existed as prose in five of thirty-nine files and was wrong on both of the five that contracted: two migrations call themselves “Additive (#10 expand/contract)” above a RENAME COLUMN, which breaks code reading the old name. The phase is derived from the statements now, and mailda deploy uploads a canary, checks it, and only then moves traffic — so a failed check needs no rollback, because traffic never moved. (#98)

Clearing a gate is not the same as sending. An approved message still sat in the outbox. The sweeper that dispatches mail is armed by sealing, and three separate acts move a message from gated to sendable — an approval completing, a Butler’s send being released, a retry — none of which armed anything. Arming from those three would have been a list, and the fourth act to clear a gate would not have been on it. The backstop is one sweep on the cron that already runs every minute, which covers the acts that exist and the ones that do not yet.

You could not reply twice to the same conversation. Reply claims the case in the same act, and the claim’s compare-and-swap was WHERE assignee IS NULL — so re-claiming a case you already held changed no rows, fell through to the lost-the-race branch, and answered held by you. The button refused with “Held by you since …” and offered to take it and tell them: stealing your own case, notifying yourself. Every test claimed a case nobody held, so none of them saw it. It was found by an accessibility harness that could not get the composer open — and the same confusion turned out to be sitting in a Butler test whose name said “refuses one somebody else holds” while its fixture had the holder and the assignee be the same person.

A layer can be complete and unreachable. Trying to publish one Butler through the product found there was no way to: the interpreter, the checker, the run ledger, the pause machinery and replay were all built and tested, and createButlerDraft, editButlerDraft and publishButler had no HTTP route at all. grep -ric butler src/client/app/ returned 0 — the whole layer was invisible in the interface, including an inspectRun that redacts run facts by content classification for a screen that did not exist. The same shape in miniature: the composer had rendered “New message” in two places since it was written and no caller ever omitted the inReplyToMessageId, so every outbound path in a mail product ran through somebody else having written first. Tests pass on unreachable code. Now tracked as #77, #78 and #79, and built.

The second thing that test found was worse: the mail did not leave. Driving one real message from one mailbox to another — seal, hand over, receive, trigger a Butler — surfaced a sealed send sitting held with attempts = 0 long after its hold window closed. OutboxSweeper’s alarm re-armed on the inbound outbox only, while its own comment claimed “a Node that was asleep when it expired still sends”. Nothing woke it. What actually moved the send was an unrelated poke: the sweeper is armed when mail arrives and when a page is served, so on an idle Node your mail left when somebody happened to open the app. The alarm now sleeps until the earliest release_at and sealing arms it — and the predicate for “a send worth waking for” is the dispatcher’s own, not a second copy in the scheduler.

Structure beats discipline — including when the first attempt was a check. An automated review found header injection in the outbound path. The first fix was a validator called at each site, which closed the hole in the shape this project had already rejected for innerHTML: correct only while every future author remembers. It is now a builder where the unsafe state is unrepresentable — no array to push a raw header line onto, so a field added next year is validated whether anyone thought about it or not. A test asserts that property directly rather than asserting the current fields are safe.

Structure beats discipline, again. Approval binds a manifest id, and editing a sealed manifest produces a new id — so “any material edit invalidates approval” stopped being a rule someone has to remember and became a property of the identifiers. Same move as the partial unique index that makes two current signing keys unrepresentable.

Honest semantics are enforced, not aspired to. The outbox will never say Sent, and there is a measurement behind that rather than a principle: Cloudflare accepted a message addressed to nobody@example.invalid — a TLD that provably cannot exist — and returned a message id for it. The most optimistic thing a transport can tell a Node is compatible with the mail being undeliverable by construction, so handed_over is the literal ceiling of what is knowable. So the strongest claim Mailda makes is handed_over, and there are seven outbound states because collapsing any two of them would be the first lie a mail client tells. A forwarded copy is called a copy, never a sync. A provider action Mailda observed after the fact is never described as approved. When a send outcome is genuinely unknown, the product says outcome unknown rather than guessing. Names must mean the same thing in the code, the CLI, the API and the UI.

Structural over disciplined. Where a rule could be a code-review convention, it’s made into something a build can check instead: no Cloudflare resource in two Worker configs, no queue without a dead-letter queue, no retryable table without a unique constraint, no bare Date.now() outside one module. “At most one current signing key” is a partial unique index, not a guard clause — two current keys are unrepresentable rather than merely avoided.

A receipt that outlives its schema is a number that still reads as verified. Adding two threading indexes tripped the guard on the message-size receipt, so it was re-measured against real remote D1 before the guard’s constant was touched: 1,253 → 1,505 bytes per message, and a 10 GB shard now holds 7.1 million messages rather than 8.5. Two indexes cost 1.4 million messages of headroom — a figure nobody would have noticed without the guard. (receipt)

Keys belong to the Node. Both root keys are generated into a Durable Object on first use — no binding to configure and no way to install a Node that is accidentally unprotected. Secrets Store lost that argument on measurement: removing its config block doesn’t relink the binding the way D1 does, it drops it silently. Re-sealing then makes rotation real, verified against the plaintext hash each receipt already stored for exactly this purpose. (receipt)

The audit trail can be checked, not just trusted. Every entry carries the hash of the one before it, so a deletion, a reordering or an edit breaks verification at a nameable entry — and an administrator reads it in the product rather than in a cloud dashboard. Demonstrated on the deployed Node: editing one row directly in the database produced “entry was altered after it was written: its contents do not produce its hash”, naming the row. This cannot stop someone with database access from rewriting the whole chain — you own the database, which is the point — but it turns a log you have to trust into one you can check. (receipt)

An audit entry commits in the same transaction as the act it records. Writing the change and then appending the entry leaves a window where the change survives and nothing records it — and that hole is invisible to verification, which proves only that what was written is unaltered. So the entry travels inside the caller’s batch: either both land or neither does. The corollary is deliberate — if this Node cannot record an act, it does not perform the act.

The accessibility check had to be built to catch what the standard tool cannot. axe-core returns zero contrast violations on this interface — and proves AA for exactly one of its fourteen text nodes. The body’s gradient means axe cannot resolve a background, so it files almost everything as unproven rather than failing it, and a harness reading only violations reports green forever. Contrast is therefore computed from the tokens against both ends of the gradient, which bounds every point between them. That found a real failure: small dim text in light mode was at 4.15:1 against AA’s 4.5. Fixed and deployed, with the dark theme’s 0.01 margin now visible rather than latent. (receipt)

A measured number was deleted rather than corrected. A receipt recorded that the Deploy to Cloudflare button does not provision the R2 bucket Mailda stores mail in — the last thing standing between this and an installable product. Re-examined, that observation came from a probe whose deploy died before reaching R2, so it never measured what it claimed to. Direct measurement shows wrangler deploy creates the bucket in every shape tested, with an explicit name or without one, interactive or not. The constant is gone rather than flipped, because a number that reads as a platform limit when it was a broken probe is worse than no number. (receipt)

What a customer’s install can actually provision is checked too. A real Deploy to Cloudflare click was measured against a paid account: it provisions D1 but not R2, pins one Worker per build project, and writes resource ids into the clone. Those findings constrained nothing for two days — so the Worker’s configuration is now held to them, and a binding the button cannot satisfy fails the suite until somebody says how a customer gets it. doctor names a missing evidence bucket directly, instead of the generic failure that used to send the reader to migrations.

And CI is checked by CI. A cancelled run reads as neither pass nor fail, so a commit whose run was superseded looked verified when nothing had checked it — that happened here, to a commit on main. Two guards now: a test asserts the workflow’s own concurrency policy cannot cancel a push to main, and a step fails the build when any earlier push lost its verdict to a manual cancel or an outage.

The checks run without being remembered. Every check in this repository worked from a terminal long before anything invoked one — which reads exactly like a check that passed. CI now runs them on every push and pull request: that the committed constants still match their receipts, that the ctx seam holds, types, tests, and that the Worker still bundles against its binding configuration.

Audit coverage is enforced by the schema, not by memory. The trail shipped with eight call sites placed by hand, which is correct today and has nothing to notice when it stops being correct — an action that records nothing looks exactly like a quiet week, and a hash chain cannot help, because it proves what was written is unaltered and says nothing about what was never written. So every table is classified: either changes to it are auditable and the actions are named, or it is exempt for a stated reason. A migration that adds a table fails the suite until somebody decides which (test/audit-coverage.test.ts), at the moment they still have the context to decide well.

Nothing checks itself by default. mailda doctor --url <origin> verifies the runtime claims every other decision made, and on its first run against the deployed Node it found that the mail it holds is encrypted under a key published in this repository. Two checks deliberately use a credential rather than test for its presence — a Secrets Store secret is pending for a while after creation, so the binding exists and reading it throws. Every failure carries a fix, and there is a test per failure mode, because a check that cannot be shown to fail reads as verified. (receipt)

A green test suite is not evidence about the platform. Building sign-in, PBKDF2 was set to OWASP’s recommended 600,000 iterations. Every test passed. The deployed Worker returned HTTP 500 on every sign-in, because Cloudflare rejects any single PBKDF2 call above 100,000 iterations and local workerd does not enforce that limit. The work is now chained across six rounds, and the receipt says in as many words: measure this against a deployed Node, never against the local runtime. (receipt)



Passkeys are the way in now, and passwords are the fallback they were meant to be (#84)

Section titled “Passkeys are the way in now, and passwords are the fallback they were meant to be (#84)”

ADR 29 locks “passkeys are the authentication Mailda builds; password authentication survives as a per-user fallback” and it shipped inverted: passwords were the only authentication, and every reference to passkeys was prose. #83 made that worse rather than better — a Node that can add people turns one operator’s own password into every colleague’s, on a system holding an organization’s mail.

The sign-in screen now offers a passkey first and the password beneath it, because an interface that puts the fallback at the top teaches the opposite of what the decision says.

The relying-party id is derived from the request, never configured. A stored one can disagree with the origin the browser is actually on, and when it does every ceremony fails with a mismatch nobody can act on — so deriving it makes the disagreement unrepresentable, and keeps the repository free of the customer-specific value ADR 24 forbids.

Verification is @simplewebauthn/server at +128.9 KiB gzip, two thirds of which is X.509 machinery for attestation this Node deliberately does not request. Adopted anyway, by the test this repository set when it deferred a parser: attacker-chosen structure feeding an authentication decision is where a mature implementation earns its bytes. The tempting counter — that the cryptography is Web Crypto either way — is true and beside the point, because the five checks around it are where implementations go wrong and omitting one is an auth bypass rather than a bug.

Tested against a real software authenticator rather than a recorded fixture, because the negatives are the whole property: a fixture proves one response verifies and can never answer does a replay fail. That choice paid immediately — a mutation caught one of these tests being vacuous, asking to revoke a credential id that did not exist, so the line binding a passkey to its owner could be deleted with everything still green.

It also put the pre-authentication surface into the accessibility gate for the first time, and found a WCAG 2.2 AA failure that had been shipping since #83. That gap was the sharp one: the harness signs in first, so the one page an operator meets when the Node is broken was the one page nothing checked. See docs/authentication.md.

Both send APIs exist now, and they are not interchangeable (#86)

Section titled “Both send APIs exist now, and they are not interchangeable (#86)”

ADR 33 locks “the transport offers both send APIs, and every send records which one carried it.” The recording half was built and correct, with one possible value.

The second adapter goes over POST /accounts/{id}/email/sending/send, and checking Cloudflare’s docs before building it decided its shape: that API takes structured JSON and no raw MIME, so it cannot carry authored fidelity and refuses it rather than rebuilding the message. The binding stays preferred wherever it exists — it holds no credential to leak or rotate, and it is the only adapter that can submit the exact recorded bytes. What the REST path is for is the Node that has no binding, where the previous answer was “cannot send, permanently”.

Two of the ticket’s own arguments for it did not survive this tree: batching recipients would collapse per-delivery outcomes that migration 0013 exists to keep, and a prior measurement had already found that submitting N times costs nothing extra. So it does not batch, and the honest remainder is smaller and real — a bindingless Node can speak, and a permanent bounce known at submission becomes suppressed rather than an optimistic hand-over.

It is also the first credential this Node has ever held, and therefore the first real test of ADR 22’s “every credential is a Secrets Store binding” — which does not survive ADR 24, for the reason already recorded in wrangler.jsonc: a store_id is account-specific and its removal drops the binding silently. The token is wrapped under the credential KEK instead, whose own header already named transport credentials as what it protects. No route returns it, and it is never a property of env — which is the property ADR 22 was actually buying. See docs/send-transport.md.

The routes are described once, and the description is checked (#85)

Section titled “The routes are described once, and the description is checked (#85)”

ADR 12 locks “UI, CLI, SDK, Skill and MCP parity is generated from shared contracts”. Two of the five surfaces existed, neither was generated from anything, and packages/contract held one command’s schemas against seventy-one served paths — through a "main" pointing at a file that did not exist, imported by nothing. A package with no importers has no way to be wrong.

packages/contract/src/routes.ts now describes every route, and three checks hold the chain: the client’s template is typed per method, so naming a route this Node does not serve is a compile error; and a tripwire compares the registry with the handler in both directions, on paths and on verbs.

Writing it found a live defect. The interface sent PUT /api/policies/:id/draft and the handler answered only POST, so editing a policy draft returned 404 not_found — on a governance surface, since the route shipped. Nothing caught it because the route tests built every request with a helper that hard-coded method: "POST": a helper that fixes the method cannot detect a method divergence, and fourteen green tests sat over it. Confirmed against a running Node before and after, rather than inferred.

Step 2 adds the schemas: every describable route is described, 90 of 90, and the test asserts equality rather than a floor — so a route added without a schema fails. Partial on purpose — ninety-four hand-written shapes that nothing checks against a real response would be ninety-four guesses a generated client trusts, so each arrives with a test that drives its route and parses the answer. Two of them do security work rather than tidiness: .strict() is what makes a route that grew an apiToken or a publicKey field fail instead of leak.

It found that usr was not in the id registry — minted as a literal since the first layer, because nothing had ever needed to validate a user id — and registering it made the id-prefix tripwire fire on both mint sites, which now go through the registry.

Reaching all of them took fixtures the product’s own rules dictated: a second and third administrator for dual control, a lowercase address for the Butler ceiling, and a stub transport that refuses — because a retry is offered only where non-acceptance is recorded.

Step 3 ships the SDK, generated. packages/sdk emits one method per route from the registry, pnpm sdk:check fails on any diff, and responses are validated against the contract by default — so a Node that has drifted is caught in the caller’s process with the offending field named, as a ContractViolation rather than a refusal. Building it found its own defect: a top-level writeFileSync in the generator made the drift test vacuous, because importing the generator regenerated the file before the test could read a hand edit.

Step 2½ applies the request schemas instead of only publishing them (#93). Through steps 2 and 3 nothing on the Node ever checked a body against one — the only reader of spec.request was the MCP server, turning it into a tool’s input schema and forwarding the body unexamined. So a route with a schema read exactly like a route that was validated, and one of them was publishing rules nobody had written: POST /api/policies with {"conditions":{"mailbox_id":…}} dropped the misspelled key, stored five NULLs, and made a policy version matching every send in the organization — reported to the caller as created, because it was. A deny written that way stops all outbound mail.

The boundary is one function, applied centrally before the route and before authentication, and it refuses unknown fields only — bad values still reach the handlers, whose refusals name the four outcomes or explain why a volume floor of 0 is an unconditional rule in disguise. Strictness is a per-route decision with the argument written beside the schema, not a global flag: two routes refuse an unrecognised field today, six tolerate one, and a tripwire asserts both sets exactly. The refusal names the offending key, the fields that do exist, and the near miss — did you mean mailboxId? — using normalised spelling rather than an edit distance, because a distance cutoff is a number with no receipt.

See docs/api-contract.md.

Two machine surfaces, over one curated list (#88, #89)

Section titled “Two machine surfaces, over one curated list (#88, #89)”

ADR 12’s last two surfaces. Both needed the same answer to the same question — what should a machine be able to do here? — so it is answered once in packages/contract/src/agent.ts and both read it.

53 of 94 routes are offered. Everything that answers a question, and everything a person can undo. Withheld: 25 that need more than one person or cannot be undone, 17 that are acts of running the Node rather than using it, and one that is the surface itself.

governed is not about permission. §18 counts distinct people, and an agent inside somebody’s session is that person — so the Node already refuses. What the tier prevents is offering the act. A Skill listing “approve a send” teaches an agent to try, and it will keep trying: the refusal says ask somebody who holds approval.decide, and it has no way to know it can never be that somebody. An offer a caller can never complete is worse than no offer.

So the Skill names what it withholds, with reasons. An absence reads as a gap somebody forgot; a stated exclusion reads as a decision.

The MCP server is POST /mcp on this Worker, which was #89’s actual question. A second Worker breaks ADR 18; a separately-run bridge would be the first component holding credentials for a Node it is not part of, which is the shape ADR 7’s custody premise rules out. A tool call re-enters this Node’s own router in process — same guards, same audit entry, same refusals — authenticated by the caller’s own session, so the trail names the person who set it going rather than a machine. See docs/machine-surfaces.md.

The document is defended now, and not only the message inside it (#97)

Section titled “The document is defended now, and not only the message inside it (#97)”

The Node sent no security headers at all: no CSP, no frame-ancestors, no nosniff, no HSTS, no Referrer-Policy. The reason that went unnoticed is worth more than the fix. The message reader’s sandboxed iframe is a real, well-reasoned defence and is documented as one — so it read as the browser-security story, when it only protects the document from the mail. Every governance control here is a button — approve, release, lift, grant — and an application any origin may frame turns a button into a signature.

One policy, set in withSecurityHeaders and applied in fetch, which every response already passed through: default-src 'none', script-src 'self', style-src 'self', frame-ancestors 'none', base-uri 'none', nosniff, no-referrer and HSTS. Two of those need saying out loud.

script-src 'self' cost the shell its inline code, which is the whole point. MAILDA_CONFIG shipped as an inline <script> and the stylesheet as an inline <style>; keeping either needs 'unsafe-inline', which permits exactly what the directive exists to stop, or a per-response nonce that the header and the document must agree on forever. So the config is a same-origin ES module at /app/config.js — a module rather than a JSON endpoint because session.client.js reads it at module evaluation, and an async bootstrap in the file whose job is that nobody sees a 401 is a worse trade than an import. The stylesheet is /app/app.css.

That module is now the browser’s one channel for a receipt-derived number, including the composer’s hold window, which also retired a ?? 15 fallback standing in for a receipt. The obvious alternative — the composer is bundled here, so let it import { BUDGETS } — was built, measured and withdrawn: it put the whole 218-entry table in the shell bundle for one integer (+7,960 bytes raw, +2,783 gzip) and gave the interface a second source for figures that must agree with the Node.

It also broke #90’s draft-flush test at the time, by slowing that screen’s module graph enough to cross the 1,499 ms boundary the test sits on — and that half of the argument has since expired, which is worth recording rather than deleting. The test was on vi.useFakeTimers({ shouldAdvanceTime: true }), where wall-clock time advances the fake clock, so it really was sensitive to how long a module graph takes to load. That was a flaky test rather than a cost of the import, and it is now fixed at the root: the clock moves only when a test moves it. So the bundle size is the whole reason this alternative stays withdrawn.

frame-src 'self', not 'none' — though not for the reason the ticket gave. The reader renders sanitised mail into a sandbox="" srcdoc frame, and the expectation was that 'none' would break it. Driven through a real Chromium, it does not: that frame renders under 'self', under 'none', and under no frame-src at all, because a srcdoc navigation inherits its parent’s policy instead of being matched against a source list. 'self' stays anyway, and the comment says why — only one engine was measured, and 'self' is the true description of what this application frames, where 'none' would be a claim that it frames nothing. What does matter is that the frame inherits this policy, which is safe rather than lucky: the sanitiser already strips <style>, every style attribute and src on images, so the mail HTML asks for nothing default-src 'none' refuses — asserted against the sanitiser’s real output, not a fixture.

HSTS is one year and says nothing about subdomains. docs/receipts/hsts-max-age.md records why: the preload list’s minimum is the only externally stated figure in the mechanism, and includeSubDomains from a Node at an apex domain would assert HTTPS for hosts Mailda never saw, for a year, on a customer’s behalf. A domain-wide claim belongs to the domain’s owner, who has a zone setting for it.

The accessibility harness broke on the CSP, which was the useful signal: it injected axe-core as an inline <script>. It now injects through the debugger instead — and deliberately not with bypassCSP, which would have disabled enforcement for the application too and hidden the one regression the harness is now placed to notice.

A comment that cites a file which isn’t there, and the three checks not worth building (#103)

Section titled “A comment that cites a file which isn’t there, and the three checks not worth building (#103)”

Nine authorization gaps in one audit turned out to be one test-oracle defect. The follow-up question was narrower and more uncomfortable: the comments were wrong too, repeatedly, including comments written while fixing wrong comments. One cited a test file that has never existed:

test/composer.test.ts — the real file is test/drafts.test.ts

That reads exactly like a citation of a real file, and nothing in the toolchain resolves a path inside a comment, so it is wrong at the moment it is written and stays wrong. #103 asked whether that has a mechanical countermeasure and said the honest answer might be no, but that it should be concluded, not assumed.

One of four candidates paid, and it widens a check that was already here. test/node/receipt-references.test.ts resolves citations shaped docs/receipts/<id>.md in the worker’s src, test and scripts; the question was whether the idea holds for every path in every prose region. It does. test/node/prose-references-world.test.ts extracts all 2,931 of them and fails on any that does not resolve. It found four stale references nobody had caught — all outside the older check’s scope, three of them outside the worker’s source tree — including a receipt whose Measured: line named a path that its own line 140 contradicted, and two client type declarations pointing at an externals-note document that exists nowhere.

The scan deliberately stops where the toolchain starts. Sweeping code as well raised the candidate set to 3,851 and the unresolvable set from 33 to 86 — and all 53 extra were false: template holes, property reads, package specifiers. A check whose new findings are all false is a check that gets muted.

Two holes turned up in the checker itself, both found by measuring rather than reasoning. The suffix match was built as "/" + path without stripping a leading ./, so it reported a live file as missing; and the token pattern allowed only one leading ../, which skipped every deep relative reference in the repository without mis-resolving one — the scan simply reported clean. That is the defect the check exists to catch, twice, inside the check.

What it cannot catch is the sharper half. A wrong claim about a real file resolves fine. While fixing the two declarations above, the first attempt deleted a correct reference after an ls of the wrong directory — a false claim introduced by the act of removing one, and structurally invisible here, because a reference that is gone is never unresolvable. Existence mechanises. Accuracy does not.

So the rule the check depends on is now written down: an inline `path` is a citation and must resolve; a fenced block holds a literal and is skipped. Without that line, every document explaining a broken reference would need exempting from the rule it explains.

Three detectors were rejected, each with the measurement that rejected it, in false-claim-detectability. Counts in prose — 261 of them, and the class most of the session’s own errors fell into — because verifying a count needs to know which set it counts and the prose never says. Release gates in prose — 14 matches, zero genuine orphans, and “ship” turns out to be polysemous. And prose citing a test by name, which is perfectly checkable and appears once: a tripwire over a single instance cannot fail for the reason it was built, which §2b forbids. An earlier, broader phrase list looked far more productive at 36 apparent orphans, until reading them showed it was flagging unmeasured — which in this repository is overwhelmingly a deliberate honest-limit statement, the receipt discipline working. It would have punished the practice it was meant to protect.

The canary’s 404 was never a setting, and the gate had to change shape (#98)

Section titled “The canary’s 404 was never a setting, and the gate had to change shape (#98)”

mailda deploy uploads a canary version and checks it before moving traffic. For three deploys the check could not run — the canary’s hostname 404’d — and two rounds of the live drill recorded the cause as “not established: could be an account-level preview setting, a per-Worker dashboard toggle, or something about a Worker whose first version predates the alias”, each time noting that settling it needed the dashboard.

It never needed the dashboard. One read of the account’s API:

GET /accounts/{account}/workers/scripts/mailda/subdomain
→ {"enabled": true, "previews_enabled": true}

Preview URLs were already on, the alias was recorded on every version, and no hostname routed. The cause is a documented platform limitation: Cloudflare does not generate preview URLs for Workers that implement a Durable Object. Mailda has two, and cannot drop them — ADR 28 moved both root keys into KeyVault precisely so a managed secret store could not re-provision them. preview_urls: true was a declaration with no effect, standing exactly where a reader would look for the reason.

The transferable part is not about preview URLs. The suspected cause was checkable without the dashboard, and two drills did not check it — the ticket asked a person to go look at a toggle that a GET could have read.

So the gate changed shape. The canary is now placed in the current deployment at 0% and reached through Cloudflare-Workers-Version-Overrides on the production hostname. That works with Durable Objects, and it brings one trap with it, which is the interesting half: Cloudflare does not error when an override cannot be applied — it routes by traffic percentage, to the version already serving. A gate asking “is the Node healthy?” would get ok from the incumbent and promote a canary nothing had examined.

That is an assertion that cannot fail, so the gate is an identity check rather than a health check: the Worker gained a version_metadata binding, /api/doctor reports which version answered, and the deploy refuses unless that id is the one it uploaded. A report naming no version is a refusal too — a Node too old to carry the field is one this gate cannot check, and “cannot check” is not “passed”.

Building it turned up a fourth lexical assertion in this repository that proved nothing. The test requiring identity to be settled before the verdict matched shouldPromote(verdict) exactly, so a mutation inserting shouldPromote(report.verdict ?? "refuse") above the identity check passed — a different string, the original still found later, green against a sequence that decides on the verdict first. It matches the call now, not one of its call sites.

The hand-written deploy-parse.d.mts also drifted while this landed: it kept exporting the preview-URL parser after the function was gone. Its own header had said the drift was “bounded” and that “nothing checks the pair”. Something does now — a set comparison in both directions, which is what that admission was always worth.

Still not verified end to end: the sequence needs a deploy against a live Node, and the two drills that produced the measurement above are the reason it is written this way. Every refusal in it leaves the incumbent serving 100%.

A customer can now check what they merged, and cannot yet check who wrote it (#102)

Section titled “A customer can now check what they merged, and cannot yet check who wrote it (#102)”

#102’s argument is that the update model — merge this repository into the software holding your organization’s mail — has to be verifiable. Three of its six parts were already done: the Apache-2.0 licence, SECURITY.md with private disclosure, and a protected main with the CI verdict required and no bypass actors.

Two more are now done. Every push to main publishes a CycloneDX SBOM and a Sigstore-backed provenance attestation over it, after the suite passes:

Terminal window
gh run download --repo Straits-AI/mailda --name mailda-sbom
gh attestation verify mailda-sbom.cdx.json --repo Straits-AI/mailda

It is keyless — GitHub’s OIDC identity rather than a maintainer’s key — which is why it could ship now while commit signing cannot.

The generator reads pnpm-lock.yaml, not node_modules, and both reasons are the point. pnpm list needs a completed install, so the inventory would describe the runner rather than the commit; and it reports resolved URLs but no integrity hashes, which is the one field that makes an entry checkable rather than a name somebody typed. 386 entries, every one carrying the hash the install would have verified.

It parses YAML with regular expressions, whose failure mode is silent under-reporting — and an SBOM missing a dependency is worse than no SBOM, because it answers “is this dependency here?” with a confident no. So an entry the parser cannot read fails the build, and the count is checked against a second, independent scan of the same section rather than against the generator’s own parser. The document is byte-identical across runs of the same commit, because “reproducible” was the word #102 used.

The sixth part was considered and rejected, which is the more useful half to record. Maintainer commit signing is not adopted. Merge commits into main are already signed by GitHub’s web-flow key, so every change that lands carries a signature over the merge that put it there; authored commits report N.

The argument against went: a signature is worth what its verification is worth, and nothing in this product’s update path verifies one. git pull does not check signatures unless an operator configures an allowed-signers file and passes --verify-signatures, which nobody does. Requiring them would constrain every commit in order to produce a property no consumer reads — a condition backed by nothing, which is the defect this repository spends most of its effort removing.

What answers the question that matters — did this come from there — is the attestation above: one command, tied to a workflow run over a commit that passed check. What it does not answer is who wrote that commit, and the control there is repository access: protected main, no bypass actors, a required green check, a reviewed pull request per change.

It changes if the update path ever verifies. Shipping git pull --verify-signatures into an update command, or a preflight that checks the upstream commit against a known key, would make signing load-bearing — and signing should follow that rather than precede it.

Two lexical assertions were caught proving nothing while this landed, both by mutation. The test requiring the attestation to run only on a push searched backwards from the step for the nearest if: — with the job’s own condition deleted, it ran on into the check job and found if: always() && github.event_name == 'push' there. Green, against a workflow that attests every pull request. It reads one job’s block now. And deploy-parse.d.mts — whose own header said the drift it allowed was “bounded” and that “nothing checks the pair” — had kept exporting a function that no longer existed. Both hand-written declarations are now compared against their modules as sets, in both directions, in test/node/declaration-drift.test.ts.

Nothing checked that the evidence was still there (#92)

Section titled “Nothing checked that the evidence was still there (#92)”

doctor sends one HEAD at the R2 bucket. That answers is R2 reachable, and nothing at all about whether what is in it is what arrived. So the question a mail system exists to answer — is the message still there, and is it the one we received — had no mechanism behind it, and the first person to find out otherwise would have been somebody opening a message years later.

#92 asks for a restore into a clean Cloudflare account, and says of the step that proves sampled messages decrypt and hash-verify: “Step 5 is the one that makes the rest true. An export nobody has restored is a claim, and this ticket exists because of a claim.” That step needs a verifier. There wasn’t one — for a backup or for the running Node.

POST /api/evidence/verify opens each object in a bounded batch and compares the plaintext hash against the one recorded when it was sealed. mailda verify-evidence --url <origin> pages until the Node says there is no more, and prints the total it actually covered — the route cannot sweep everything in one invocation, and a single call that looked complete is the failure this feature exists to prevent.

It swept inbound mail only, and said nothing was wrong (#131). The inventory next door walked all four prefixes the Worker writes; the verifier read ingress_receipts. So a Node whose evidence is drafts or staged sends — every Node before its domain is bound, and any Node that composes more than it receives — verified nothing and reported a clean sweep. Measured on the first real backup: three sealed drafts, 3 object(s) listed, 0 checked, 0 fault(s). Every number honest and the conclusion drawn from them false.

Neither list was wrong on its own. They had stopped agreeing, and nothing could tell — which is #67 and #74 a third time, arriving as a difference in coverage rather than a gap in one list. So the verifier now groups INVENTORY_REFERENTS by table instead of naming any, and a test asserts it names no evidence table in its own code: a mutation swapping the derivation for a literal of the same four names passed a test that compared values, because the literal is right today. The property is that a fifth prefix arrives without anybody remembering.

Two consequences worth stating. The batch bound now counts objects, not rows — one send_manifests row stages three, so a row bound meant three different per-invocation costs depending on which table was reached (re-measured, and the old receipt’s stale_when named this change as the thing that would invalidate it). And a fault reports its table: a bare id was unambiguous when one table was swept, and sends somebody looking in the wrong place now.

The recorded hash is of the plaintext, not the stored bytes, and that decision — made at ingress, long before this — is what makes the check survive ADR 28’s key rotation. reseal.ts rewrites every object under a new generation; a ciphertext hash would mark every message in the Node as altered after correct maintenance, and a verifier that fails after correct maintenance is one that gets switched off.

Three faults, kept separate because an operator does something different with each: missing (the object is gone, the row saying it existed is not), unreadable (it names a key generation this vault cannot produce — the ADR 28 loss the recovery codes exist for), and altered (the bytes changed after ingress, which does not happen by accident).

Two of my own tests were theatre, and the mutations said so

Section titled “Two of my own tests were theatre, and the mutations said so”

Both are the oracle defect this repository keeps finding: an assertion a broken implementation also satisfies.

The missing-object test broke one object out of two and asserted one fault. A verifier that stops at the first fault satisfies that too — and checked came from the page size rather than the loop, so the count did not move either. Mutating the loop to break left it green. It takes two broken objects with an intact one between them to tell “found a fault” from “kept looking”, and the fault kinds take different paths through the loop, so the altered test needed its own pair for the same reason — pairing only the missing one still left the altered branch’s break surviving.

Forcing resumeAfter to null also passed everything, because three seeded messages never fill a page of two hundred and a short page correctly ends a sweep. The branch deciding whether a sweep continues was untested. The batch bound is now a parameter defaulting to the receipt’s value, so a test reaches that branch in three messages instead of two hundred; the route does not read it from the query string, so an operator cannot widen a batch past what was measured.

A duplicate classification, found by having to add one

Section titled “A duplicate classification, found by having to add one”

Adding the route meant classifying it in packages/contract/src/agent.ts, which is where POST /api/audit/verify turned out to be declared twice — as act, with a written justification, and again inside an operator block. Object spread means the later wins, so the act reasoning was dead code and the route was withheld carrying a why inherited from a block about “installation and the account lifecycle”. That string is what the Skill and the MCP server quote to explain the absence.

Nothing could have noticed: DECLARED_ROUTES collapses the duplicate before any test reads it, the counts matched, and every withheld route had a reason. The oversized operator block is now split into five, each carrying the reason its inline comment already stated — the comments were right, they just weren’t the operative value — and a test reads the source for a route declared more than once.

What a clean sweep does not establish, printed by the command rather than left implied: an R2 object no receipt names, and anything that never reached ingress. And the clean-account restore drill is still ahead of this — it needs a second Cloudflare account, and the export it verifies is the next layer.

The deploy refused for the right reason and named the wrong one (#98)

Section titled “The deploy refused for the right reason and named the wrong one (#98)”

Found while working out what a live deploy drill needed, on an ordinary machine, with nothing misconfigured except one thing nobody had thought to ask about: the operator’s wrangler token could see four Cloudflare accounts. That makes every non-interactive wrangler call fail with a message about non-interactive mode. What the operator was told, in order:

  1. a note that the Workflow-theft guard had been skipped — so #99’s protection against one Node stealing another’s Butler engine silently did not run, in exactly the situation where nothing else worked either;
  2. “could not tell whether this account already has a Mailda Worker”, with CLOUDFLARE_ACCOUNT_ID mentioned in passing at the end of a three-line advice block about something else.

It failed safe. It also diagnosed the wrong thing, and the guard that quietly stopped guarding is the more serious half — a check that treats “I could not look” as “nothing to check” reads exactly like a pass.

mailda preflight now answers all of it in one wrangler whoami, before anything is touched, and deploy runs it first — before the Workflow guard, because settling the account is what makes that guard’s answer mean anything:

== preflight
wrangler 4.118.0 (floor 4.97)
account unresolved
node https://mailda.mystraits-ai.workers.dev
1 thing(s) must be settled before a deploy can run — nothing has been changed.
1. the Cloudflare account is ambiguous
why this token can see 4 accounts and nothing says which one to deploy to, so every wrangler
call fails with a message about non-interactive mode — and the Workflow-theft guard (#99)
is skipped rather than enforced
fix export CLOUDFLARE_ACCOUNT_ID=<one of these>
e842216b23604d45c318ae890bbd2999 Admin@arbuilder.app's Account
...

Every problem is reported together rather than one per round trip, because an operator standing a Node up has several wrong at once and each round trip otherwise ends in a message about a different thing.

Three account failures, three messages. One account needs no environment variable — demanding one would be ceremony. Several with no choice made is the defect above. And an id that is set but not in the token’s list gets its own wording, because wrangler answers that with a permissions error which reads like an expired login and sends people to re-authenticate instead of to their typo.

The version floor is compared numerically, and that is not pedantry. As strings "4.118.0" < "4.97.0", because 1 sorts before 9 — so a floor checked the obvious way rejects every wrangler released after 4.99 and accepts the ones actually too old. The exact inversion, presenting as a broken toolchain on an up-to-date machine. There is a test asserting the string comparison is wrong, so nobody simplifies it back.

It also warns, without refusing, when a Node cannot report which version answered — that Node predates the version_metadata binding, so a version override that fails to apply is reported as “the report named no version”, which is the canary gate refusing correctly rather than a fault. Better read in advance than investigated mid-deploy.

Its parsers are tested against real wrangler whoami output, box-drawing and all, from the machine that hit this. A parser tested against a fixture written from memory is a parser tested against its author’s belief about the format, and the format is the entire difficulty — wrangler offers no structured way to ask.

And the fifth lexical assertion in this repository was caught the same way as the four before it: the test requiring preflight to precede the Workflow guard searched for refuseIfWorkflowBelongsElsewhere(), which matched the function’s definition two thousand characters earlier and failed against correct code. It matches the call site now.

The drill ran, the mechanism works, and the gate was wrong (#98)

Section titled “The drill ran, the mechanism works, and the gate was wrong (#98)”

The fourth live deploy drill, and the first since the preview-URL cause was established and the canary gate rebuilt around a version override. Every step ran in order:

preflight account resolved, wrangler 4.118.0 above the 4.97 floor
workflow guard mailda-butler-runs owned by mailda — ran, rather than being skipped
migrations 0045–0051 applied (all expansion)
reading the serving version d27a228d
canary upload c7e7b917
canary at 0% SUCCESS: c7e7b917 at 0% and d27a228d at 100%
override probe answered version: c7e7b917 ← the identity gate passed

That last line is what three previous drills could not reach. The override does reach a 0% version on the production hostname, and the canary named itself — so the identity check, the thing standing between this gate and an assertion that cannot fail, works against a real account.

Then the gate refused, and the refusal was the defect. The canary reported degraded with one finding, signing_key — “generated on the next sign-in, so this self-heals”. The incumbent reported degraded with the same one finding. A version neither better nor worse than the one already taking every request was withheld, and the operator was told to promote it by hand.

An unclaimed Node is in that state by construction until somebody signs in. So every deploy to one would have gone that way — which is the weak “upload, check by hand, promote” path the earlier drills recorded, reached from a completely different direction. A gate that always has to be overridden has stopped being a gate.

The comparison is differential now. A canary answers whether the new code is worse, and a finding the incumbent already has is information about the Node rather than about the new version — refusing on it withholds the fix as readily as the regression. Shared findings are reported as carried, never silently dropped. refuse still refuses whatever the incumbent says, because two broken versions is a reason to stop rather than to proceed. And an improvement promotes, which is the direction nobody thinks to test: a canary that fixes the incumbent’s finding has fewer, and a comparison written backwards would block it.

The mutation worth naming survived everything else: fetch the incumbent’s report with the override header and both reports come from the canary, so nothing ever blocks — including a canary that broke the Node. The pure function cannot see it; it is handed two reports and has no way to know they came from one version. The property is countable instead: exactly one request in the CLI overrides a version, and it is the canary’s.

shouldPromote is gone rather than left as an export nothing calls, with the expensive lesson from its docstring folded into its replacement.

Then it was promoted, and the drill measured what the gate can actually see. c7e7b917 at 100%; the Node now reports its own version, so every future gate can run; migrations_applied reads “All 52 expected tables present”; the events consumer was already attached, so the step the refusal interrupted had nothing left to do.

The canary check is unauthenticated, which costs something now measured: 9 findings of 21. The other 12 describe the organization’s mail and are withheld from an anonymous caller, so the differential comparison covers 9 and a regression confined to a data-disclosing finding would not block a promotion. Fixable — sessions are signed by the Node’s key and that state is shared across versions, so signing in and sending the cookie with the override header would reach the canary authenticated — and deliberately not fixed, because it needs credentials in the deploy path, which is a decision about what mailda deploy may hold.

One number moved that looked alarming and was not: the report went from 20 findings to 9 across the promotion. The old route reduced only if (orgId !== null && !signedIn), so an unclaimed Node served its full report to anonymous callers; the current one reduces that case too. Nothing was removed — four checks were added since — and the tightening is an improvement. Recorded because the first reading of a shrinking number is that coverage was lost.

A successful deploy reported failure, and the gate saw less than it looked (#98)

Section titled “A successful deploy reported failure, and the gate saw less than it looked (#98)”

Two findings the drill left, both now closed.

mailda deploy inherited doctor’s exit code. So a Node reporting degraded made a successful deploy exit 1 — and on a fresh Node degraded means signing_key, which self-heals on the next sign-in. Every green deploy to a new Node reported failure, indistinguishable in a pipeline from a deploy that never happened.

The two commands are asked different questions. doctor is asked how is this Node, so its verdict is its answer and the mapping is a faithful translation. deploy is asked did the deploy happen, and a pre-existing degradation is neither its doing nor its subject — the gate already refused anything the canary made worse.

refuse is the exception, and it earns an exit code for a specific reason: it is the one case the canary gate provably cannot catch. A Durable Object runs the promoted version only after traffic moves, so a fault inside KeyVault or OutboxSweeper appears exactly there and could not have appeared earlier. So a deploy ending in refuse exits 2 and prints the rollback with the version that was serving before it ran — the one value nobody can look up mid-incident.

The gate compared 9 findings of 21. The canary check was anonymous, and withoutDataFindings withholds everything describing the organization’s mail from a caller who is not an administrator. A regression confined to the withheld 12 would not have blocked a promotion.

It can sign in now, and the reason it works is worth stating: a session is signed by the Node’s own key, and that key lives in D1 and the vault — state, not code — so two versions share it. A cookie obtained from the incumbent is honoured by the canary, and sending it with the override header reaches the new version authenticated.

The trap that shapes the implementation is not the signing in; it is the asymmetry. Asking the canary authenticated and the incumbent anonymously compares 21 findings against 9, twelve read as new, and every deploy is blocked by a difference in who was asking rather than in what the code does. One headers object is built once and used for both; the override is the only thing added on top.

Credentials stay optional — a Node that cannot be deployed to because its credentials are wrong is a worse failure than a narrower gate — and the downgrade announces itself. The deploy now prints how many findings it compared and how many were withheld, because “the gate passed” is worth less without how much the gate could see. An ordinary member’s credentials buy nothing here and it says so: the full report needs org.admin, and anything less is reduced to the same 9.

An inventory, so a restored bucket can be checked object by object (#92)

Section titled “An inventory, so a restored bucket can be checked object by object (#92)”

The verifier from #117 proves an object is what was recorded. It cannot say what should be there at all — and a copy of a bucket into a fresh Cloudflare account is a copy nobody has checked without that. #92’s step 1 asks for D1, the R2 object inventory, and the manifests; wrangler d1 export covers the first, the manifests live in D1, and this is the missing middle.

GET /api/evidence/inventory walks every prefix the Worker writes and returns each object’s key, size, timestamp, key generation, and the hash its plaintext should have. The prefixes come from scannedPrefixes — the function #67 and #74 both exist because of, each a prefix the Worker wrote and no listing covered — whose completeness is enforced by a test that derives the written set from src/. Building a backup on a second, copied list would be that defect in the place it costs most.

All four prefixes turn out to have a referent row carrying their objects’ SHA-256, which is better than a listing of sizes: raw/ from ingress_receipts, drafts/ from drafts, exports/ from exports, and sent/ from three columns on send_manifests. So a restored copy is checkable object by object rather than in aggregate, and the existing verifier is the thing that would run over it in the new account.

An object no row names is reported, not omitted. recordedSha256: null is reconcile.ts’s “object, no referent” — safe to delete after a grace period, and never safe to leave out of an inventory: a backup that silently drops what it cannot explain restores less than the operator thinks, and the gap is invisible precisely because nothing references it.

Three things the build was taught rather than reasoned into:

D1 refused a six-arm UNION ALL — “too many terms in compound SELECT” — which turned out to be the right design pressure. A page is always from one prefix, so only that prefix’s referents can name its keys; joining a page of raw mail against the drafts table was pointless work as well as one arm too many. Worst case is now three arms, for sent/.

The closed world caught my own route contradicting itself. The route’s documentation said it was withheld from machines, and nothing classified it — so the GET-derivation rule offered it. An inventory is the widest description of an organization’s mail that contains no mail: every object with its size and timestamp, which is who was busy, when, and how much. It is also a backup’s index, which is a list of what to ask for next. The declaration is what withholds; prose is not.

One filter was decorative until a test made it load-bearing. Removing org_id from the hash join passed every test, because keys are org-scoped by the R2 prefix they were listed under. What it defends is narrower and real: another organization’s row naming an object under our prefix would otherwise supply their recorded hash for our object, and a restored copy would be verified against another tenant’s number. That case is now a test, so the line can fail.

A backup an operator holds, and a check that runs without the Node (#92)

Section titled “A backup an operator holds, and a check that runs without the Node (#92)”

Cloudflare’s own recovery — D1 Time Travel, Durable Object point-in-time — covers thirty days and restores into the account that failed. #92 puts it plainly: “A backup that only restores into the account that failed is not a backup for a product whose selling point is that you own the account.”

mailda backup --url <origin> --out <dir> writes three files:

catalog.sql wrangler d1 export. The thing you restore — and it carries the manifests, the
audit chain and the wrapped vault escrow, because all three are rows.
inventory.jsonl every R2 object with the hash its plaintext should have.
index.json what the other two should contain, with a SHA-256 of each.

That the escrow is a row is why #92’s layers had to come in the order they did: exporting evidence nobody can decrypt is a backup that proves nothing, so the keys had to exist first.

It deliberately does not copy the evidence bytes. Streaming a mailbox’s worth of R2 through a laptop is not a backup strategy, and a command that pretends otherwise works on a demo Node and fails on a real one. The inventory is what turns somebody else’s copy — rclone, an R2 bucket-to-bucket job — into a copy that can be verified object by object afterwards. The command says so last, where it is read, because that is the half an operator will assume it did.

mailda verify-backup --in <dir> reads the artifact and nothing else — no Node, no network — so it runs on the copy you keep rather than the machine that took it. It answers exactly one question: is this backup the one that was taken? Every file present, every hash matching, the line count agreeing with the index. That catches a truncated copy, a partial download, a corrupted transfer and a directory somebody edited, which is most of how a backup is discovered to be useless — and all of it discoverable before the day it is needed.

What it cannot establish is printed in its own output rather than left to a reader, because “the backup verified” is the sentence somebody will remember on the day it matters: not that the evidence decrypts (the objects are not in the backup), and not that the catalog restores. Both are properties of a restore.

Three judgements worth naming:

verified: null is “not asked”, never “clean”. --verify runs the evidence sweep first and records what it found; without it the field is empty, and the checker reports the absence rather than letting a reader infer a clean bill of health from a quiet field. Faults found at backup time are a failure, not a note — a backup of a known-broken state discovered on restore day is the whole thing this ticket is about.

Every problem at once. A backup is checked rarely and in a hurry; being told about a missing file, then a bad hash, then a short inventory across three runs is how a restore becomes an evening.

An unknown format declines rather than guessing. A future backup read by an older CLI would otherwise be checked against today’s rules and produce confident nonsense. Declining leaves the operator their files.

Both administrator-only commands now refuse an unclaimed Node with the accurate reason. They used to ask for MAILDA_EMAIL and MAILDA_PASSWORD and then fail at sign-in — which on an unclaimed Node is a request for something that cannot exist, since claiming is the step that creates the first organization, its first user, and that user’s password. There is also nothing to back up on such a Node. The old message sent an operator after the one thing that cannot work; the check reads claimed rather than probing the login route, because a failed sign-in is recorded and counts toward lockout.

The prose-reference tripwire from #103 caught this change three times, which is the return on having built it: the docstrings naming files the command writes — the same class as an export archive’s entries, so the exemption cap moved from 26 to 30 with the reason recorded — then the sentence in the receipt explaining that exemption, which cited one of those names inline, and then this very section.

The third catch came from CI rather than locally, and the reason is worth knowing: this test reads the whole repository, and turbo caches on the package’s own inputs. A change to the root README alone leaves the cached result in place, so pnpm test reports green without re-running it. Recorded in the test itself, since anyone editing prose will meet it.

A runbook written before the drill, and a check that its commands exist (#92)

Section titled “A runbook written before the drill, and a check that its commands exist (#92)”

Three deploy drills each spent their time rediscovering a precondition — an ambiguous account, a preview URL that cannot exist, a gate comparing against the wrong thing. docs/disaster-recovery.md is the restore drill’s sequence written down first, so the fourth one can be followed rather than improvised.

It names its preconditions because each has already cost something: the source Node must be claimed (an unclaimed one has no administrator, so the commands that need one refuse by name); the destination account must have R2 enabled, which is a dashboard-and-billing action a deploy cannot do for you; CLOUDFLARE_ACCOUNT_ID must be set when the token sees several accounts. Two facts found while writing it: there is no wrangler d1 import — execute --file is the restore path — and the destination needs no claim, because the catalog carries the organization and its users, so a restored Node arrives already claimed.

What it will not do is pretend. It says it has not been run end to end, and it splits RTO rather than offering one number: restore-to-readable is measurable, and restore-to-receiving needs DNS propagation, which is not the product’s to control. A receipt recording the first and stating the second is unmeasured is worth more than one figure covering both.

test/node/runbook.test.ts is the #103 defect in a new place: the path tripwire catches a comment citing a file that is not there, and nothing caught a document citing a command that is not there. Both are claims a reader follows and nothing resolves — and a runbook is read once, under pressure, by somebody who cannot tell a stale document from a broken Node.

Mutation testing found the first version checking the wrong half of its own document. The pattern was \bmailda\s+, and an actual invocation reads node packages/cli/src/mailda.mjs backup — after “mailda” comes .mjs, not a space. So it was matching the prose mentions and none of the commands an operator copies, which are the only ones that fail at three in the morning. Renaming a command inside the runbook’s own code block passed. It matches both spellings now.

The second gap was subtler: the blueprint and AGENTS.md are exempt, because one sketches thirty-three verbs by design and the other names two as illustrations of an error message’s shape. A mutation adding the runbook to that exemption list also passed — the general non-vacuity check was satisfied by the README while the one document the test exists for went unchecked. It is now pinned in scope by name. And writing this turned up one real instance: SECURITY.md named a CLI command that does not exist, in my own sentence about signing. Rephrased rather than exempted.

That is the third time in this session a checker for wrong claims has made it awkward to write about wrong claims — the path tripwire caught its own receipt, then a README section explaining an exemption, and now this sentence, which named the very command it was reporting as absent. The pattern is worth knowing rather than fixing: each time the cheapest correct move was to describe the defect instead of quoting it, and each time that left the prose better. A checker that objects to its own documentation is usually objecting to a quote that did not need to be there.

The claim page was a blank page without JavaScript, and a comment said otherwise (#92)

Section titled “The claim page was a blank page without JavaScript, and a comment said otherwise (#92)”

Found by driving a browser at the claim screen — the point of doing it that way rather than reading.

main.tsx stated that three screens “stay server-rendered with no framework, because they are the screens an operator sees when the Node is broken and they must work before any bundle loads”. The reasoning is right. The mechanism was not there. ui.ts exports page(), which ships <main id="app"></main> and a script tag; nothing in src/*.ts renders a claim form, and the only place the form’s text exists is the framework-free client script.

Measured by fetching it: 2.4 KB, and the only visible text was the wordmark. No form, no error, no hint — on the first screen a Node ever shows. That is the worst available diagnostic, because a blank page reads as a network problem rather than as a requirement, and an operator claiming a Node has no reason yet to suspect their own browser.

Half the comment was true and worth keeping: those screens use no framework, and React is imported dynamically only after sign-in, so somebody staring at a 500 does not download a hundred kilobytes to find out why. What was false was server-rendered, and it was false on the one screen where the stated reason matters most.

There is a <noscript> in the shell now — it says the page needs scripting, says plainly that nothing here is rendered on the server so a reader stops looking for a path that does not exist, and points at /api/doctor?format=text, which genuinely needs no scripting because the server renders it. The comment says what ships. Actually server-rendering those three screens is a larger change and a decision, not an omission to fix quietly, so it is stated rather than done.

One assertion in the new test is the measurement itself: strip the scripts, the SVG and the tags, and what a browser without JavaScript can show is the wordmark plus the notice. If server-rendering ever arrives, that test fails and should be rewritten rather than deleted.

The ten recovery codes were minted, hashed, escrowed, and thrown away by the interface (#134)

Section titled “The ten recovery codes were minted, hashed, escrowed, and thrown away by the interface (#134)”

The operator who claimed a Node was never shown them. They asked whether they had missed a screen; they had not, because there was no screen.

claimNode returns them — claim.ts:186, recoveryCodes: escrow.codes — and the contract is explicit that this is the only place they ever appear, “the Node keeps a hash to recognise one and an escrow only the code itself opens, so nothing can produce them again.” The claim handler did:

if (response.ok) { adopt(); startSessionTicker(); return route(); }

The body was never read. src/client/ contained zero references to recoveryCodes. So the one artifact that decrypts an organization’s mail was returned once and dropped on the floor, and doctor correctly reported “10 recovery codes exist and nobody has confirmed holding one” about codes nobody could ever have held.

And there was no way to spend one either. POST /api/recovery/redeem exists and is deliberately unauthenticated — the state it exists for is one where the signing key cannot be unwrapped, so no session can be issued. But no screen referenced it and the CLI had no verb: the only way to redeem was a hand-written curl, for the operation whose entire purpose is to be performed during a disaster by somebody who has lost everything else.

Measured, in #92’s restore drill: a full catalog and every object restored into a different Cloudflare account, the destination holding all ten hashes and escrow blobs, and it refuses — signing_key: E_EVIDENCE_AUTH_FAILED. The escrow ADR 28 says it does not ship without was present, correct, and unusable.

Now the claim shows them on a screen of their own with one way forward, and the button says what it asserts — “I have saved these ten codes” — rather than “OK”, because a person clicking it should know what they are claiming. And mailda recovery-codes redeem reads the code from a terminal, never an argument, for the reason set-password already gives: a secret on a command line ends up in shell history.

The command with the gentler verb was leaking the worse secret (#136)

Section titled “The command with the gentler verb was leaking the worse secret (#136)”

Walking the drill with that fix in place found the rule broken forty lines from where it is written. redeem refuses a code as an argument; confirm required one. And confirming is the more dangerous place to leak, because confirming deliberately does not spend the code: a redeemed code in a shell history is spent, while a confirmed one still opens the escrow holding the organization’s content and credential keys.

The second reason is the one that decided it. Confirmation asserts exactly one thing — that a person holds the sheet — and doctor words its warning as that: “nobody has confirmed holding one”. A code a script reads from a file clears the warning without the fact becoming true, which is 2b: an assertion that cannot fail. The agent that found this had just rotated a Node’s codes and could have cleared its warning from the file it had written, making the Node report that a human held codes no human had read.

So confirm prompts, and refuses --code by name with the reason rather than ignoring it. The test is written over the whole function rather than over the one command, because the rule had been living in a comment next to a single caller — test/node/recovery-code-entry.test.ts requires that every code the CLI sends is one somebody typed, which is a rule the next recovery verb has to meet.

src/client/app.client.js renders the claim, the sign-in and a locked-out doctor — and no test had ever loaded it. Its imports name the session and config modules the way the Worker serves them, not as files on disk, so it could not be resolved under vitest. The React suite drives the screens after sign-in. This file was outside both, and the claim screen is the first thing a Node ever shows.

Three aliases and a stub make it importable, so the pre-authentication surface is now testable at all. That is the more valuable half of this change: the fix is four lines, and the reason it was possible to ship is that nothing could see the file.

Two mutations survived the first version of the tests, and both are worth naming. Adding an unconditional route() to the new screen — rendering ten codes and navigating past them, the original defect wearing the fix’s clothes — passed, because route() is async and the assertions read the DOM in the same tick. And softening the heading from “Write these down now.” to “Recovery codes” passed, because the substantive warnings live in the paragraphs. The tests yield before asserting now, and the heading is pinned.

The claim-path assertion searched for return route() and found it inside a comment saying “this used to be return route()”. That is the eighth time in this repository a lexical assertion has matched its own documentation, three of them caught only by mutation testing — which means the others were luck. test/without-comments.ts now exists, lists all eight, and strips comments by default rather than by each test remembering. It does not fix the class: a lexical test can still match the wrong call site, and scoping to a function’s body is the other half.

The ninth arrived while fixing #138, and it is that other half rather than a comment. A test asserting the collision branch exits non-zero took nine hundred characters from the branch’s start, ran past it into the guard below, and found that fail(. Swapping the branch’s own fail( for a process.stdout.write( passed. Delimiting the slice at both ends killed it.

The drill’s last finding, and the one with a person on the other end of it. GET /api/drafts/:id on a Node whose vault was incomplete answered 200 with body: "" and bodyBytes: 180 beside it — the row contradicting the answer, and nothing saying which of two very different states it was.

.catch(() => "") swallowed both alike. The reasoning behind it was sound and is still in place: a draft whose body is gone should not fail to open, because that loses the recipients and the subject as well as the writing. What was missing is that the two cases need different words. missing is ADR 32’s reportable-only side and nothing recovers it. unreadable means the object is there, sealed under a key generation the vault does not hold — the ADR 28 loss the recovery codes exist for, and it clears when one is redeemed.

And an empty box invites typing. Two losses were reachable from there, and the second is the one reasoning alone would have missed:

  • a non-empty save re-seals to bodyKeyFor(orgId, id), which is deterministic, so it overwrites the object it could not open — recoverable evidence becomes gone evidence;
  • an empty save skips that branch entirely and leaves body_key pointing at the old object while writing body_sha256 = NULL and body_bytes = 0. The bytes survive and stop being verifiable, because the verifier skips a row carrying no recorded hash.

So the read says which state it is, the composer says it where the empty box is, and the write refuses while the body is unreadable — naming the recovery code as the way out. Only unreadable is refused: blocking a write over a body that is genuinely gone would strand the recipients and subject too, punishing somebody for a loss they cannot undo. A mutation over-blocking that case passed until a test was written for it.

The refusal is gated on the body actually changing, which costs nothing on autosave — and the gate turned out to be better than its own justification. A caller can only produce a matching digest by already holding the text, so writing it back is proof the writing was never lost, and it repairs the object. The test that went looking for persistence found that instead.

The restore drill: what running it found that reading could not (#92, #138)

Section titled “The restore drill: what running it found that reading could not (#92, #138)”

Two full runs against two real Cloudflare accounts. The backup half works. The half it exists for does not, and that is now measured rather than assumed.

The redemption installs nothing. On a clean destination — Worker, D1, R2 and queue all provisioned from scratch, catalog restored, evidence copied and byte-checked — spending a recovery code answered:

HTTP 200
{"restored":{"content":[],"credential":[]},"conflicted":{"content":[1],"credential":[1]}}

A fresh Node mints its own content and credential generation 1 before it can be claimed — mailda deploy’s closing doctor says so on a brand-new unclaimed install — and the escrow carries generation 1 too. One number cannot hold both, and the Node keeps its live key, which is the right trade in the scenario the code was written for and the wrong one here: the destination’s generation 1 has sealed nothing, so keeping it costs the whole organization’s mail to protect an empty key. The runbook’s own order forces the collision, because the redeem route needs a claimed Node and the claim arrives with the catalog.

And every layer read it as a success. The route returned two arrays and a 200; the CLI printed “the vault is restored” — a false success message I had shipped less than an hour earlier in the #134 fix, over the exact case where an operator most needs the truth. The Node already knew better: vault.restore answers restored | conflict | identical, so it can tell a harmless re-run from a real collision, and only the API threw the distinction away. The answer now carries what happened, including the sentence worth the most — another code will not help, because all ten carry the same generations. Without it an operator spends the sheet.

The escrow installs now, and the trade that blocked it was real. restore never overwrote a generation already present, and the argument is good: a code redeemed against a healthy vault by somebody uncertain what state it is in would otherwise replace live keys with older copies and make everything sealed since the escrow unreadable. That trade only exists when there is newer mail. A generation nothing has ever sealed under is a reserved number, not a protected key — so an escrowed key may now take it, and a generation that has sealed is still refused. The vault records the difference at the first seal rather than at the mint, which is why doctor moved from sealingKey to a new ensureKey: a diagnostic that wraps a constant and throws it away was marking the generation load-bearing, and that alone made the escrow uninstallable on every fresh Node.

Measured on the drill’s destination: both generations installed, adopted naming what they displaced, and sign-in returned 200 for the first time — the restored Node authenticating the source’s administrator, with the source’s user id.

Then the evidence failed, and it was my own copy that did it. verify-evidence opened all three objects and reported all three unreadable. wrangler r2 object get | put carries the bytes and drops the custom metadata naming the key that sealed them; there is no flag for it. The destination fell back to generation 0, the published constant, and authentication failed on every frame.

The first answer was a runbook warning and a list of approved copy tools. That is the wrong shape: a Node’s ability to read its own mail should not depend on an annotation any S3 client may normalise away, and an operator recovering from a disaster is the least likely person to have used the blessed tool. So the generation went back to being a hint (#142). When an object carries no label, the read tries the generations the vault holds — sound rather than a guess, because AES-GCM authenticates: a wrong key fails rather than producing wrong plaintext, so the sweep either finds the key that works or proves none does.

Bounded, and off the ordinary path entirely: an object with its label takes the route it always did. And the frame header has three unused bytes already covered by the authentication tag, so a later change can put the generation in the object’s own bytes, where no copy can lose it.

With that, the drill completed: 3 checked, 0 fault(s), and the same draft byte-identical on both Nodes.

The check designed to catch exactly this was already built and already silent: the inventory reported keyGeneration per object as 0 for everything, because list() was never passed include: ["customMetadata"] and R2 returns none without it — honoured since compatibility date 2022-08-04 (#141, fixed). A wrong number in a backup artifact, in the one field saying which key opens each object, and the eight tests over that inventory passed throughout because not one of them asserted it.

And a draft whose body cannot be decrypted reads as an empty body (#143), with bodyBytes: 180 returned beside it. The existing reasoning is sound for a missing object — losing the recipients and subject as well would be worse — but it swallows a decrypt failure too, and an empty body in a composer is an invitation to type over evidence that was only unreadable, not lost.

Three of the side-findings were ours, and are fixed (#148, #149, #150). A backup aborted on one transient sign-in 500 with nothing retried — and a backup that fails on a blip is a backup that does not happen, since it runs unattended and the next thing anybody learns is that the newest artifact is a week old. It retries now, three times with a short backoff, and never retries a 401, because wrong credentials will be wrong again and a scheduled job hammering a login is a different problem. /health called a missing database “no schema” and named migrations apply — which resolves the binding by name, applies every migration to a live database the Worker does not read, and reports success; the two states are now told apart and the second says plainly not to run it. And mailda deploy stopped on a raw wrangler error when a Node’s script existed but its bindings did not, a state that is routine because every failed provisioning retry produces it; it is now named, with the path that works.

Seven more findings between “the destination exists” and “the destination is clean”, each in the runbook now: a second restore onto a restored Node fails with the opaque {"D1_RESET_DO":true}; a deleted D1 is never re-provisioned by wrangler deploy, because the binding is linked server-side, so the Worker kept reading a dead database id while the CLI resolved the name to a live one; the fix the Node printed therefore ran successfully and changed nothing, applying 51 migrations to a database the Worker does not read; /health calls a missing database “no schema”; mailda deploy lists migrations before the deploy that provisions them; auto-provisioning never adopts an existing resource, and fails after creating the earlier ones, so each retry needs a manual unwind; and a Worker cannot be deleted while it consumes a queue.

The pattern across all of them is one thing: a layer’s honest output read as the layer above’s good news. backup --verify reported 0 checked, 0 fault(s) over three objects — true, and worthless, because the sweep reads ingress_receipts and this Node’s evidence is drafts (#131). verify-backup was the only thing in the chain that said it: “the sweep that ran when this backup was taken checked nothing … That is not a clean bill of health.”

Onboarding: what a Node’s own Cloudflare grant costs, and one path that measurement closed (#108, ADR 42)

Section titled “Onboarding: what a Node’s own Cloudflare grant costs, and one path that measurement closed (#108, ADR 42)”

#108 charted a journey in which Mailda provisions a Cloudflare account for a customer who has none, deploys into it, and hands over a claim URL. That path is not available to this product, and it was closed by running it rather than by reading the product list (receipt):

Temporary account ready: Claim within: 60 minutes
Provisioning CATALOG (D1 Database)... ✨ provisioned
✘ /accounts/…/r2/buckets failed. Authentication error [code: 10000]

An authentication error rather than a capability one, so it reads as a missing scope and there is none to add. It fails part way through — D1 is created first, and since auto-provisioning never adopts, the second attempt blames a Workers Free D1 quota and offers an upgrade link, misdirecting at billing for a product-support gap. And the temporary account is on Workers Free, which ADR 25 requires to be Paid, so the path stays closed even if R2, Workflows and Email Sending are added later.

A second objection survives any product-list change. The claim window is sixty minutes and an unclaimed account is deleted with its resources — while ADR 28 puts the root keys in Durable Object storage, which #92’s drill measured is not carried by a D1 export. A Node claimed inside an account that then expires leaves its operator holding ten recovery codes for a vault that no longer exists: the escrow’s premise inverted, silently.

So the bootstrap orchestrator is dropped too, because it existed for exactly one reason — “the Node cannot store the grant before the Node exists” — and that window was created by the path just struck. The operator has or creates an account, deploys, and only then is there a grant to store. Operating a hosted service to solve a problem measurement removed is the dependency AGENTS.md calls a hostage.

What replaces it: the Node is its own OAuth client. Cloudflare supports only the Authorization Code flow for third-party clients, so a grant needs a browser redirect — and the Node has a browser interface. A shared Mailda-owned public client cannot serve it, because redirect_uris are enumerated per client and every Node has its own hostname, so the code would land on a Mailda-operated redirect and make Mailda the custodian by construction. A private client is authorizable only by members of the account that created it — the customer’s — so the Node holds its own client credentials and no Mailda-operated service ever holds a Cloudflare grant at any point. That is the only shape in which “disconnecting Mailda stops nothing” needs no argument, because there was never anything to disconnect.

The cost is one dashboard ceremony, stated rather than hidden: the operator creates an OAuth client and gives the Node two values. It is reduced rather than eliminated — the Node prints the exact steps with the required scopes and verifies the result.

And there is a screen for it now (#210). Nineteen provider routes shipped before one existed, reachable only through mailda provider … — so the honest answer to “can a non-technical operator run this?” was no, however carefully each refusal was worded. The person these routes exist for owns the Cloudflare account; requiring them to install a CLI and hold an API token is requiring them to be somebody else. /setup sends what the CLI sends and renders the same refusals in the same words. The two steps still in the dashboard are the two that cannot leave it: a client this Node is not allowed to create for itself, and a consent only a human may give.

The callback negotiates on Accept for the same reason. It is the redirect URI, so a browser is always what reaches it, and it answered with {"consent":{"ok":true,…}} — a flow written in English ending at a parse error. error_description is a query parameter reflected onto that page, on the one route here with no session check, so it is escaped.

And the destination is restated, because as written it was unreachable. It promised a journey without learning the dashboard for every customer, and account creation cannot be orchestrated for this product — so a customer without a Cloudflare account performs one named ceremony first. #108’s own rule is to remove routine dashboard work, not to disguise legal or security decisions as automation.

The verification this rested on is resolved, and the proof is an error rather than a success (receipt). A private client was created with one workers.dev redirect URI, and an authorization request with too short a state was answered by redirecting to that URI — and RFC 6749 requires a server not to redirect when the redirect_uri is unregistered, so redirecting an error to a URI is the server saying it accepted it. A private client needed no domain verification either, which matters because workers.dev is not a domain a customer could verify. Two smaller facts came out of it: the endpoint enforces a minimum state length of 8, and Cloudflare’s own sign-in challenge sits between the request and the consent screen.

L1 is built (#162): the Node holds a client, runs the redirect with PKCE, exchanges the code and stores the grant wrapped under the ADR 28 credential key. Five connection states, not three, and docs/cloudflare-grant.md carries the whole design. The state worth naming here is account_not_selectable — an account administrator can disable public OAuth app access, and the consequence is that the consent screen simply does not list the account the operator means, with no error and no response the Node ever sees. So it is the one fact in this product recorded as reported rather than observed, said four times over: in the column names, in a required evidence field on the API, in the audit entry’s own detail, and in doctor’s prose. A reported fact read months later is exactly where it gets mistaken for a measured one.

The endpoints are constants with a receipt rather than runtime discovery, and the reason is measured: dash.cloudflare.com answers the RFC 8414 path with a 200 carrying HTML, so a Node that discovered its endpoints would parse the dashboard’s shell and fail on the authorization path at the moment an operator was connecting. doctor compares them against live discovery instead (receipt).

mailda deploy --plan says what a deploy would do before it does it, with three verbs rather than one, because #92’s drill measured that a create-only plan is wrong in the expensive direction. Three of its six dispositions describe a deploy doing something other than what it looks like it does: a leftover from a failed attempt is not adopted but fails on, a resource deleted from under a live Worker makes the next deploy report success and change nothing, and a Workflow another Node owns is taken silently at exit 0. The unwind is an ordered sequence whose every step was found by getting it wrong, and the plan prints only the steps that apply. It reads the account through the operator’s own wrangler and not through the grant, since a plan for a first install runs before there is a Node to hold one.

Four things this layer does not claim, and says so: the scope matrix is unmeasured — the mechanism to produce it ships but no figures exist, and printing plausible scope names would be a fabrication an operator would find by pasting them into a picker that does not offer them; whether Cloudflare’s token response names the account is unmeasured, so the surface says not yet determined rather than showing an empty one; the revocation drill is an argument from what the code touches rather than a measurement; and the same receipt records that Cloudflare’s discovery document advertises client_credentials while its documentation says third-party clients cannot use it. If the document is right, this whole ceremony is unnecessary. The probe that would settle it is named and not run, because it creates a real client in a real account.

A supervised search that matched nothing left no record (#158)

Section titled “A supervised search that matched nothing left no record (#158)”

supervised.query is written only when a page returned rows, and the argument beside it is right for a listing: paging past the end of a mailbox discloses nothing. A search inverts it. ADR 28’s amendment says what the contentless body index gives somebody with the data — the ability to confirm a guess — and a null answer is half of that capability. So a supervised reader, somebody with a dual-approved time-boxed grant into a colleague’s mailbox, could search it for a word, learn the word is not there, and leave the trail empty. Repeated, that is dictionary-style probing with §7’s record blind to it.

supervised.query_empty closes it, one entry per live grant, because the page is one query spanning everything the reader may see — an empty answer is a fact about each of those mailboxes individually.

The hard half was the entry’s shape. A record of a probe that does not say what was probed for answers nothing; a term in the audit trail is the investigator’s own search words, retained for whoever reads it. Of the three options — the plain term, a digest, a count alone — the decision was the digest: an auditor learns that a mailbox was probed, how often, and how many probes were the same word, and never what the word was.

And a digest of a search term is a key problem, not a hash problem. sha256("redundancy package") is reversible with a wordlist in seconds, because the search space is English rather than 2^256 — so an unkeyed digest would deliver the plain term to exactly the reader it was chosen to withhold it from, while looking cryptographic. It is therefore an HMAC under a key derived from this Node’s content key, which never enters D1. Content rather than credential is the security argument rather than a preference: an attacker holding the content key already reads every message, so reversing a probe digest tells them strictly less than they have, where the credential key reads no mail and adding this to its compromise would be a genuine widening.

Two mutations survived the first round of tests and both destroyed the design invisibly — a bare SHA-256 (still 43 characters, still stable, still not the plain term, now wordlist-reversible) and sealingKey in place of ensureKey, which marks a content generation used on a read and is #138’s defect in a worse place than #138 found it. Neither is visible in the entry; both are now asserted directly.

The cost claim was wrong in the first draft too, in the comment justifying the code: it said an ordinary reader’s search costs nothing extra. It does — the grant read runs before anybody knows whether there are grants to find. Measured instead of argued: an empty listing costs 2 subrequests whether or not the reader holds a grant, an empty search costs 3 for any reader, and 6 for a supervised one. See docs/supervised-access.md.

The brand reached one shell and not the other (#128)

Section titled “The brand reached one shell and not the other (#128)”

The palette, the fonts and brand.ts all shipped. markSvg() was consumed exactly once — by ui.ts, which builds the pre-authentication shell — and the React chrome went on rendering MAIL<span class="accent">DA</span>, the instrument panel’s wordmark it had replaced everywhere else. Every file was individually correct and nothing joined them, so the identity was on the sign-in page and absent from the product. Reported by the operator looking at their own Node.

The rail is now Ink in both schemes, which is the brand sheet’s product mockup rather than a theme choice: the mail sits on the light surface and the rail is the dark one. What is not taken from the mockup is the shape — there the rail is a strip of icons, and here it carries mailboxes and ledgers with their counts, because chrome.tsx argues for that and the mockup was not drawn against this product’s information architecture. Adopting the surface and leaving the structure is the honest half to take.

A dark rail is a fourth surface, and every existing measurement was blind to it

Section titled “A dark rail is a fourth surface, and every existing measurement was blind to it”

contrast.test.ts measures against --ground, --ground-2 and --sky — Mist, White and Sky in the light theme. The rail is none of them, so a light-theme token used inside it was checked against three grounds it never sits on and passed while being unreadable on the one it does. Not hypothetical: .rail-mine used --live, which reads 3.01 on Ink. Every page-tuned token fails there —

--live 3.01 --warn 3.14 --alarm 2.68 --accent-text 3.36 --text 1.00 (invisible)

— so the rail has its own tokens, which are the dark theme’s values, and a closed world over the rail’s rules forbids reaching for a page-tuned colour inside one. That last test exists because a mutation put --live back and every contrast assertion still passed: measuring a value is not the same as measuring where it is used.

The sheet draws the search field as a Mist pill on a White header. Mist on White is 1.10, and WCAG 1.4.11 wants 3:1 for the visual information identifying a control — so the brand’s fill identifies nothing, and neither rule token helps (.10 is 1.23, .22 is 1.61). --control-edge is rgba(15,23,32,.47), the first alpha clearing 3:1 on all three grounds. Heavier than the mockup’s hairline, and recorded with the numbers so the choice is legible rather than looking like a line got thicker by accident.

The magnifier is the submit button. The usual way that mockup gets built is a decorative glyph beside a field that submits on Enter, which loses the button — so a keyboard has nothing to land on and a screen reader is told the search cannot be run.

Two things the tests caught that review would not

Section titled “Two things the tests caught that review would not”

Two controls with one accessible name. The field and the button were both “Search mail”, and search-field.test.tsx could then find neither unambiguously — which is the test noticing what a screen reader would: “Search mail, edit” then “Search mail, button”, with nothing to tell them apart. The field names itself; the button names the act.

The brand’s short placeholder denied a feature. “Search mail” replaced “sender, subject or message text”, and the search-copy test (since deleted, see 20 September 2026) failed on exactly that, because “people do not discover a feature the interface denies having”. The pill keeps the brand’s two words and a hint beside it carries the rest.

And the mark is not shipped, because it does not work

Section titled “And the mark is not shipped, because it does not work”

brand.ts said its by-eye reconstruction “reads as the Mailda symbol at interface sizes” and that this was “fine for a screen at 24–40 px”. Rendered at 26px and zoomed, it is a small squiggle with a dot: it does not read as an M, and it had been on the sign-in page on the strength of a sentence nobody had checked against a screenshot. It was drawing the favicon too — the place the same file called “not fine”.

So MARK_IS_AUTHORED is false, both shells set the name in the display face alone, and the favicon is a letter on Ink. A logo that is nearly right is wrong; one that is not even nearly right is worse, and type is a real decision rather than an approximation of somebody else’s. The designer’s vector flips one line.

Satoshi is still not shipped, and the licence is why. The ITF Free Font Licence permits self-hosting and forbids redistribution “through another font website, font library, marketplace, repository… or publicly accessible servers”, and requires each third party to obtain their own copy. ADR 24 has customers clone this repository, so committing it would be redistribution to every one of them — and subsetting it for size is the modification the same clause names. fonts/README.md had this right before the question was asked.

Two filters that look identical and are not (#107)

Section titled “Two filters that look identical and are not (#107)”

since, until and from were one line of a ticket: “ordinary range predicates and want no index beyond what exists”. Two of the three are, and the third is a different problem wearing the same clothes.

since and until reach the index. They are bounds on accepted_at — the column ir_org_accepted is built on — so they are the same shape as the keyset cursor, and the planner seeks with them. Shipped.

from does not — and then it turned out to be filtering the wrong column. #152 was filed saying this could not be made cheap, because from_addr is on messages and accepted_at on ingress_receipts, so no single index can serve sender-filtered time order.

True of the From: header, which is what from_addr holds — and that is what the sender chose to display. ingress_receipts.envelope_from is what the sending server handed over, the transmission fact this Node recorded, and it sits on the same table as accepted_at. One index serves both, the seek survives, and the keyset cursor keeps working. The ticket’s three options were all answers to a problem that only exists for the header, which stays searchable through q and the FTS index: two questions, two surfaces.

Measured on 1,200 deliveries, the index dropped and re-created inside the measurement so the figures are genuinely a before and after:

queryrows read
rare sender, no index1,207 — over the 1,000-row budget
rare sender, ir_org_sender9
bulk sender, either208 — unchanged, so the index is free rather than a trade

The corpus puts the rare sender’s only message at the oldest position deliberately: a time-ordered scan starts at the newest and reaches it last, which is the worst case for a plan that filters instead of seeking and the ordinary case for an investigation looking for something old.

The index is on lower(envelope_from), because envelope_to is lowercased at ingress and envelope_from is not — an asymmetry that predates this. Normalising in storage would need a backfill over every receipt ever written; the expression buys the same case-insensitive matching with no data rewrite, and D1’s SQLite does use it (SEARCH r USING INDEX ir_org_sender (org_id=? AND <expr>=?)), which was the open question rather than an assumption.

Two mutations survived and are worth naming. The measurement creates its own index, so dropping lower(...) from the shipped migration left all three of its assertions passing — an index the planner declines, every sender page back to scanning, and the only symptom a query slower than a receipt claims. sender-index-matches-predicate.test.ts couples the two texts, because SQLite matches an expression index by its text and “these strings agree” is the property rather than a proxy for it. And a test of mine asserted not.toBeNull() on a field that was undefined, which passes — the listing does return the sender, but the assertion would have passed if it did not.

That is the whole test for whether a filter belongs in a listing: does the predicate reach the index, or is it applied to rows already read. Two filters can look alike in a ticket and sit on opposite sides of it.

accepted_at is compared as a string, and the cursor beside it already records what that costs: Date.parse accepted "2027" and "2026-08", so a truncated value became a silent wrong answer. A date is worse, because the short form is the one a person types:

'2026-08-20' < '2026-08-20T00:00:00.000Z'
'2026-08-20T09:00:00.000Z' > '2026-08-20'

So until=2026-08-20 compared as typed excludes every message accepted on the day named — silently, in the direction that hides mail. Somebody asking “did anything arrive by Thursday” is told no. A day therefore widens to its own edge, and the two edges differ: since takes the day’s first instant, until its last, so since=X&until=X returns X’s mail rather than nothing. A near-miss instant of the wrong width (2026-08-20T09:00:00Z, no milliseconds) is refused for the same reason — it sorts between a day’s messages rather than at either edge.

And the measurement that reversed the plan

Section titled “And the measurement that reversed the plan”

A date window was meant to ship on the searched page too, and looked more valuable there: that page is ranked, capped and cursor-less, so a date range is the only way to reach past the cap. The figures said otherwise (receipt, authz.list.max_rows_read is 1,000):

queryrows readpage
common term, no window77151
common term, since at the corpus midpoint4,33551
selective term, since at the midpoint13412
unsearched, window over half the corpus20851
unsearched, window holding less than a page10124

Same page, five and a half times the reading: inside a ranked arm a window is a residual filter, so the arm scans further to fill its cap. A selective term is affordable, and that is the trap rather than the reassurance — selectivity is not knowable before the query runs, so there is no per-request rule that admits the cheap case.

Filtering the union outside the arms keeps the cost exactly and changes the meaning: the arms cap by rank first, so “mail about demurrage since October” would answer nothing whenever October’s demurrage mail ranks below the cap. A wrong answer to a reasonable question, silently, is worse than a refusal — so the combination is refused, and #153 carries the plan a windowed search needs.

Two claims of mine died here too. A bounded page is not simply cheaper: over half the corpus it reads exactly what an unbounded page reads, because the scan stops at LIMIT either way. And that receipt’s stale_when did not anticipate a new predicate, so adding one would have left 771 describing a query that no longer existed — a landmine by this repository’s own definition, in the file whose job is to prevent them.

The whole tree reviewed on two axes, and what each finding turned into (20 September 2026)

Section titled “The whole tree reviewed on two axes, and what each finding turned into (20 September 2026)”

A review of the entire repository against AGENTS.md on one axis and the Blueprint on the other. Two reports, kept apart on purpose so neither could mask the other. What each finding became:

Standards. The 25 MiB attachment ceiling in src/mailbox-policy.ts was the exact landmine AGENTS.md uses as its example, typed by hand while email.inbound.max_bytes sat in the budgets with a receipt; it reads the budget now. The queue consumer’s batch_size: 25, max_wait_time_ms: 10_000 (and the attach script’s --batch-size 25 --batch-timeout 10) were inherited from a deleted wrangler block and measured by nobody, so they are gone: the consumer takes the platform’s defaults, which is where a platform number belongs. Two more literals moved under receipts, auth.failed_login_window_seconds (the window the budget key auth.max_failed_logins_per_15min already named) and doctor.stalled_outbox_seconds, the latter measured the same day on the live Node (worst real publication lag 25 s; 600 s stands at 24× it). The doctor’s seven-day DMARC window is a presentation constant and is now named as one.

BudgetExceededError printed three of §3’s four parts. The fourth, “the exact command that changes it”, pointed at a policy-set verb that never existed and never will: a generated budget has no runtime override by design. The error now ends raise remeasure <receipt>, then pnpm receipts, and §3 was amended to say that rather than promise a verb.

The CLI carried twenty-nine /api/... strings written by hand. Every one now goes through api() in packages/cli/src/support.mjs, which reads the contract’s route registry and throws on a route this Node does not serve, and test/node/cli-route-parity.test.ts parses the CLI’s string literals so a stray path fails a test rather than a customer’s terminal.

Seven lists had a hidden LIMIT. Each now reads one row past its cap and returns truncated, so a reader of the quarantine, suppressions, sends, drafts, notifications, audit or log listing is told when older rows exist (§3). Three copies of the routing-rules listing read one page of fifty and stopped; they share routingRulesOf, which follows pages. allowedTypesOf read an unparseable column as “accept everything”; it throws, because only our own code writes that column and anything else is corruption. The Butler release path labelled every failure to reach a run released_after_run_expired, including a D1 outage; the reason now says released_but_run_unreachable with the message when the error was not an absent instance.

One test was deleted: search-copy-world asserted three sentences of product copy against the inbox source as text, which §2c rung 4 calls guarding nothing. Its argument survives in the comment beside the search field.

approvals.ts and provider/cloudflare-grant.ts each changed for five reasons and were split by reason; both paths remain as barrels so nothing that imported or cited them moved.

Spec. Thirteen places where the Blueprint described something other than what is built. None were code defects; all were the document overclaiming or lagging, which AGENTS.md treats as a bug in both places. Each section now says what exists: the resource-style route registry rather than a command plane with four headers nobody implemented; MCP on the session cookie; no per-mailbox version counter; the real submission and delivery vocabularies in the §14 diagram; forward as a sealed send; restore as a runbook with no restore verb; one trigger and reserved node kinds; merge without split; D1 forecasting not built; a TypeScript SDK only; no MailCoreAdapter. And the one thing the code did that the document never mentioned, domain purchase through the registrar, is now in §2 with its gate.

The flag reached the eye the same day. A truncated nobody renders is the hidden limit with a new name, so one component (Truncated in src/client/app/chrome.tsx) prints “Showing the newest N … Older ones exist and are not listed” on the outbox, audit, log, suppressions, quarantine, drafts strip and the notices band. The quarantine sentence says across this Node, because the cap is on the Node’s list and the queue shows one mailbox’s share of it.

The install asks for the URL before it asks to deploy (20 September 2026). Run on an account that already had a Node, mailda install asked “deploy now?”, took the yes, and then refused for want of the Node’s URL, which the canary needs. It knew the account had a Node before it asked, so the question moves to where the knowledge is: an upgrade says so and asks for the URL first. The same run printed every Workflow the account owns and every deployment the Node ever had, both wrangler tables the CLI reads one row from; both are quiet now, by capture’s own rule that a question is answered in the CLI’s words and never echoed raw. And the name is a question, not a flag, because the install is one command and stays one: the account’s Nodes are listed from the ButlerRun Workflows every Node registers, the operator names the Node they want, and the name decides between upgrade and second Node. The probe, the deploy and the claim secret all follow the chosen name’s derived config, where before the secret would have landed in the first Node’s catalog and been refused as already claimed. The URL an upgrade needs is asked once per Node per machine and kept in a git-ignored .mailda/nodes.json. Driven against the live account with expect, declining at the deploy question: the upgrade path, the remembered URL, and a new name each behaved, and nothing was deployed.

Three things a founder saw on the inbox, and one face too many (20 September 2026)

Section titled “Three things a founder saw on the inbox, and one face too many (20 September 2026)”

Looking at the live Node: the search field was small, its hint touched it, and the page was set in two typefaces. The first two were sizes: the field’s input grows from .85rem to 1rem with the pill’s padding to match, and the hint takes a small margin and the pill’s own inset so it reads as a caption of the field rather than a part of it.

The third was drift. --mono is kept by a rule older than the brand, that every figure is tabular, and it had spread from figures to hints, notices, buttons, uppercase labels, table headers, inputs, selects and the whole status bar, so that a screen with no numbers on it was still half monospace. Prose is --body now; a small uppercase label is --body at weight 500 with tracking, the same register without the second face. What keeps the tabular face is what the rule was for: counts and rail figures, the status bar’s numbers and countdown, ids, hashes, state chips, dates in the message list, code and message source. Checked on a local Node with the fixture inbox before and after; the contrast receipt’s tokens are untouched, so its figures stand.

One family, one spacing scale: what a browser audit of every screen found (21 September 2026)

Section titled “One family, one spacing scale: what a browser audit of every screen found (21 September 2026)”

The previous section moved prose off the monospace face and left figures on it. The founder looked again and asked for one font on the entire page, unified styles, and proper padding and margins everywhere. Rather than read the stylesheet, fourteen agents opened each route on a local Node with fixture data, signed in, and measured computed styles: every font family in use and which elements carried it; every element whose text sat within four pixels of its own edge; siblings with gaps under four pixels; buttons, cells and inputs with under six pixels of vertical padding; controls of one role styled two ways. Two hundred and twenty-two findings. Two skeptics per finding re-measured each one to refute it; one hundred and thirty-one fell (already adequate, mis-measured, or a size-and-weight variation the one-family rule accepts) and ninety-one stood. One agent then wrote the plan over the stylesheet, grouped by rule.

Three families became one. Inter was already the body face. The display token named Satoshi, never shipped, with Plus Jakarta Sans behind it, and reached two elements. The monospace token, kept for figures by a rule older than the brand, had spread to twenty-plus elements on the bare inbox and thirty-four with a message open: rail counts, the status bar, the reading pane’s header labels and values, every sender and date in the message list, the state chips, code blocks. Both tokens now resolve to the body face, so every rule keeps working and the audit’s per-element list needed no per-element edit. What the other faces bought is bought another way: headings by weight 700 and tight tracking, figures by tabular digits, machine text by weight 500 in the dim colour. Inter 600 and 700 replace the two Jakarta files, byte-identical to the same Fontsource package as the 400 and 500 already here. The trade the plan named: source in the Butler editor and raw message bodies lose fixed-width indentation cues and are read by indent depth alone.

Six label rules became one. Field names, table headers, rail headings, ledger counts, picker labels, recipient kinds, legends and chips each had their own size and tracking, from .6rem to .72rem. One rule sets all of them, and the chip takes the same size with display inline-block so it stops rendering at two heights on one screen. Selects became one control: the field-row style is the global one, so the inbox mailbox filter (a native Arial select), the queue picker (bordered, monospace) and the composer’s mailbox choice match the inputs beside them. Buttons took one box: primary and quiet share padding and height. The ledger head, declared twice six hundred lines apart with different margins, is one block.

Spacing moved to a scale of .25, .5, .75, 1 and 1.5rem: inputs and selects gain a half-rem inset so text no longer touches the underline’s ends; field rows, switches, grant lists, dry-run rows and Butler actions open up; notices stack with a half-rem between them; the composer dock drops to half the viewport so the reply actions under it stay reachable. Screen edits were three: a flex class the agents’ capability checkboxes lacked, an empty state for the suppressions table in place of a bare “None.”, and captions styled by element so four tables that omitted the class match the one that carried it.

Verified after the edit by the same instrument: every route renders one family, the last stray being the browser’s default monospace on a code element, now inherited away. Typecheck, lint, the client and node suites, the fonts test (four weights of one face) and the contrast scan pass; the contrast receipt’s colour tokens are untouched.

The install claims the Node and creates its OAuth client, and one recorded contradiction closed (23 September 2026)

Section titled “The install claims the Node and creates its OAuth client, and one recorded contradiction closed (23 September 2026)”

A founder looked at the Setup screen of a freshly installed Node and read the ceremony it printed: create an OAuth client in the dashboard, set two grant types, add one exact redirect URI, tick eight exact scopes, leave it private, copy two values back. Twelve fields, each a place to get it wrong, after an install that had promised one command. The question was whether wrangler, already signed in, could do it.

It cannot, and finding out why produced the path that can. Cloudflare has a documented OAuth Clients API, POST /accounts/{account_id}/oauth_clients, which the 9 September receipt had guessed at and left unrun. Probed read-only against the operator’s account, the route answered 403 Authentication error where a made-up sibling answered No route, so it exists and wrangler’s token may not call it; wrangler login --scopes-list has no scope that could. The permission that governs it is OAuth App Registrations Write, read from the account’s permission groups, and only an API token can carry it. So the dashboard visit is not eliminated. It shrinks from a twelve-field form to one token with one permission, and the form is filled by the installer from the Node’s own GET /api/provider, so the scopes and the redirect URI cannot be mistyped.

For the installer to register the client it needs a session, and a session needs a claimed Node, so the install now claims too: administrator email, a password typed twice with echo off, POST /api/claim with the secret the run just seeded, and the ten recovery codes printed in the same terminal. Then the token, the client, PUT /api/provider/client, POST /api/provider/authorize, and the consent opened in the browser. Both questions can be declined, and the ending is then the one it was: the URL and the secret, and the Node’s own screens. --yes reads the three values from the environment.

ADR 42 had rejected a pasted API token, and is amended rather than quietly contradicted: that rejection was of a permanent secret held by the Node in place of a refreshable grant, and this token is held by the operator’s terminal for one request, grants nothing over mail or DNS, and is deleted afterwards. The same reading of the API closed the contradiction the endpoints receipt had carried for two weeks: the create schema admits only authorization_code and refresh_token, so there is no client-credentials or device path and the browser consent is the ceremony. The request builder and the reading of Cloudflare’s answer are a pure module with five tests, each seen to fail against a mutated line; the fetches are in the installer. Not yet measured: a client created through the API completing a consent, which the first install to run this path will report.

An upgrade verb, and a backup before the schema moves (23 September 2026)

Section titled “An upgrade verb, and a backup before the schema moves (23 September 2026)”

The upgrade was mailda install with an existing name, decided two days earlier so that install and upgrade would be one command. Asked how a Node is upgraded, the honest answer exposed a name that overclaimed: the run deploys whatever code the clone holds, so an operator who never pulled gets a canary, a gate, a promotion and the word upgraded while nothing changed. And asked what happens to existing data when the schema changes, the answer was good, expand before the canary and contract only on request, with one gap: the expansions and their backfills run in place on the customer’s only copy of their mail, and nothing took a backup first.

mailda upgrade is those two fixes and nothing else. It finds the release remote by URL rather than by the name origin, since a fork fetches from itself, fetches, and stops with current when the clone is not behind. It fast-forwards only, refusing over uncommitted changes or local commits with the command to run instead, and reinstalls. It refuses a name that is not already a Node. Then it takes mailda backup into .mailda/backups/<node>/<time>, with an administrator’s credentials asked once and kept for the canary gate, and does not proceed without one. It lists the pending migrations by phase, using the same statement-derived rule the deploy refuses contractions by, so the answer to what will this do to my data is on screen before the question upgrade now?. Then it calls mailda deploy, which is not copied. The three readings it makes of text, the remote, the distance, the phases, are a pure module with tests, and each of four mutants was seen to fail, one only after a fixture separated a remote’s fetch URL from its push URL. Not measured: an upgrade with a pending migration on a live Node, which the next release with one will be.

And whether the token itself could go (24 September 2026)

Section titled “And whether the token itself could go (24 September 2026)”

Asked plainly, can it not all be automated, the answer was checked against the current documentation rather than restated. It cannot, below one token and one click, and the receipt now cites where Cloudflare says so: client creation is the dashboard or an API token with OAuth Clients Write, third-party clients get the authorization-code flow only, and there is no dynamic registration. What could still shrink was the token form, and it did: Cloudflare’s token template URLs prefill the permission, the account and the name, so the installer opens that link and the operator’s part is Continue, Create, copy, paste, delete. The permission’s template key is inferred from its label and said to be, in the receipt and on screen.

Setup progress, derived from what the Node already serves (24 September 2026)

Section titled “Setup progress, derived from what the Node already serves (24 September 2026)”

Finishing a Node’s onboarding, the founder could not see how far along it was. The Setup screen’s five sections each knew their own state; nothing summed them, and nothing on the inbox said setup was unfinished. The first idea was a checklist from doctor, since every step has a check. Reading the checks showed why that would overclaim: transport_adapters and sending_events_consumer are ok on every Node by design, and inbound_routing’s prose says that nothing having arrived is consistent with both correct setup and none. A checklist parsing that prose would be rung four of AGENTS.md §2c, and one reading ok would say done about things doctor deliberately does not decide.

So each step reads a structured field instead: the connection state, inbound_routing.ok (an address exists, which is exactly what it means), the routing rows (enabled, nothing still required), and the delivery-events rows (onboarded for sending; subscription with queue and consumer). Three states, with unknown kept apart from to do because an unread source and a checked failure call for different next acts. Setup renders all five with a count and the next step; the shell shows one line on other screens for the two sources it already has, and stays silent about the three it did not read rather than guess. The derivation is a pure function with five tests, four mutants seen to fail. The test stub gained scenery defaults for doctor and delivery events, because both are now read unguarded from the shell.

The token path moves into the Node, and the Setup screen gets it (24 September 2026)

Section titled “The token path moves into the Node, and the Setup screen gets it (24 September 2026)”

The founder, on a claimed Node with no grant, asked to continue from the interface and was told the token path was CLI-only. That is the parity bug AGENTS.md names in one sentence, and it was there because the client creation had been written where the token was first available, in the installer, calling Cloudflare directly. It is POST /api/provider/client now: the Node takes the token, asks it which account when none is given and refuses between several rather than guessing, creates the client with the redirect URI and scopes it publishes, registers it through the same code the pasted path uses, and discards the token. A test reads the binding and the audit trail for the token’s bytes and finds none. The ceremony gained the token page link, the permission’s name and the unverified prefill, so the screen, the installer and mailda provider --connect print the Node’s words rather than three copies. The CLI module that built the request is deleted with its tests; the request is built in one place and tested there. The agent registry withholds the route for the reason the grant exists: a token is a person’s, and handing one to a machine to spend is what the grant makes unnecessary.

An update command with no git in it (24 September 2026)

Section titled “An update command with no git in it (24 September 2026)”

mailda upgrade pulled the release for the ordinary clone and printed the README’s four git commands for a deploy-button clone, which has no history and no remote. The founder did not want an operator typing git at all, and there was no reason they should: the one conflict that merge produces is package.json’s name, measured and held by test/node/update-path.test.ts, so the resolution is mechanical. The upgrade now adds the release remote when there is none, merges once with unrelated histories allowed, keeps the clone’s name and takes upstream’s everything else, and commits. Any other conflict aborts the merge and names the files, since that is an edit only its author can merge. update.sh is the installer’s twin, served from the site: it finds the clone where the install left it, checks the same three tools, and hands over to mailda upgrade. The two pure pieces, which files conflicted and how package.json is resolved, are tested, and the resolution is checked against the same property the update-path test names: the customer’s name, upstream’s scripts.

Drilled twice before it was right. A history-less clone with its own name, run against a local release, was refused as “402 behind, 1 ahead, cannot fast-forward”: git rev-list --left-right --count does not fail on unrelated histories, it counts every commit on both sides, so the clone read as one with its own commits. The detector is merge-base failing, which the update-path test had used all along. With that, the same clone joined the release, kept its name, left no conflict marker and a clean tree, and reported current. The abort path was drilled by accident first: a clone carrying uncommitted files upstream lacked conflicted in ten, and the merge was aborted with their names and nothing half-done.

The first real run, on a Mac with a clone from before the verb existed, found the shape wrong. The script installed dependencies and handed over to mailda upgrade, and the clone’s CLI, being the thing that was out of date, did not know the verb: it printed its usage and pulled nothing. An updater cannot lean on the code it exists to update. So the pull moved into the script: it finds the release remote or adds it, fetches, fast-forwards, or joins a history-less clone with the same one-file resolution, refuses a dirty tree, installs, and only then hands over to a CLI that is current. mailda upgrade keeps its own git half for the operator who runs it from a clone directly, which is the one duplication here, and each side says why. Drilled three ways against a local release: a clone one release behind fast-forwarded; a deploy-button clone joined and kept its name; a dirty tree was refused. pnpm’s “update available” box is silenced in both scripts, since it is about pnpm and read as a step.

And the verb no longer stops at “current” (25 September 2026)

Section titled “And the verb no longer stops at “current” (25 September 2026)”

The second real run pulled the Mac’s clone to the release and then mailda upgrade read the clone as current and stopped, so the Node kept running the old code. “Current” compared the clone with the release and was made to end the run, on the reasoning that there was nothing to upgrade to; but a Node does not know its commit, so nothing here can read what is deployed, and the clone being current says nothing about it. The deploy runs every time now, and its own gate decides: deploying what is already serving costs one deployment entry and changes nothing. --force went with the early stop.

Pointing at a Node instead of spelling it (25 September 2026)

Section titled “Pointing at a Node instead of spelling it (25 September 2026)”

The first real upgrade printed the account’s two Nodes and then “Node to upgrade [mailda]:”, a text field under a list. The founder asked for arrow keys. choose() sits beside readSecret() in the CLI’s support module, raw mode for the same reason, forty lines and no dependency: up and down or j and k move, a digit jumps, Enter takes the row, and the list collapses to the question and its answer so the transcript stays readable. It answers the account, the Node in upgrade, and in install the choice between upgrading an existing Node and naming a new one, which used to be made by typing a name that happened to match. Driven in a pseudo-terminal: down then Enter picks the second row.

Five things the first real run found (25 September 2026)

Section titled “Five things the first real run found (25 September 2026)”

Running the update on a Mac, the founder found five things in a row. The chooser left a stray line and crept right on every arrow press: raw mode turns off the terminal’s output processing, so a bare newline moves down without returning to the first column; the chooser writes \r\n now. Ctrl-C at a raw-mode prompt printed a stack, because raw mode takes SIGINT from the terminal and the prompt rejects with “cancelled” instead; the dispatcher says cancelled once, for every verb, and exits 130. The scripts checked for git, Node 22 and pnpm and pointed at download pages; they now print the one command that installs the missing tool on this machine, by which package manager is present, and do not run it. The token link, in Cloudflare’s account form, opened an account picker and then a blank form; the ceremony now uses the documented user-token form with accountId=*, which puts the account inside the prefilled form, and says what to tick if the prefill still fails. And the inbox’s search hint sat too close to the pill; it moved from a fifth to half a rem, the spacing scale’s next step.

Section titled “The token link, three tries, and the honest fourth (25 September 2026)”

The user-token form prefilled the name and not the permission: an unknown short key is dropped without a word, and this permission’s short key is not documented anywhere. Cloudflare’s docs PR on template links says account-token links resolve a permission group id in the key field, so the third try addressed the exact id. The dashboard refused it by name: “requested an entry we don’t recognize”. Three measured failures on a permission three months old say the template vocabulary does not have it. The link now opens the account’s token page with the name filled in and nothing else claimed, and the ceremony spells the tick: Account → OAuth App Registrations → Write. The receipt records all three, so the next person does not try them a fourth time.

What the token was for: the grant leaves the critical path (25 September 2026)

Section titled “What the token was for: the grant leaves the critical path (25 September 2026)”

After a day of trying to make the token step friendlier, the founder asked the question that should have come first: what do we need the token for, scope by scope. The answer, read off the reach table the tests hold, was that five of the grant’s eight scopes are things wrangler’s login already carries, by a consent every install already has: zones, routing rules, routing settings, queues and subscriptions, sending. The three it lacks are raw DNS records, which the subdomain routing endpoint makes unnecessary because it writes the MX records itself, the registrar, which is domain purchase, and the plan read, which cannot be read anyway. So the grant was buying one thing: a browser doing those acts later, without a terminal.

Measured on mailda.site with wrangler’s token (docs/receipts/wrangler-login-reach.md): every read the Setup screen makes answered 200 except raw DNS and the registrar; enabling routing on a throwaway subdomain wrote three MX records visible in public DNS; a rule was written and deleted. One residue: the subdomain’s records cannot be deleted with that token, so probe.mailda.site keeps three MX records until somebody removes them in the dashboard, and the settings table now says that taking a subdomain out of routing is a dashboard act. Tried to be tidy, and was not; written down instead.

The design that follows is a split by what each side holds. The CLI has wrangler’s consent, so mailda install now asks which domain the Node receives at and does receiving, sending and the subscription itself; mailda upgrade offers the same to a Node never set up. The browser has only what the Node holds, so the grant stays for changes made from Setup later, and becomes optional. There is one implementation: the Node’s provisioning routes accept the operator’s credential in two request headers for one request, and the two functions that turn a request into a credential are the whole seam. The audit entry names which authority acted. ADR 42 is amended rather than reopened; the grant is still the Node’s own credential, it is just not on the way to a first message any more.

One honesty rule came with it. The progress list read receiving, sending and outcomes through the grant, which a Node set up at install may never hold. It now falls back to provisioned on GET /api/provider, the latest act of each kind from the audit trail with its date and authority, and says in those words that it is a record of an act and not a live read.

The inbox that was not ready, and the Node that was never updated (25 September 2026)

Section titled “The inbox that was not ready, and the Node that was never updated (25 September 2026)”

The founder, after the install script, the update script and opening the Node: I ran the setup script, then the update script, then I opened the web and saw the inbox. So as a user of course I thought it is ready to use. If not, you should only show the next setup steps and guide me until the whole thing is ready. This should not be seen in production apps. The first reply from that inbox had refused with E_MAILBOX_HAS_NO_ADDRESS. He also said the search hint still touched the search pill, and that such a thing should have been looked at before being called fixed.

Two failures, named plainly. An inbox was shown on a Node that could not receive: nothing in the interface asked whether the Node was ready before rendering a screen that implied it was. And a spacing change was shipped on the strength of a stylesheet edit, never looked at in a browser.

The check of the Node itself found a third fact under both: mailda-whymelabs had never been updated. Its serving version was still 20 September’s; the only later entries were canaries at zero traffic on 24 September, uploaded and never promoted, so every update run had stopped at the deploy gate. The old interface was the one being judged, and no routing rule or subscription pointed at the Node. The reason the gate refused is not in this repository’s evidence yet; it needs the run’s transcript.

The design that follows, built the same day. A first-run gate in the interface: until a Node has an address and mail routed to it, every screen but Setup and Doctor is one page, This Node is not ready to use yet, with the progress list and the next step’s action two ways, the update command from the clone, which uses wrangler’s consent and needs no token, or connect-then-set-up from Setup; an administrator may open the app anyway, per tab. The rule is those two facts and no more, because they are the ones whose absence makes an inbox lie, and sending is not required to read mail. A mailda setup verb does the receiving, sending and outcomes setup on a claimed Node without deploying, for the Node claimed before the install could. Install, upgrade and setup end with == next: the URL, the address to send a test message to, and that the app shows the next step until it is ready. And a spacing change is measured in a browser before it is called done.

The catch-all, and why Cloudflare decides it by the shape of the domain (25 September 2026)

Section titled “The catch-all, and why Cloudflare decides it by the shape of the domain (25 September 2026)”

The founder, told that a Node receives at one address until more are added, asked: “I thought it should work on the catch-all and then the address logic is all internally managed instead of managed in Cloudflare?” That is the obvious design, and the Node was already built for it: the email() handler has rejected unknown recipients since Layer 1, and the blueprint lists catch-all routes among the address types. Per-address rules were an implementation choice, and one this document had defended by refusing to take the catch-all over at all.

Cloudflare’s own rule settles how far the obvious design goes: “Catch-all entries support apex domains only. To route mail sent to an Email Routing subdomain, list each literal recipient address.” So a Node at a zone’s own name, which is the founder’s case (whymelabs.com, whose catch-all today goes to a Worker named butler beside many literal rules that keep their priority), can take the catch-all and manage every address on People. A Node at a subdomain, the shape the installer proposes so the apex keeps its mail, needs one literal rule per address, and the most the Node can do is write that rule in the same act as the address, and say when it could not.

Measured before building: PUT /zones/{zone_id}/email/routing/rules/catch_all is permitted to wrangler’s login, by a write of mailda.site’s own catch-all content (disabled, drop) that answered 200 and read back unchanged, so nothing moved. Literal rules take priority over the catch-all, so a zone’s forwards survive the take-over. The receiving proposal now reports apex and the current catch-all; confirming with catchAll: true points it at this Worker and records the previous target, and the put-back restores it. The one-rule take-over still refuses the catch-all, since it is a decision about a domain and not an address, and the receiving step is where the whole domain is shown.

Two words kept honest. Taken over is what the audit entry records this Node asked Cloudflare for, with the target it replaced. Confirmed is a read-back, as it has been for the MX records since #92: the catch-all is read again after the write, and the outcome carries what Cloudflare then reported rather than what was sent.

The first real setup run, and what raw DNS had to do with it (25 September 2026)

Section titled “The first real setup run, and what raw DNS had to do with it (25 September 2026)”

The founder ran the update against mailda-whymelabs with whymelabs.com picked from the zone list, the first time the setup step reached Cloudflare with wrangler’s login. Three of four things worked: the picker, sending (onboarded already, so no write was measured), and the delivery-events subscription, which was created (mailda-whymelabs-sending-events-whymelabs.com, six event types into the Node’s queue). That last one is the subscription write with wrangler’s token, inferred until then and measured now.

Receiving refused, and the refusal was ours twice over. The proposal read the apex’s existing MX through GET /zones/{zone}/dns_records, raw DNS, which wrangler’s token cannot read: 10000 Authentication error, the boundary the receipt had drawn the day before and the code had not honoured. Then the CLI blamed the domain, “must be a subdomain of a zone”, which was false. The catch-all question was never reached.

Measured the next morning: Email Routing’s own records endpoint answers what raw DNS was being asked. For a subdomain never enabled it lists what is missing, record by record; once enabled it lists records with errors: null; for an apex with routing ready it lists the zone’s own records. So it is the plan and the read-back in one, POST on it creates the records, and raw DNS is not needed for receiving at all. The receiving code moves onto it, the wrong fix goes, and the receipt gains the run and the shapes.

The Node receives (25 September 2026, 17:08 UTC)

Section titled “The Node receives (25 September 2026, 17:08 UTC)”

The founder’s fourth update run reached the end: deploy, promotion, then the setup with the zone picker, the catch-all rows and the mailbox’s first address. The receiving proposal read the apex’s routing records through Email Routing’s own endpoint and had nothing to write; the catch-all on whymelabs.com, which pointed at a Worker named butler, was taken over, read back as this Worker, and the previous target recorded for the put-back. The eighteen literal rules on the zone, four of them forwards, were untouched, verified from the account afterwards. Sending and the delivery-events subscription were already in place from the run before. The Node’s first address is hello@whymelabs.com; every other address at the apex is filed if a mailbox holds it and bounced otherwise. What remains unmeasured live: the catch-all put-back, and a message actually arriving, which is the founder’s next act.

One credential, not two; a hostname of one’s own; and how a person gets in (26 September 2026)

Section titled “One credential, not two; a hostname of one’s own; and how a person gets in (26 September 2026)”

The founder, looking at the Setup screen’s API-token block after the Node received, asked whether it was still needed, and answered his own question: the OAuth client and an API token each cost one dashboard form, so keep one, the simpler. ADR 42 is reopened accordingly. The private client, the consent, the hourly refresh, the five connection states and the scope ceremony are struck; the Node’s own credential is a stored API token, verified with Cloudflare, bound to the one account it can see, wrapped like the sending token already was, and forgotten on request. The hygiene argument the decision had made against a pasted token is answered by what a token is: account-restricted, permissions visible, an optional expiry, one revocation list, no state machine. The three OAuth receipts stay as measurements, marked superseded. None of this touches the ordinary path, which is wrangler’s login carried on one request at install.

The second question was a hostname of the Node’s own, which had been dashboard-only because changing it also changed the OAuth redirect URI. With the redirect gone, it was measured on a scratch Worker: a first wrangler deploy attaches a custom domain, the canary path keeps it, wrangler triggers deploy adds one to a Worker already deployed, and deleting the Worker leaves no DNS behind (docs/receipts/worker-custom-domain.md). The install and the upgrade ask for one.

The third was how a new person gets in, and the answer was already built and undocumented at the place a person would look: an invitation minted on People, shown once and not mailed; a password chosen on the link; nothing held until a relation is granted; and an address of their own added to a mailbox they hold, which under the apex catch-all is the whole act. The README and the shell doc say so now.

A domain onboarded before the Node is a sighting, and the Node records it (26 September 2026)

Section titled “A domain onboarded before the Node is a sighting, and the Node records it (26 September 2026)”

mailda upgrade on the live Node ended with sending: not set up for a domain that sends. The audit trail explained it: at install the domain was already onboarded, GET /api/provider/sending said so, the CLI printed “onboarded already” and never posted, and POST /api/provider/sending would have refused it as E_PROVIDER_SENDING_ALREADY anyway. provisionedFacts reads only that trail, so the Node’s own record, the first-run progress list and the upgrade summary all said sending was never set up. The refusal is gone: a confirmed proposal for a domain already in place records provider.sending_observed (a subscription already publishing into a queue this Worker consumes, provider.delivery_events_observed) and answers with the proposal, Cloudflare untouched; a stale digest still refuses. The record marks a sighting as one, and the progress list says “in place on Cloudflare before this Node, observed on …” rather than “set up at install”. The install posts in that case and prints “onboarded already, recorded”. Receiving never had the gap. Beside it: provisionedFacts no longer swallows a failed read into “nothing set up”; the doctor’s sending_events_consumer finding stopped naming a grant that no longer exists and a consumer step the confirm now performs; butler_execution names the ceiling, simulation and the four replay modes as built and only taint tracking and the second trigger as not; and the canary gate’s doctor prints are headed by whose report they are, since the incumbent’s degraded lines read as the canary’s repeated.

The upgrade finishes each setup step on its own record (26 September 2026)

Section titled “The upgrade finishes each setup step on its own record (26 September 2026)”

The sighting above was written, deployed, and the next mailda upgrade still ended sending: not set up. The upgrade ran the setup only when provisioned.receiving was null, so a Node with receiving recorded at install and sending onboarded from the dashboard before the Node existed never had its sending step run, and the record the sighting exists to write was never written. provisionNode now takes the Node’s provisioned record and skips each step on its own record, printing recorded <date>; the upgrade and mailda setup call it whenever any of the three is missing, and a recorded receiving domain is reused rather than asked for again. Measured on mailda-whymelabs the same day.

The interface redesign (26 September 2026)

Section titled “The interface redesign (26 September 2026)”

A design memo asked for a calm, dense three-pane mail client: a grouped sidebar with a Compose button, a list with one-line previews and All / Unread / Mine / Waiting tabs, a reader with the headers folded behind a to … disclosure and a row of suggestions, a status bar reading Connected and Health, a command palette and single-key shortcuts, archive and trash, a dark palette with one sans family, and every existing route kept. Most of it was built as asked. What the code forced, and what the user decided, is the part worth keeping.

The memo’s palette failed its own contrast check before anything was drawn with it. Its muted grey, the colour of every time, preview and group label, measured 3.10:1 on a selected row, and its red 4.13:1 there; its primary text colour on its accent was 2.15:1, so Compose carries dark text in dark mode. Each was adjusted, the light theme was designed to the same hierarchy, and the receipt keeps the three failures as inequalities a change back would trip (contrast-tokens.md).

A preview needed a decision, not a column. The memo’s one line of each message is body text, and ADR 28 had said there are no body excerpts in a result list, because D1 holding content is exactly the dump ADR 28 defends against. A per-row body fetch was fifty R2 reads a page and, for a supervised reader, fifty recorded opens nobody made. So the preview is a column of messages sealed under the content key, opened only for a reader with standing content read and never under a supervised grant, re-sealed with its receipt when the key rotates, and written by a backfill for older mail that waits for the others to go idle (ADR 45, and ADR 28 amended by it). The sender’s display name came with it, and drops any name that looks like an address or a domain, because that is the spoof such a column carries.

Three things the memo asked for are not here, each for a reason written down. No Waiting tab: cases are open, claimed or closed, and deriving we replied last would put an unmeasured probe on every row and claim the other side owes an answer when our reply is only handed_over. No Agent runtime row in the Health popover: agents are external principals and nothing of the kind runs here, so the row is Automation, and Access and recovery was added because recovery and legal-hold checks can set the verdict. And no Mailda suggestions: the reader’s Next steps are deterministic, carry no AI styling, and leave the AI slot empty because no llm.* node runs.

Four defects that had already shipped were found on the way. Mark unread on an open message was undone at once: the effect that marks a message read keyed on the read flag, so clearing it fired the effect again (it now fires once per open, and patches the listing instead of refetching it, which for a supervised reader was one more audit entry per open). The reply-collision alert rendered far below the Reply button on a full page, and could steal the case of whichever message was selected by the time somebody pressed Take it anyway; it is bound to the message whose claim was lost now. A hand-over’s compare-and-swap compared against the Node’s own read of the holder, not the holder the giver saw, so a colleague’s claim made while the giver was looking could be overwritten; the assign route takes the holder the giver saw. And scripts/axe.mjs had never passed the first-run gate on a harness Node, nor opened the reply state, since the name reply matched the reply all button as well, and it exited 0 either way.

The user decided three things. The Inbox, Unread and Mine look back through at most messages.max_lookback of the messages a reader can see per request and say so (None of the newest N messages you can see is in your Inbox, and Look further back), rather than walk every message a person has ever filed to fill one page; Archive and Trash page from their own index. The per-message bytes figure was not remeasured, since that needs a throwaway database in the user’s Cloudflare account, so the shard thresholds derived from it are withdrawn until it is, rather than left reading as measured. And the theme is a choice, Dark by default, Light or System in Settings, kept per viewer in the browser, because following the OS alone showed Light to everyone whose OS states no preference.

What was measured when it was assembled, and what was not yet. On the finished tree, before any commit: both TypeScript programs and the linter clean; the workerd suite 1,910 tests in 99 files, the node suite 648 in 72, the DOM suite 384 in 35, all passing; the generated budgets, SDK and Skill regenerate byte for byte. The lookback is 500 messages a request, at 9 rows read per message it looks through (message-page-size.md). The authenticated bundle is 731,737 bytes raw and 211,745 gzip, 7.5% and 8.4% over the 680,900 and 195,380 it was the morning the redesign started, and those were already 28.6% over the receipt’s last recorded figure, which nobody had remeasured; both steps are recorded now (react-shell-bundle.md), and nothing before sign-in loads any of it. The byte-per-message drift guard carries the five new columns with a note saying they were added without a remeasure, beside the withdrawn shard thresholds. Not done at assembly, and owed rather than assumed: the accessibility run over every route and state in both themes, the browser walk-through of claim, reply, send, archive and undo against a seeded Node, the screenshots, and whether the sandboxed body frame loads its own stylesheet in a real browser. docs/application-shell.md says the audit is owed instead of keeping the old numbers.

The redesign reviewed in a real browser, and what that found (27 September 2026)

Section titled “The redesign reviewed in a real browser, and what that found (27 September 2026)”

The assembly above left three things owed: the accessibility run, a browser walk through the mail flow, and the screenshots. A review did all three against a seeded local Node, and its findings were fixed in the same change. The ones worth keeping are the ones only a browser could have found, and the ones where the words had got ahead of the code.

A modal gave focus back to nothing. Every dialog returned focus in its cleanup while it was still open and modal, so the page behind it was inert and the focus() did nothing: after every Escape, focus fell to the page. The unit tests had passed over it because happy-dom has no inertness, so the client test setup now emulates it; the fix was confirmed in Chromium.

The words had got ahead of the code in five places. The type was said to be on six sizes and the sheet set others (16 px on a toast’s ×, then 13 on the file control’s text), so test/node/type-scale.test.ts now reads the served sheet. The draft-flush paragraph said signing out was covered, and a reply typed and signed out of at once came back without its last words, so the Drafts section now records that loss as not covered. A comment said Settings reported a shortcut choice the browser would not let it read, and it said nothing, so now it does. ui.ts said the theme was applied before anything rendered, and a module script can run after the first paint. And the accessibility section said its run was owed after the run existed.

The accessibility run found ten AA violations with one root cause, the sidebar’s Admin toggle squeezed under 24 px, and after the fix measured 68 views in both themes with 0 AA violations, 94 unproven nodes in 10 rule results (contrast under an overlay, which the token test covers) and 2 advisories (region on sign-in). Its own harness had three defects of its own: the documented -- <origin> crashed on Invalid URL, a rule count was printed where a node count was meant, and a Node without a paused Butler could not be told from a state that failed to open.

Among the rest, each with its test: a discarded or sent reply wrote its draft back afterwards; R pressed before a message’s body arrived addressed the reply from the list row, to From rather than Reply-To; the composer docked as a card over the reader and left a clipped strip of the original beside it; a refusal about a reply was said under the open composer, where nobody could see it; a held send stayed held on screen until something else refreshed the outbox; and a display name of a full-width @ address, a Cyrillic domain or a Hangul filler reached the list as the sender.

What the lanes left each other. Four requests between the fixes had no one after them to pick them up, and were done at the end. A second draft of the same reply, reached from two tabs, surfaced the unique index as a 500; it is now a 409, E_DRAFT_EXISTS, naming the draft in progress. POST /api/maintenance/requeue-previews had no button, so the Doctor screen now offers Requeue failed previews under the failing preview_backlog finding, beside the other remedies. The status bar’s connection word is a polite live region, so a change from Connected to Offline is heard. The sign-in rack is a <header>, which clears the two region advisories. Measured after them: 68 views, 0 AA violations and 0 advisories; the authenticated bundle at 737,772 bytes raw and 213,501 gzip, 8.4% and 9.3% over the figure before the redesign.

The convergence review’s first round (27 September 2026)

Section titled “The convergence review’s first round (27 September 2026)”

A second pass over the redesign, again in Chromium against a seeded local Node, closed what the first left open and three things it had got wrong.

Two losses that were recorded are now covered. Signing out lost the words typed in the autosave pause, and the Drafts section said so; Sign out and Sign out everywhere now save the open draft first and keep the session, with the Node’s reason, when it will not take them. And a seal refused after its dock had gone lost the words, because the flag that stops a sent message being written back answered saved for them; a write asked for mid-seal now waits for the Node’s answer, and Close and Discard wait with it. What stays lost is a sign-out the Node imposes, which arrives after the session has gone.

The words had got ahead of the code in three more places. The list’s status region spoke a total (12 messages match these filters. on page two) and a zero the screen is careful not to print; it now speaks the count’s own words and the empty screen’s sentence. The lookback sentence took its N from the bundle’s copy of the budget; it now takes the bound the answering Node says it used. And the requeue route and doctor said the preview backfill ran 25 a minute, a literal beside a constant and a cadence it does not have: it runs only on a scheduled pass that finds the body and authentication backfills idle, and both messages are built from PREVIEW_BACKFILL_LIMIT now.

The accessibility run was measuring loading screens. It audited the moment the shell mounted, so the slower routes were audited as their Reading… notice, in one theme and not the other, and one Queue state clicked a row that opens nothing. It now waits for each view to load or fails the run, and the Queue state opens Hand to…. Measured after: 68 views, 0 AA violations, 0 advisories, 96 unproven nodes in 10 rule results. The authenticated bundle is 739,675 bytes raw and 214,247 gzip.

Among the rest, each checked in Chromium (the Safari case by emulating its press, since WebKit would not launch there): the message body frame had no focus ring when Tab reached it, because a sandboxed frame fires no focus event in the page (it now draws the accent ring from the window’s blur); a toast’s Undo dropped focus to the page; a popover’s own button reopened it on a press that does not take focus, as in Safari; on a phone the full-screen composer covered the non-dismissible bands above it; and opening from /drafts the draft already in the composer replaced it.

The convergence review’s second round (27 September 2026)

Section titled “The convergence review’s second round (27 September 2026)”

A third pass, again in Chromium against a seeded local Node, and again it found the words ahead of the code in a few places.

The accessibility run waits for what it can count. It waited for Playwright’s networkidle, which is answered at once after its first time, so a view a click had opened was audited with that click’s fetches still out; and once the reader’s sandboxed body frame had attached, it sometimes never fired with nothing in flight, which failed three views on a healthy Node. The harness now counts the requests the page and its frames start and waits until none has been out for QUIET_MS, and a view that does not settle names the requests still out. The Hand to… state’s release looked for its row by the field the release takes away, so it timed out on every run and blamed a draft; it finds the row by its merge checkbox now, runs whether or not the state opened, and a failed undo names what it could not undo. Measured after: five runs in a row, 68 views each, 0 AA violations, 0 advisories, 94 unproven nodes in 10 rule results. The authenticated bundle is 739,952 bytes raw and 214,355 gzip.

A dock that is sealing is kept. Opening another composer while a seal was in the air replaced the dock, so a refusal landed on a dock nobody saw and a seal that succeeded closed the new one; the dock now stays, and the assertive region says why. A sign-out held back for an unsaved draft was offered again as Sign out anyway even when what it held was Sign out everywhere; it now carries the held one’s own name.

Among the rest: on a phone the full-screen composer started under a screen’s own status line (Matter opened.), which the band rule caught along with the two real bands and stretched edge to edge; only Setup is unfinished and the Notifications band are bands now. Opening a message’s details dropped the to … disclosure a few pixels, from under the pointer that opened it; it stays put now. The label field has a visible hint, Label, then Enter. And two sentences in the shell’s documentation said more than had been run: a popover’s press in Safari and Firefox, emulated in Chromium with neither browser run, and where a toast’s Undo gives focus back, which by Tab is Health, the control before the toasts, not the row; that path is checked in Chromium now too, on 27 September 2026, and no test in the repository runs it.

The convergence review’s third round (27 September 2026)

Section titled “The convergence review’s third round (27 September 2026)”

A fourth pass, in Chromium against a seeded local Node, and the accessibility run was again what had said too much.

The accessibility run had read clean over three defects. It audited every route at one width and only as far as the window showed, so a defect below the fold or in a narrow layout was never on screen when axe looked. It now audits every route at 1280, 1024 and 390 px wide, grows the window until nothing on the page scrolls vertically, fails the run on a view it could not show whole top to bottom, and adds the first-run gate, the invitation form and a refused sign-in. With this round’s three fixes reverted, where the old sweep had read 0 AA violations, it found 8, three defects in four views in each theme. The capability checkboxes on /agents stood 23.3 px apart at 1280 and 1024, under the 24 px target; a row is at least 24 px now. A table on /limits at 390 px scrolled sideways inside an unnamed div no keyboard could reach, one of nineteen such sites, all a named region in the Tab order now (Scroller in chrome.tsx). And the headers dialog’s block, once the fixture carried a header block of real size, had the same fault; it is a region named Header block now. Measured after: 146 views, 0 AA violations, 0 advisories, 92 unproven nodes in 8 rule results. The authenticated bundle is 740,995 bytes raw and 214,872 gzip.

A popover opens where it can be read whole. More actions opened leftward from a ••• the action bar had wrapped to the reader’s left edge, so on a phone its labels were cut off and at 768 px the list pane covered them, and Assign ran past a 768 px window. Each popover and the menu now keeps the side it prefers when that fits, and takes the other when the window or a pane that scrolls would clip it.

Among the rest: R on another message while a seal was in the air claimed that message’s case before saying the dock was sealing; it claims nothing now. The alert for a sign-out held back by an unsaved draft said the draft was not saved when you signed out, which the person had not; it says when you asked to sign out. The Hand to… state’s release found its row by the merge checkbox’s name, which two cases with one subject share, so a twin’s Claim answered its wait before the release had landed; it presses Release in the row holding the open field and waits for that field to go. The reader’s promise that the actions never move under the label line is narrowed to where it holds: where the labels and their field fit beside the to … line. On a phone, in a two-pane window at its narrowest with a label, or with the details open, they take a line of their own and the actions sit that line lower while it is there. And three sentences in the documentation said more than had been run: that the accessibility run leaves the queue as it found it, which the reply state’s claim does not; that the second round’s five runs were on one Node, where they were on two; and that a toast’s focus path was asserted, where it was checked in Chromium and no test in the repository runs it.

One change in that round was not in the interface, and it grew into the outbox’s publication rather than a patch to it. A message accepted just before a sweep that a page load had armed stayed accepted and out of the inbox until something unrelated woke the Node: the sweep found it younger than the five-second claim cutoff, asked the same question, whether anything was claimable, to decide whether to wake again, and let the alarm lapse. Reproduced on a local Node (a page load, a delivery half a second later, not in the inbox after 40 seconds, listed 5 seconds after the next page load). The cutoff existed to keep the sweeper from racing an in-request “fast path” that the email handler, apps/node/worker/src/outbox.ts and ADR 31 had described since Layer 1 and that was never built: the handler only armed the alarm, five seconds out, so every message had waited at least five seconds for a race with nothing. The sweeper’s alarm is now the only publisher, armed for the present instant on acceptance, and a row never tried is claimable at once; building the described path was rejected, with the argument in the module and in ADR 31’s amendment. Each claim writes attempts and retry_at before its handler runs (migration 0069), so a failed event, or one whose pass was killed, backs off on its own row (five seconds doubling to a five-minute cap), and a retried event is claimed after every event with fewer attempts, so one that kills its pass cannot head the next; the old claim, the 25 oldest unpublished rows, let 25 failing events stop all publication. The minute cron arms the sweeper whenever an event is due and unclaimed, the three swallowed catches on the path now log (outbox.handler_failed at each doubling of attempts, outbound.sweep_failed from the alarm, outbox.arm_failed), and doctor’s outbox_draining tells a failing event from a sweeper that is not running. On a fresh local Node five single deliveries were listed 90 to 411 ms after acceptance, including one half a second after a page load and one while the next alarm was a failing event’s backoff 19 seconds away. Twenty-one cases in apps/node/worker/test/outbox.test.ts, each seen red under a mutation of the line it covers.

The convergence review’s fourth round (27 September 2026)

Section titled “The convergence review’s fourth round (27 September 2026)”

A fifth pass, and again what nobody had looked at from a keyboard.

The composer hid Tab stops. Over the reader column, or over a phone’s whole screen, the dock left everything under it in the Tab order: Reply, Assign and the message frame took focus nobody could see (WCAG 2.4.11), and Escape does not close the dock. What it covers is visibility: hidden now, in the rules that place it. The card on a screen without a reader column covered the Queue’s last Claim buttons and an Outbox row’s .eml; the column behind it makes room for it and scrolls a focused control above it. The headers dialog lost its 16 px gutter below 792 px, and ran edge to edge at 760 px or narrower, because a rule sizing the reader’s children outranked its own width; its block wrapped a long header line back to the left edge, where it read as a new field. It keeps at least a 16 px gutter (17 px at the shell’s 14 px type, where the browser’s own modal max-width binds first) and scrolls sideways. R pressed on another message just before a seal began, whose body arrived after it, still claimed that message’s case; the reply asks again once the body is in. The authenticated bundle is 741,028 bytes raw and 214,879 gzip.

The accessibility run looked for the message list to decide that a Node was ready, so a ready Node with no mail failed the run; it looks for the shell. Its Hand to… release found the claimed row by the field the state opens, so a claim followed by a failure before the field opened was never given back; it finds the row held here. And a view’s growth was printed above the view’s own line, where it read as the previous view’s. The documentation said the run grows a view until nothing on it scrolls, where it grows it only in height; that the sweep had found four defects, where it found three, in four views per theme; and that four popovers were checked at four widths, where two were.

The per-message bytes, remeasured (27 September 2026)

Section titled “The per-message bytes, remeasured (27 September 2026)”

Row projections (0068) and a person’s places (0067) landed without the remote measurement, and the three shard thresholds were withdrawn rather than carried on a figure known to be low. With the account owner’s consent the script ran twice against a scratch D1 that it created and deleted (receipt). A message now costs 2,089 bytes, up from 1,788: 270 of the difference is 0068’s sealed preview and display name, and 31 is the corpus itself. The script had padded every id to three characters short of what ctx.id mints since 12 August while its comment said 30, so every figure it produced until now was a little low. An extra delivery is 432 bytes, and a filed place, measured for the first time, 391. A 10 GB shard holds at most about 5.1 million messages, an upper bound because the rows every message also keeps in the ingress receipts, the outbox and the search index are not in the figure, and the thresholds are back in the budgets: warn at 3,597,986, stop bulky projections at 4,368,983, route new metadata at 4,625,982. The account-wide ceiling’s figure in the receipt had not moved since 12 August’s divisor; it now reads at most 526 million.

Eight lines under a green suite (27 September 2026)

Section titled “Eight lines under a green suite (27 September 2026)”

CI’s workerd run printed eight uncaught exception lines while every test passed, on this branch and on the pull request before it. Run file by file, they came from four tests, and none of them left a promise floating. workerd logs a throw from an RPC method in the isolate that threw, at the moment it throws, even when the caller catches the rejection; no unhandledrejection event fires there, so nothing in a test can see it. That was measured under miniflare with verbose logging, not on Cloudflare.

A release asked the platform about a run its own record said had ended. releaseButlerSend called BUTLER_RUNS.get for every released send that had a run, and two tests release sends whose runs were driven inline and were never instances. Miniflare’s get asks its engine object for a status, which throws twice, and then throws instance.not_found itself: three lines per release. The query that finds the run now returns its state too, and the instance is contacted only when the record reads awaiting_release. In production that saves a get and a sendEvent on every release of a run that had timed out, and it keeps resumed from depending on whether the platform accepts an event for a completed instance it still retains, which is unmeasured. released_after_run_expired, the state a parked run is closed with when its instance has gone, had no test until now.

The vault refused an unknown generation by throwing across RPC. KeyVault.openingKey now answers null for a generation it never held, and requireOpeningKey raises the same E_VAULT_UNKNOWN_GENERATION text in the caller’s isolate. A lost vault is a documented state with a remedy and should not look like a fault in the vault on every read. The change found a real defect beside it. The recovery escrow read each generation with .catch(() => null), which treated a vault that failed to answer the same as a generation it never held. An escrow’s currency is judged by its newest generation, so a mint that met one such failure wrote an escrow missing an older generation that still read as current, and a restore from it could not open that generation’s mail. The failure now fails the mint; with the old catch put back, the new test sees minted.

The build now fails on these lines. The workerd suite runs through apps/node/worker/scripts/fail-on-uncaught.mjs, which passes the output through and exits non-zero when a line begins uncaught exception. That prefix covers every form the installed workerd logs, ; source = … (the only one CI has shown), ; exception = … and ; description = …; the first version matched only the first. Reading the output is the only option: the pool does not let a config replace the handler that prints workerd’s logs, and a per-test assertion has nothing to observe. It was proven end to end three ways. A planted, caught get of an instance that never existed: one test passed, three lines, exit 1. A planted vault method that threw across RPC: one test passed, one line, exit 1. The release gate removed again: contract-responses.test.ts passed all 81 tests, three lines, exit 1. The real-binding tests in butler-run.test.ts now also end every Workflow instance they create, where two of them had left one running or parked after the test finished.

The guard’s blindness is a test, not a note. Read from output, the guard exits 0 both for a clean suite and for one whose lines never reached it: verbose: false in vitest.config.ts (the pool defaults it to true), a pool that stops forwarding the lines, or a workerd that rewords them. The first plant is now kept as apps/node/worker/test/canary/uncaught.canary.ts, outside the workerd suite by its suffix and run through the workerd suite’s own config by vitest.canary.config.ts. test/node/fail-on-uncaught.test.ts runs it through the guard and expects exit 1 with a reported line, and the canary’s own test passing, about 7 seconds alone. Each way of going blind was tried and each turned it red: verbose: false, the guard’s pattern reworded so it no longer matched workerd’s wording, the report’s line entries renamed, the exit code ignoring what was seen, and the canary test failing while the lines still printed.

Code by mail, and a limit you can see (28 September 2026)

Section titled “Code by mail, and a limit you can see (28 September 2026)”

A zip of source code could not be sent at all. The seal refused any attachment the inbound judge calls dangerous, and a project’s source lists .js or .py files, so verifylab-1.0.0.zip met E_ATTACHMENT_DANGEROUS exactly as a zip of malware would. The judge cannot tell somebody’s own code from a program meant to be run; its author can. The seal now takes allowDangerousAttachments: true on POST /api/sends, and only the literal true (a truthy string or a number is still refused, tested through the route). The refusal’s fix names the field. send.sealed carries dangerousAttachments, the ordinals of the parts the flag covered, whose send_attachments rows hold the names and verdicts. Butlers never set it, and it never reaches a forwarded original: E_FORWARD_CARRIES_DANGEROUS still refuses one that carries a flagged file. The composer judges each file as it is attached, with the same classifyAttachment, shows a warning under a dangerous one, and sends the flag only while that warning is on screen. The warning says what receiving servers do: Gmail refuses .js, .jar, .exe and more even inside a zip, so a link can still be the only way such a file arrives.

The attachment limit was on no screen. A send may carry 20 files and 4,718,592 bytes of attachments at base64 size (4.5 MiB): a provisional 90 per cent of Cloudflare’s receipted 5 MiB outbound ceiling, or 3,538,944 bytes of files (3.375 MiB). Neither the 0.9 nor the 20 is measured or has a receipt. The only place the number appeared was the seal’s refusal. It is under Attach now, counted as the seal counts it, from src/outbound/attachment-budget.ts by way of /app/config.js, and Seal and send is disabled past it. The label rounds down, to Up to 3.3 MB (the composer’s MB is 1,048,576 bytes): rounded to nearest it said 3.4, a promise of room for a file the seal would refuse. Asked whether there was a limit at all, the first answer here was 4.7 MB, the encoded figure in decimal megabytes read as a file size, which is the confusion a limit shown nowhere but in encoded bytes invites.

What review found before it merged. The usage line rounded both figures down, so a send just over read 3.3 MB of 3.3 MB used … Over the limit, and a small file read 0.0 MB; usage now rounds up once it is over and reads in KB under a tenth of a megabyte. A file was missing from the list until it had been read, with the send still enabled, so a send pressed in that window left without it; it is listed at once, reads Checking…, and blocks the send until judged. The over-limit line kept .hint’s grey, because .hint came later than .bad at equal specificity, which only a real browser showed. The refusal offered the flag before the mailbox’s own limits had spoken, a remedy the next refusal took back; it is now the last attachment refusal. And both the refusal and the warning said Gmail refuses every file this Node flags, which is false for .py, half of the zip that started this.

A second round found four more. The seal’s entry first named each flagged file, and twenty flagged files under long names came to 3,713 bytes, past audit.max_detail_bytes, where the whole detail is replaced by a truncation record and the seal’s policy decision goes with it; it lists ordinals now, and a test seals twenty such files and finds the entry whole. The usage figure was worked back from the base64 size, padding included, so a 4 MiB file read 4.1 MB beside its own 4096 KB; it sums the files’ own sizes. A send held only because a file was still being checked gave a screen reader no reason, since Checking… sat in the file’s row and not in the live line; the line now says so. And the file picker, locked while sealing, still looked live, because its button’s own colours override the browser’s greying.

A third round found that the line had first counted the checks down (Checking 2 files…, then 1 file), and added to its dangerous count as each verdict landed. A live region is read again whole on every change, so twenty files meant twenty-one readings. It now reads Checking files… until every file is judged and only then counts the dangerous ones; each file’s own warning still appears as soon as it is judged.

An event with nothing to key on, and a fix that named a cause it could not know (28 September 2026)

Section titled “An event with nothing to key on, and a fix that named a cause it could not know (28 September 2026)”

A delivery event with no eventId or no recipient was acked and left no trace. applySendingEvent returned applied: false and the consumer acked it, so an outcome Cloudflare did publish read exactly like one it never sent. It is now logged as sending_event.unusable at level error, with its type, its message id and which field was missing, and never the recipient (GET /api/logs is for administrators, and administering a Node is not a grant to read its mail). EventOutcome.unusable names the fields, so an unusable event is no longer the same answer as a redelivery. It is still acked: a retry cannot make the fields appear. A field that is empty or not a string now counts as missing. Before, an empty eventId became a primary key, and a null body, or a non-string message id, type or bounce field reaching bind, threw, which the consumer logs as sending_event.failed and retries, though no retry can change the body. A type spelled like an Object method (toString) matched the delivery-state map through the prototype, and now does not.

send_breakers said why the breakers cannot fire, and it could not know. Its fix read “received no delivery outcome it could attribute … Fix the event subscription”. The predicate is delivery_visibility’s ok, which is also false when doctor could not read the delivery tables at all, so the fix now sends the reader to that finding and claims no cause of its own. And delivery_visibility’s header, which still said no tool in this repository creates the event subscription and that §5A forbids the credential needed to read it, now names POST /api/provider/subscription and GET /api/provider/delivery-events, which have done both since ADR 42.

No outcome is reported for verified destinations (28 September 2026)

Section titled “No outcome is reported for verified destinations (28 September 2026)”

The observation. mailda-whymelabs replied four times. The reply to an address on whymelabs.com produced a message.delivered 18 seconds after hand-over. The three to the operator’s own gmail.com address produced nothing, and that address is the one verified Email Routing destination of the account among them; the first of the three was confirmed in the inbox. So the Outbox showed those recipients as unobserved, exactly like a recipient whose answer is still coming, and mailda doctor would have called a Node that sent only to them blind and sent its operator to rebuild a subscription that works.

The confound, found before building on it. Every event either Node in the account had ever received was for a recipient on a Cloudflare MX, or for example.invalid, which never left Cloudflare. The only external mailbox sent to until then was also one of the account’s six verified destinations. So “no event for a verified destination” and “no event for mail delivered outside Cloudflare” fit the same four sends. One send to a second gmail.com address that is not a verified destination told them apart: it was answered with message.delivered in 27 seconds, on the same subscription and the same receiving provider as the three silent sends. So the silence is about verified destinations, sampled on one address (docs/receipts/email-sending-events.md, the addition of this date, which also records the MX lookups, every event on both Nodes by recipient domain, and every send with its message id and timing; recipients are described there rather than named).

What is recorded, and why an interval. POST /api/provider/verified-destinations reads the account’s destination list, with the Node’s token or with wrangler’s login carried by mailda setup and mailda upgrade, and stores only the intersection with the addresses this Node has already handed mail to: the list can hold anybody’s address, and the Node keeps one more fact about addresses it already had and nothing about anyone else. Each row keeps an interval, from Cloudflare’s own verified timestamp to the latest read that listed the address, and rows are never deleted. Replacing the set on every read was the first design, and it was wrong both ways: an address verified after a send explained that send’s silence, which hid real blindness, and an address removed later turned an explained send into an unexplained one, which raised a false alarm. A hand-over after the latest read is extrapolated for an address that read still listed, and every sentence that relies on that says so.

doctor tells three silences apart, in the one statement it already issued: explained by a read, covered by a read and not explained (the subscription is the suspect, and the fix leads with it), and not covered by any read yet (the fix asks for the read first). “Covered” compares the manifest’s state_at, stamped after every recipient write, and not the recipient’s hand-over time, which is the dispatch pass’s start and would have counted a send still dispatching during the read as checked. Three kinds of evidence void every explanation: an event published for a recipient a read explained, of any type (a complaint sets no delivery state and still counts), and an event this Node could not attribute for an address a read listed, both of which contradict the receipt, and an event that arrived unusable, which may have been somebody’s outcome. That last one exists because of the silent drop in the section above: before it was logged, “no row” could not be read as “Cloudflare published nothing”. On a blind Node the voiding is in delivery_visibility’s own words. On a Node that is hearing outcomes it is a separate degraded finding, delivery_explanation_void, because turning delivery_visibility red there would tell the breakers they cannot fire, which is false; the first draft put the evidence inside a passing finding, so the receipt’s tripwire could fire under an OK verdict.

Counts, not addresses. The route, the Setup screen, the CLI and doctor say how many; which recipients they are is shown only by the Outbox, which is bounded by who may read the send. The read is audited as provider.verified_destinations_read, naming the actor, the credential and the account, with two counts on a read that succeeded or the failure on one that did not. A read that fails is recorded as could not read, never as none verified, and an operator credential naming a different account from the Node’s token, or with no token held from the account the last read named, is refused rather than read. Voided evidence stops a silence counting as explained and leaves what a covering read found alone: a recipient that read did not list is still said to be not listed, so the fix leads with the subscription.

Who you sign in as, what you send as, and what routes an address (28 September 2026)

Section titled “Who you sign in as, what you send as, and what routes an address (28 September 2026)”

The question. A founder claimed a Node as admin@whymelabs.com and found replies going out as hello@whymelabs.com, and asked how to change it. Nothing was wrong with the sending: ADR 36 sends as the mailbox, and hello@ was the address the install chose for it. What was wrong was that nothing on screen said so. The composer rendered no From line for a mailbox with one address, because a select with one option is furniture, so Blueprint §4B.3’s “sender identity remains visible” held only for mailboxes with several. The composer now always shows From: the address and the mailbox’s name, a select at several (still starting from none chosen, #94), a line saying a mailbox with no address sends from nothing, a line saying so when the mailboxes could not be read, and a hint that more addresses are added on People. The claim screen says, under its email field, that the email is what you sign in with and that mail goes out from a mailbox’s address, which setup chooses, and mailda install’s terminal claim, where most claims happen, prints the same sentence.

What the answer uncovered. Adding admin@ on People, the obvious next step, would have said routed by the domain’s catch-all, and it was not: admin@ had a literal Email Routing rule to another Worker, and a literal rule outranks the catch-all. addAddress decided catch_all from this Node’s own audit record of taking the catch-all over, which cannot see somebody else’s rule. Two more defects sat beside it: a rule on the address was kept as “already routed here” whatever it did, so a forward read as this Node’s; and when the rules could not be read, the rule was written blind, which is Cloudflare’s 2014 Duplicated Zone rule or a second rule.

The decision is now one pure function, classifyAddress in apps/node/worker/src/provider/routing-classify.ts, over the zone’s rules as Cloudflare lists them, used by adding, removing and the receiving onboard. Its own rule counts as routing here only when enabled with a Worker action naming this Worker; an enabled rule anywhere else is routed_elsewhere, named with where it goes and the two commands that would change it (the listing prints the digest a take-over must confirm); a disabled one is rule_disabled, because Cloudflare does not say whether the catch-all then applies and the Node claims neither; the catch-all, the listed row with no literal matcher, covers only the apex; and nothing is written when the rules cannot be read, which on a domain this Node took the catch-all of is unconfirmed, never catch_all. Removing deletes only a rule that delivers here and that this Node wrote: a rule it took over is the customer’s, so it is left and the answer names the put-back, where the removal used to delete it and with it the only record of where it went.

The receiving onboard answers with the same routing, and records it after the act as provider.receiving_routed, beside the intent entry rather than on it. The intent alone had made every re-run of mailda setup and mailda upgrade, and the Setup progress, read an onboarding as set up whose first address went to another Worker, and told the operator to prove it by mailing that address. Receiving now counts as set up only when the recorded outcome routes here; a record from before this has none and reads as it did, and says so on the CLI. mailda provider --onboard-receiving, which the CLI names for routing another address, asks for a test message only when the address reaches this Node, and whether this Node holds a domain’s catch-all is read from the catch-all’s own take-over and put-back entries, so a later onboard of one address does not read as giving it back.

And the upgrade’s last word. mailda upgrade printed the live Node’s report and then read verified destinations, so every upgrade ended on a report its own read had outdated (observed that day: “2 of 5 … observed”, no snapshot yet, and the read after it). The deploy now takes a step to run after promotion and before its closing report, and the upgrade hands it that read and nothing else: the read writes nothing to Cloudflare and never ends the run, while the setup’s writes still wait for the verdict and are skipped on refuse, as before. A step that throws is printed, and the report and the rollback line still follow before the error is raised again. mailda setup prints no report, so it had no such inversion. The order, the hand-off from deploy to its report, and the writes staying after the refuse exit are held by apps/node/worker/test/node/closing-report.test.ts.

The domain beside the address, and a mailbox beside the invitation (28 September 2026)

Section titled “The domain beside the address, and a mailbox beside the invitation (28 September 2026)”

The Node’s owner asked for two things on People. First, that an address be typed as its local part with the domain fixed beside it, ”[ admin ] @whymelabs.com”, to avoid confusion: a blank field took any address, and one letter wrong in the domain is an address on a domain this Node does not receive for. The domains are the ones the screen’s own reads already show: the provisioned receiving domain (GET /api/provider, an audit-trail read with no Cloudflare call) and the domain of every address on a mailbox listed. One is a fixed suffix, several a picker with the provisioned one first, and none leaves the whole-address field. A value typed with its own @ is sent as typed and the suffix steps aside. The install’s first-address prompt asks the same way, the part before @domain with hello as the blank answer, and MAILDA_ADDRESS may now be a local part too.

Second, that inviting somebody could give them a mailbox. Also give them a mailbox at runs three existing acts in order (the invitation, a mailbox named for the person, the address on it with its routing said in the Node’s words) and grants the invitee nothing, which keeps apps/node/worker/src/invitations.ts’s rule that an invitation carries no authority. The order is chosen for refusals: a refused invitation makes no mailbox, and a refused mailbox or address is said on its own line beside a secret that still works. When the person has signed in and their email is that mailbox’s address, People asks whether to give it to them and names the two relations the button grants, mailbox.content.read and send.propose, the pair a mailbox’s creator receives. No route was added: the prompt is derived from GET /api/people and GET /api/mailboxes, so a mailbox at an address other than the person’s sign-in email is not matched, and is granted in the table like any other. The rules are pure, in apps/node/worker/src/client/app/screens/people-derive.ts, and are held by apps/node/worker/test/client/people-address.test.tsx, apps/node/worker/test/client/people-invite.test.tsx and apps/node/worker/test/node/first-address.test.ts.

What a catch-all does not reach, and the first address from the sign-in (28 September 2026)

Section titled “What a catch-all does not reach, and the first address from the sign-in (28 September 2026)”

The install offered the catch-all on whymelabs.com as “every address at it … addresses live in the Node”. On that zone sales@, contact@ and info@ had rules of their own to another Worker and one more address was forwarded, and a literal rule outranks the catch-all, so “every address” was not true of four of them. The receiving proposal (GET /api/provider/receiving) now carries ownRules: every address on the domain with a literal rule, each classified by the one classifier (classifyAddress, through rulesOfTheirOwn in apps/node/worker/src/provider/routing-classify.ts) and with where the rule sends it, or the error that kept the rules from being read, which is said and never shown as none. mailda install and mailda setup print the list before the choice, whose option now reads “every address without a rule of its own”; mailda provider --onboard-receiving prints it under the apex line and says the same of --catch-all; the Setup screen lists it beneath the box, whose label says the same. None of those rules is changed. The list is in the proposal’s digest, so a rule added between the proposal and its confirmation makes the confirmation stale. Printing that proposal also showed that mailda provider --onboard-receiving had never printed its confirm command on a zone already routing: the command was concatenated onto one branch of a conditional. It is parenthesised.

The first address’s default is now the administrator’s own local part when they sign in with an address on the domain being set up and it has no rule of its own sending it elsewhere; the founder had claimed as admin@ and found replies going out as hello@. The sign-in address is the claim’s in mailda install and MAILDA_EMAIL in mailda setup and mailda upgrade. A rule of its own elsewhere, or disabled, falls back to hello and says where it goes; rules that could not be read fall back and say it could not be checked. The install ends with one line, “You sign in as … Mail goes out as …”; when the Node refused the onboarding it names the address asked for as one that may be on the mailbox (below, 29 September 2026). Held by apps/node/worker/test/receiving.test.ts, apps/node/worker/test/client/setup-screen.test.tsx, apps/node/worker/test/node/catch-all-reach.test.ts and apps/node/worker/test/node/first-address.test.ts.

Controls that stood a caption’s distance apart (28 September 2026)

Section titled “Controls that stood a caption’s distance apart (28 September 2026)”

“The Add the address button is sticking to the text field.” It stood 4px under the field, because .field-row’s gap is a caption’s distance from its field and every control after the field sat at it too. The fix is one shared rule: in a field row, a control after a field takes 4px more (apps/node/worker/src/shell-css.ts). The request was to review the whole application for the same thing, and reading could not do that, so apps/node/worker/scripts/spacing.mjs measures it: every route and opened state at 1440 and 390 px in both themes, every pair of controls sharing a row or a column, anything under 8px. It walks the same views axe does, because the opened states, the wait for a view to load and the page growth moved out of axe.mjs into apps/node/worker/scripts/sweep.mjs, which both import; axe.mjs launches a browser when it loads and could not be imported itself.

Its first run found the field rows, the reader’s actions and Next steps at 6px, a rule’s Save draft and Cancel a text space apart, the Cc / Bcc link under To at the start of its row although its own rule said the end (a field row’s button rule outranked it), the address field’s domain picker 4px under the field on a phone with its @ left behind, and the last message 3px above the Health button on a phone. It also found two overlaps it had made itself, a field below a pane’s fold against the status bar, because a clipped box still reports where it would be; the sweep now grows each view until nothing scrolls first, as axe does. The composite controls (tabs, a menu’s items, the message list’s rows, the search field and its magnifier) are exempt by a list the script keeps and says it keeps. After the fixes, 132 views and no pair under 8px. The shared rows are now 8px: .row-actions with the rules and approvals rows that were the same row under other names, a new .inline-actions for controls in a sentence or a table cell, where a block row would break the line, and the reader’s, Next steps’ and the queue’s. CI holds that those rules are in the served sheet (apps/node/worker/test/node/control-spacing.test.ts), and says it measures no layout.

On 29 September the final screenshots showed the @ inside a focused local-part field’s ring: the sheet draws its focus ring 2px out from a control and 2px wide, and the @ stood 4px from the field and from the domain picker. Text is not a control, so the sweep does not measure it. Both gaps are 8px now, and the spacing test holds them.

What the invite bundle, the install’s last line and the catch-all list claimed past what was read (29 September 2026)

Section titled “What the invite bundle, the install’s last line and the catch-all list claimed past what was read (29 September 2026)”

A review of the three sections above found five sentences saying more than the Node knew, and one pair of controls the spacing sweep could not reach.

The install’s last line said the first address “does not reach this Node” whenever its routing was not here, which included unconfirmed (the rules could not be read) and rule_disabled (Cloudflare does not say where the mail goes). Those two now read “whether mail to it reaches this Node could not be confirmed”. A refused onboarding read “this run set none up”, but the Node registers the address before asking Cloudflare anything, so a refusal after that leaves it on the mailbox; the CLI cannot tell which from the refusal, so the line now names the address asked for as one that may be there, and says People shows the mailbox’s addresses.

The list of addresses with a rule of their own was headed “it will not reach them” in both channels, over rows that could be disabled rules, of which Cloudflare does not say whether the catch-all then applies. The heading now says it of an enabled rule; the web row and a CLI note beneath the list say the rest.

People’s prompt said ” has arrived” of anybody with an account and nothing held directly on the mailbox at their address. Nothing observes an arrival, a relation held through a team is filed under the team, and departure here is revocation, so a person whose access an administrator withdrew was offered it back in one click. The prompt now says what was read (“has an account and holds nothing directly on the mailbox at that address”), and when somebody is to be asked about, People reads the access.revoked entries of the audit trail and leaves out every mailbox a relation of theirs was withdrawn on; a failed read withholds every prompt and says so, and older entries unseen are said beside the prompts. The README and the application shell said the mailbox beside an invitation “is granted to nobody”; its creator may read and send from it, as the creator of any mailbox may, and they now say so.

Doctor’s Verify a batch and Continue from where it stopped sat a text space apart, and the sweep never saw them because the second renders only when a verdict has more to verify. They are in .inline-actions, measured 8px with a stubbed verdict. Four behaviours had no assertion that could fail (the local part’s trim, the invite’s mailbox field disabled until ticked, the address field emptied after an add, and the domain picker’s min-width); each has one now, seen red with the line broken. Held by apps/node/worker/test/node/first-address.test.ts, apps/node/worker/test/node/catch-all-reach.test.ts, apps/node/worker/test/client/setup-screen.test.tsx, apps/node/worker/test/client/people-invite.test.tsx, apps/node/worker/test/client/people-address.test.tsx and apps/node/worker/test/node/control-spacing.test.ts.

合同.pdf arrived as __.pdf (29 September 2026)

Section titled “合同.pdf arrived as __.pdf (29 September 2026)”

An audit made while planning a Chinese interface found three defects that meet a Chinese-speaking customer on their first day whatever language the interface is in. This entry is two of them; search is the third.

safeFilename replaced every character outside [A-Za-z0-9._-] with _. It ran on every outbound attachment at the seal, so a file named 合同.pdf reached its recipient as __.pdf, and the manifest stored __.pdf as the evidence of what was sent; it ran on downloads too, so a received 合同.pdf saved as __.pdf. The seal now keeps the name as the author gave it (NFC), and refuses rather than rewrites one with a control character, a direction control or a path separator, or one past 255 bytes. The header builder renders a plain name exactly as before and any other as an RFC 2047 word beside RFC 2231’s filename*, with continuations when it is long; a recipient’s parser (postal-mime, the one this Node reads inbound mail with) reads 合同.pdf and a 240-byte name back whole. Downloads add RFC 6266’s filename* beside the ASCII fallback, less the sender’s direction controls.

The subject was one encoded word of any length: about sixteen Han characters passed RFC 2047’s 75-character word, and about 245 passed RFC 5322’s 998-octet line, which is a MUST. It is now words of whole characters, 42 bytes each, one per folded line; a short subject renders as it did. Reading one back showed the other half of the bug: the parser decoded adjacent words one at a time and kept the folding space between them, so a long subject from this Node or from Gmail read back with spaces in it, and a character whose bytes a sender split across two words became two replacement characters. Adjacent words are now one run.

Held by the tests under “names and subjects outside ASCII” in apps/node/worker/test/outbound.test.ts, the adjacent-words case in apps/node/worker/test/mime.test.ts and the download case in apps/node/worker/test/quarantine.test.ts. Each changed line was broken by hand and its test seen red; the one that survived at first, the NFC step, has a case of its own now.

The review of it found five more, each fixed with a test seen red. Joining every adjacent word’s bytes put one ISO-2022-JP word’s closing escape against the next one’s opening escape, which the decoder reports as U+FFFD, so every folded Japanese subject gained one at each fold; only UTF-8 words are joined now, and “adjacent words are one run” above holds for them alone. One word that did not decode left its whole run undecoded with the folds removed; now only that word is left as written, with its spaces. A name spelled as the Q-encoded word of a script’s name, with no dot in it, was judged plain (no extension) and sent as written, and a recipient’s client decoded it to the script’s name; a name containing =? is refused. The direction-control list was hand-written and missed U+061C; it is Unicode’s Bidi_Control now, with the line and paragraph separators, one list for the seal and the download. A long name’s RFC 2047 fallback was one word of 352 characters; it is folded like a subject. And the author’s name had replaced safeFilename’s output in send_attachments.filename, which the previous version renders raw if a rollback leaves it dispatching this version’s manifests; the name moved to author_filename (0072_send_attachment_author_name.sql) and filename keeps what the previous renderer can interpolate.

Subjects and sender names stored before this change keep the old decoder’s reading: spaces at each fold, and U+FFFD pairs where a sender split a character. messages.subject and from_name are decoded once, at arrival, and re-deriving them would read every message’s evidence from R2, because which subjects were folded is not visible in the column. Not done; docs/message-search.md says so for search.

发票 did not find 关于发票的问题 (30 September 2026)

Section titled “发票 did not find 关于发票的问题 (30 September 2026)”

The third of the defects above. unicode61 splits on everything that is not a letter or a number, which is a word tokenizer for text written with spaces and a sentence tokenizer for Chinese and Japanese: 关于发票的问题 was one token, so 发票 found nothing, 订单123 was one token, so 123 did not find it, and full-width ABC was not abc, for anyone receiving such mail. searchText (apps/node/worker/src/search.ts) now rewrites every indexed text and every query identically: NFKC inside words, and every Han, kana or Hangul run as overlapping bigrams plus its last character. Measured on a literary corpus (Lu Xun, 《吶喊》), not mail: 3.8 times the posting bytes on Chinese, none on English, 941 rows read for a full page of the commonest character against the 1,000-row budget (receipt).

The first cut of 0071 emptied the subject index and requeued bodies, which was right only if no old-form writer ran afterwards, and mailda deploy applies migrations before the canary, so the previous version keeps indexing until promotion and nothing would have revisited its rows. So 0071 became additive: each message records the form its rows were written in, both backfills select below SEARCH_FORM, and a trigger clears the body stamp on every claim. The same change found that the subject backfill’s and doctor’s NOT EXISTS into the FTS5 table scanned the index once per message, 4,501,465 rows read a minute on a caught-up 3,000-message Node locally, and replaced it with an indexed range. q over 12 typed words is refused (E_SEARCH_TOO_MANY_WORDS) instead of truncated. A body is indexed up to d1.max_row_bytes, and the cut is recorded and reported (body_index_partial). And d1-fts5-search.md’s disclosure wording, which ADR 28 repeated, was corrected: the contentless body index keeps offsets and gives back a body’s tokens in order, and a CJK body as its text. ADR 28 now says so, and keeps detail=full.

The review of the change found five more, each fixed with a test that goes red when its fix is undone. NFKC over the whole text turned separators into letters (Acme™ became AcmeTM, so acme widget stopped finding it); it now folds only inside words. The word limit did not bound index terms, since a pasted Chinese paragraph is one word and a term per character, and a phrase’s time grows with its terms without showing in rows read; it is now refused past search.max_query_terms (128), sized where the worst phrase costs no more than the commonest one-character search. mailda backup carried the form stamps without the indexes, so a restored Node would have held mail marked indexed beside empty indexes, found nothing, and had doctor call both complete; the dump now ends by clearing the stamps. doctor, 0071 and the docs said old-form mail was still “found by whole words”, which is true only of Latin text: the query bigrams a CJK word the old row holds as one token, so until re-formed such mail is found by its CJK text only as a run’s first one or two characters, and the text now says so, rollback included. And 0071’s columns made message-metadata-bytes.md stale while no Cloudflare write was allowed to remeasure it, so its three shard thresholds are withdrawn until it is, as they were for 0068.

The CLI reads wrangler’s JSON, and a take-over offers only what it would do (30 September 2026)

Section titled “The CLI reads wrangler’s JSON, and a take-over offers only what it would do (30 September 2026)”

The CLI reads wrangler’s JSON, not its tables. mailda read the account table and a sign-in sentence out of wrangler whoami, the login token out of wrangler’s config file after running whoami for its refresh, the canary’s id from the first UUID in versions upload’s prose, the serving version from the last (N%) line of deployments list, and the workers.dev URL with a regex. The comments said wrangler offered nothing structured; the locked 4.118.0 does. It now reads whoami --json (signed in, signed out, or unreadable), auth token --json, deployments status --json (the one version holding traffic; a rollout in progress refuses rather than picks), and the WRANGLER_OUTPUT_FILE_PATH entries version-upload, deploy and version-deploy. The earliest wrangler with all of them is 4.65.0, measured from the 4.64.0 and 4.65.0 tarballs (receipt); the 4.97 floor for Workflow schedules still binds first.

The token read asks wrangler, and leaves nothing behind. auth token --json applies wrangler’s own order, a Global API Key before CLOUDFLARE_API_TOKEN, so the CLI no longer reads the token variable itself and provisions with a credential wrangler did not deploy with; a Global API Key is refused by name. wrangler logs every line it prints, the token included, to a debug file kept 30 days: 4.118.0 and 4.90.1 both did, run with a made-up token. WRANGLER_WRITE_LOGS=false stops it from 4.91.0 and WRANGLER_LOG_PATH moves it on older ones, so the read sets both, into a private directory removed when wrangler exits. A failed read says what wrangler said instead of “no login”, and mailda upgrade prints that reason rather than a fixed sentence. Every “wrangler said” the CLI prints (the token read, an unreadable whoami, a failed workflows list page, an unread #99 probe) leaves out npm’s update notice, which npx appends after wrangler exits: an isolated smoke run with a made-up token printed “wrangler said: npm notice” until they did. Every answer the CLI reads is asked for at wrangler’s default log level and without colour: an operator’s WRANGLER_LOG=error left auth token printing nothing, debug put lines ahead of whoami’s JSON, and FORCE_COLOR hid workflows describe’s Script Name: behind escape codes, which refused every upgrade with the wrong cause. A relative WRANGLER_OUTPUT_FILE_PATH or _DIRECTORY is resolved where it was set, not inside apps/node/worker where wrangler runs.

Three first-page reads now page or ask by name. The #99 Workflow guard scanned page 1 of wrangler workflows list and passed silently when the Workflow was on page 2; it asks wrangler workflows describe <name> now, and a success it cannot read refuses. existingNodes() pages workflows list until an empty page and says how far it got on a failure. zonesOf pages on result_info.total_pages (it stopped at 50 zones). The CLI starts wrangler with WRANGLER_SEND_METRICS=false unless the operator set it.

A routing take-over is refused where it would route nothing or lose a destination. A disabled rule, a rule with more than one destination, an address with more than one rule, and a zone with subaddressing on (where user+tag@ would reach the Node and bounce) are refused by name before anything is written. An address row already there keeps its mailbox, which the answer names, in Setup and now in mailda provider --take-over too. Each listed rule says what the Node would do with it, take_over, put_back or the refusal, from the same function the act refuses by, so neither Setup nor the CLI offers an act the Node must refuse; that includes a put-back of a rule this Node wrote but never took over. Take-over and put-back now read the rule back after the PUT and record provider.routing_rule_read_back: a PUT whose answer was lost and whose read-back also failed is failed with “may have applied”, never refused. removeAddress never deletes a rule this Node ever took over: a refused put-back had left the rule routing here with the put-back as the latest entry, and it was deleted.

Layer 0. The Deploy button runs mailda deploy under CLOUDFLARE_API_TOKEN with no login. mailda deploy never reads the token, so the token-read changes do not reach it; the log level, colour and output-path changes do, and an operator who sets WRANGLER_LOG or FORCE_COLOR in the build variables no longer gets a preflight or #99 refusal for it. None of this was exercised on a real button run. Seven places still said the button runs npx wrangler deploy directly, the reason given for hanging the React build off build.command. That was true on 6 August; on 19 August it ran the root deploy script, which has been mailda deploy since 21 August. They now say both, and why the decision stands: a detection that moved once can move again. The Blueprint carries all of this as dated amendments in §10, §11A and ADR 42.

The interface’s languages, layer 1: the mechanism (30 September 2026)

Section titled “The interface’s languages, layer 1: the mechanism (30 September 2026)”

The interface’s words became a catalog, and Chinese is in review rather than on offer. English is the source and its literals are the type, so a translation missing a key, a call with a misspelt key or parameter, or a literal where the interface’s own words go is a compile error. The runtime is Intl and the repository’s own code, with no library, after four were measured and rejected (ADR 46). Simplified Chinese, 淼达, is a preview: reachable by ?locale=zh-Hans, never by negotiation, a stored choice or Settings. An adversarial review of the plan found that a shipped partial locale would put English under <html lang="zh-Hans"> on the flagship screen, per file rather than per route, so the flag replaced exposure rather than patching it.

The scan that counts what is left uses the checker, not the parser. The parser-only version found 764 strings and, measured, missed about a third of the interface’s prose: object values, ?? fallbacks, string props, returns. The default-deny version flags any literal with a letter whose contextual type is not a token type and whose position is not structural, and counted 2,330 across 32 files on the day it landed. That count includes API paths and storage keys, which are registered as not words, with a reason, as each file is migrated. It takes seconds to type the program, so the file has its own measured hook bound (untranslated-scan-timeout.md).

The bundle drift was measured before the work, so the work is not blamed for it: +2.7% raw since 27 September on the untouched tree, then +0.21% raw and −0.06% gzip for the layer, whose migrated screens moved their words into two tables of about 18 KB each, and a 3 KB module before sign-in, which now loads the viewer’s language and still no framework. Every screen now has its own <title>, which closes a WCAG 2.4.2 gap. Layer 0 is untouched: nothing the Node serves changes for an English viewer except those titles and the Language block.

A review of the layer found what its own checks could not. A table’s content-tagged URL answered an old tag with a 404, so a sign-in tab left open across a deploy failed to load the shell; an old tag now redirects, uncached, to the current table. <NodeWords> marked the interface’s own translated fallbacks as English, because a failure carried its words and not who wrote them; now it carries both. The scan exempted a component’s name and id by the attribute’s name, and read everything inside <NodeWords> as the Node’s; it now does neither. Five Chinese words were wrong for the concept (Mine said claimed where a hand-over also holds, Release said sent back), and the glossary gained sentences so a sentence can be bound to a concept one key at a time.

The screenshots found what the catalog check could not see. The palette read 前往Butler: the register’s space between Han and a Latin word was checked in each template, and 前往{route} is correct as a template; the glue happens only when the route’s name is Latin. The runtime now makes that space where a filled value meets a Han template, never inside the value and never in an English template, so English output is unchanged and /app/locale.js grew by 262 bytes. The platform faces were read with the browser’s own font report rather than by eye: every Han sample was drawn by the Simplified Chinese cut of the system face at a real bold or medium weight, none by the Japanese cut, none synthesised. And scripts/axe.mjs and scripts/spacing.mjs now take --locale <tag>, so the preview can be swept by the same hand run as English. On a local Node with seven messages, both locales: axe found no AA violation in 145 views (English) and 146 (Chinese), and spacing no pair under 8px in 132 views each.

The per-message bytes, remeasured after the search forms (30 September 2026)

Section titled “The per-message bytes, remeasured after the search forms (30 September 2026)”

0071’s search forms landed earlier the same day without the remote measurement, and the three shard thresholds were withdrawn. With the account owner’s consent the script ran once against a scratch D1 that it created and deleted, and the account’s database list was the same 71 before and after (receipt). A message now costs 2,255 bytes, up from 2,089, where the withdrawal had estimated about 35 more. The two indexes cost what it said, 29 bytes; the three small columns cost 137, because a messages record of about 990 bytes sat just under the size at which four share a page, and three bytes tipped more pages to three rows. A local SQLite copy of the same corpus split the two causes and matched the remote figures to two bytes. An extra delivery (432) and a filed place (391) did not move. A 10 GB shard holds at most about 4.8 million messages, and the thresholds are back in the budgets: warn at 3,333,123, stop bulky projections at 4,047,363, route new metadata at 4,285,444. The account-wide ceiling now reads at most 487 million.

The owner’s review of the Chinese words (30 September 2026)

Section titled “The owner’s review of the Chinese words (30 September 2026)”

The owner answered every glossary row and every reviewer flag on a review page, one recorded decision each: 90 decisions, 86 confirming and four changing a word. Butler and Butlers are now 管家 (Latin Butler is an avoided form in zh-Hans; the CLI noun butler stays Latin), and Trash is 回收站 (废纸篓 joined NEVER, so no sentence keeps it). 79 of 81 rows carry the owner’s confirmation (CONFIRMED in glossary.ts); the flags adopted changed the rail’s count to 由你处理, Health’s outbound area to “Outbound mail” / 出站邮件, and named the holds org.admin governs. Two rows were held and answered the next day. passkey: the owner asked for 密钥, which already names cryptographic keys here (密钥恢复副本, the key vault) where Apple and Google say 通行密钥; asked once, the owner chose 通行密钥 on 1 October. Move to Trash: confirmed as 移到废纸篓, its phrase became 移到回收站 with the new word, and the owner confirmed that too. Every row is now confirmed. docs/i18n.md has the detail.

Setup and upgrade hand routing rules to this Node, and a take-over survives the Node (1 October 2026)

Section titled “Setup and upgrade hand routing rules to this Node, and a take-over survives the Node (1 October 2026)”

The owner asked (29 September) for the setup and upgrade to show the zone’s existing routing rules, warn about them, and let him choose which to hand over to this Node. A rule for one address outranks the catch-all, so on whymelabs.com eight addresses went to another Worker and two to personal inboxes, and after the first install nothing said so again. mailda install (once the first address exists), mailda setup and every mailda upgrade now end with the list for the name the Node receives at (packages/cli/src/verbs/routing-step.mjs): each address, where it goes, and the Node’s one change for it, “leave it” being the default. One y/N, only when a rule can be offered; --yes or no terminal prints each --take-over command and changes nothing. Rules on other names are a count line. The Setup screen offers the same choice with the same words, because both print the Node’s own (takeOver on the listing), not a table each.

Only the step, not “keep forwarding”. Taking over a forward ends it; the Node calling message.forward() after storing the message is designed and not decided. The owner’s other defaults were adopted as stated in the README: a forward (often someone else’s personal mail) is offered with “leave it” first and, if taken, files only into a mailbox chosen for it, a new one named after the address offered first, and the API refuses it without one (E_ROUTING_FORWARD_NEEDS_MAILBOX); a rule to another Worker says that Worker stops receiving and this Node cannot see what it did.

The review’s H1: a take-over used to be recorded only on the Node. A Node deleted after a take-over took the only record of where the address went, and left a rule naming a Worker that was gone. The take-over now also writes the replaced action into the rule’s name, mailda <worker> (was <action>[ <destination>], repointed <date>), following the name the live admin@ rule was given by hand on 28 September; a put-back restores the rule’s own name, and mailda provider --put-back <id> --domain <d> --without-node restores the action from the name with the operator’s login, writing no audit entry because the Node that keeps them is gone. The README says to put rules back before deleting a Node. The listing also reads the zone’s subaddressing setting now, so the step never offers a rule the act refuses, and a rule with an action other than forward, worker or drop is refused.

The review of the step (1 October 2026). A take-over now reports nameRecorded, whether the rule read back with the name that records where it went, and the step prints the --without-node line only after one that did. A rule already carrying another Mailda Worker’s record keeps it on a second take-over, and a Node whose audit entry is gone puts back from the name. An outcome the Node could not confirm is printed as possibly applied, never “not taken over”; --yes names --mailbox wherever the Node would refuse without it; a mailbox already named after an address is offered instead of making a second; and the README says the catch-all, whose take-over writes no name, is put back through the Node before the Node is deleted.

The interface’s languages, layer 2a: the reader, the composer, the Queue and the ledgers (1 October 2026)

Section titled “The interface’s languages, layer 2a: the reader, the composer, the Queue and the ledgers (1 October 2026)”

The screens where mail is read, written and accounted for now speak from the catalog. The reader, the composer and Drafts, the Queue, and the Outbox, Audit, Log and Doctor moved 669 of the untranslated scan’s findings into six new areas, 400 keys in each language. Each migration was held to its English by goldens taken from the file before its first edit, so the English that changed did so on purpose: the Outbox’s empty state no longer says “Nothing has been sent” (D5; nothing is sent at the submission scale, ADR 39), handed_over’s note no longer says “Accepted by the mail service”, a delivery word (D8), and the reader’s thread row says “handed over” rather than the raw token. The Queue decided “just now” by comparing a formatted duration with the string “under a minute”; it now decides on the milliseconds, so the decision survives a translation.

The sending vocabulary moved out of /app/delivery.js without moving its rules. The module still decides which state, reason and summary a reader is shown, including the one case where outcome_unknown is provably “never submitted”, and now returns the contract’s tokens. The contract declares the tokens as as const lists while the wire stays a string, so an older client still parses a newer Node, and the catalog’s keys are typed by those lists: a token without words no longer compiles, where before a test had to evaluate the served module to notice.

A message keeps its own script. Chinese mail in an English interface was drawn in whatever forms the platform picked for Han, which can be Japanese ones. The body route now says which script the message’s charset or Content-Language names, the frame’s root carries it as data-script (never lang), and /app/frame.css puts that script’s faces first; the sanitiser keeps a sender’s lang and dir. On a local Node, a GB2312 plain-text message and a UTF-8 HTML one saying Content-Language: zh were drawn in Simplified forms under both interfaces, by the browser’s own font report rather than by eye.

A reply’s quote line is its author’s words, so it is in its author’s language and carries its offset from UTC, which a correspondent in another zone needs to read the hour. Re: and Fwd: stay English on the wire and are not added after a Chinese client’s 回复:, 答复: or 转发:.

The tables grew as the work predicted, and the projection for the rest did not hold. Both catalogs are now about 49 KB raw (13.5 KB and 15.8 KB gzip), the shell’s gzip fell by 1.2 KB as the words left it, and nothing new loads before sign-in. At this layer’s rate the whole interface’s English table would be near 100 KB raw, not the 60 KB projected on 30 September (receipt). Three glossary rows were added and wait for the owner: denied (否决), recall (撤回) and vault (密钥库).

The screenshots and sweeps found what the goldens could not. A Chinese column header in a narrow column stacked one character per line (Han may break anywhere; headers now keep a Han run whole); the composer’s “Replying to:” said 回复:, which is the Chinese reply prefix itself, so its head read as a doubled subject (now 正在回复:); the Outbox’s Retry trailed a separator when no .eml followed. axe found the Log’s table scrolling sideways at 390 px with nothing in it a keyboard could reach, the first time the swept Node had a log entry, so it is now in a named Scroller. And the sweeps’ locators for the reader, composer and Queue controls still said English, which the migration made untrue under --locale zh-Hans; they now read the catalog, as the first six did. On a local Node with nine messages and eight seeded sends, both locales: axe found no AA violation in 146 views each, and spacing no pair under 8px in 132 views each.

The owner’s second review answered all of it the same day, and changed three things. The three new rows were confirmed as proposed, with that round’s own record (ROUND_TWO in glossary.ts), so every one of the 84 rows is confirmed and only UNMIGRATED and the pseudo-locale render stand between zh-Hans and release. Eight of the ten flags were confirmed as they ship. F5 moved a measure word: chrome.truncated put 条 before every noun, so the Drafts cap read 2 条草稿; each noun passed to it now carries its own (份草稿, 条通知, 条记录, 条发件记录, 封…来信) and the template none. F9 fixed the English a migration had held still: “1 sends”, “1 message(s) moved”, “within 1 minutes” and the Doctor’s “(s)” counts are plurals, a sentence with three counts being three plurals filled into it rather than one plural over three numbers (D10 to D12), and the held, awaiting and withheld notes say “Not handed over” (尚未移交), ADR 39’s word, where they said “Not sent” (D13).

A gate’s reason no longer outlives the gate (1 October 2026)

Section titled “A gate’s reason no longer outlives the gate (1 October 2026)”

A send admitted after a rate breaker read “handed over · too much, too fast”. A rate breaker seals a send to awaiting with a breaker_* reason and the sentence “it goes when the window clears”, and the sweep admits it once the window has. The claim that admits it (held, throttled or rate-gated awaiting, to outcome_unknown) wrote the state and the attempt and nothing else, so the reason and the sentence went on describing every outcome after it: handed_over, throttled, refused, suppressed, outcome_unknown. The Outbox, GET /api/sends, the SDK and a Butler’s replay all read the stale token. cancelSend did the same, so a cancelled policy_hold still said anybody could release it. Found by reading during the Chinese layer’s verification, then confirmed in the Workers tests by a real sweep admitting a send a real breaker trip had gated, rather than by a seeded row.

The same line hid a second defect. recordUnexplainedDispatch writes why an outcome is unknown only where last_error is NULL, so that it never overwrites a recorded answer. The claim kept a throttled send’s “provider 429”, so when the next dispatch threw, the cause was never recorded and no send.outcome_unknown entry was appended; the throttle’s words explained an outcome they had nothing to do with.

The claim and the cancel now clear state_reason and last_error. The claim is the one place for it, because every outcome after it is written through it. A reason now exists exactly on awaiting and withheld, which is what 0019_policy.sql said it was. 0073_state_reason_invariant.sql clears the reason on every other row, and last_error on cancelled rows, since cancel never wrote it. It leaves last_error on the other terminal states, where a stale word and a true one cannot be told apart. It is not a complete repair on its own. mailda deploy applies it in the expand step, and the previous version (the cron backstop, the OutboxSweeper Durable Object, and cancelSend behind the API) keeps serving until promotion, so it can write a stale pair after 0073 has run. If the canary check fails, that window has no end until a later deploy promotes. 0073 never runs twice. The plan was for the next release to ship the same two statements again as a migration; on 3 October 2026 the cron took that job instead (#319, “The reason repair runs every minute” below). The gate’s history stays where it was: the send.rate_limited entry, the seal entry, the approvals and policy_outcome. A database trigger that would refuse a stale pair, and a state guard in the Outbox’s chip, were both considered and not built: the trigger would be a fourth copy of the list of states with a reason, and the guard would hide the token in one channel while the API, SDK and Butler kept reading it.

The interface’s languages, layer 2b: every other React screen (1 October 2026)

Section titled “The interface’s languages, layer 2b: every other React screen (1 October 2026)”

The owner’s third review confirmed all of it, and thirteen of the twenty flags moved words or code. Layer 2b moved every other React screen into the catalog (Setup, People, Agents, Approvals, Matters, Limits, Butlers, Rules and the first-run screen, 542 strings), adding twenty glossary rows its screens needed; docs/i18n.md has its account. Its twenty glossary rows (team, mint, pause, lift, token and the rest) were confirmed as proposed with that round’s own record (ROUND_THREE in glossary.ts), and 送达 in NEVER, so no glossary row is proposed any more and only UNMIGRATED and the pseudo-locale render stand between zh-Hans and release. Three English sentences changed (D31 to D33): Setup’s delivery outcomes say “accepted (Cloudflare’s delivered)”, because no Node sees a message reach a person; a deny rule “is denied by this rule”, not “refused”, the provider’s word; and three raw ISO stamps became the viewer’s date and time. Limits had said “No domain is paused.” when it could not read the pauses at all, and now says the read failed. The Node’s tokens inside Setup’s sentences (where a rule sends mail, a status, a record) and the relation and capability tokens on People and Agents are marked as English. Two sentences side by side are joined by a catalog word, a space in English and nothing in Chinese, and a Chinese table cell is at least four characters wide, so a phone no longer stacks 已启用 one character per line. The Chinese counts people deciding as 人批准 rather than requests, calls Cloudflare’s queue Cloudflare 队列, and says a live agent is 生效中 as a live Butler is. A matter’s kind, an export’s state, a Butler version’s state and a breaker’s name are now lists in the contract that the Node itself uses, so their Chinese is keyed rather than shown as the Node’s English. One flag is left for a change of its own: the API’s prose still says stop and restart for a domain pause, byte-stable for the agents that read it (G6). The flags are in docs/i18n.md.

Five small things the layer 2b review left (2 October 2026)

Section titled “Five small things the layer 2b review left (2 October 2026)”

The Butler editor’s dry-run buttons stood 4px apart. They are list items, and their 4px margins collapsed into one 4px gap. The list is now a column with the 8px gap every row of controls has, held by apps/node/worker/test/node/control-spacing.test.ts.

A send in the reader’s conversation showed its recipients as stored, → ["a@b.test"]: envelope_to is a JSON array on the wire, and the Outbox parsed it while the thread printed it. Both now go through format.ts’s recipients, a list as the viewer’s language joins one (a and b, a和b).

The Outbox’s When column showed a clock only, so yesterday’s send read 17:09:00. The Audit and Log ledgers’ At column had the same cell. All three now show format.ts’s stamp: the clock for a row from today, the date and the clock for any other day, in the viewer’s locale.

The Outbox’s Retry and Resend buttons had an empty title. It read retry.why, which apps/node/worker/src/outbound/retry.ts sends only when no mode is offered: an offered mode carries its proof (retry-effect) or duplicatePossible (resend-may-duplicate), and the button shows only then. The contract described the mode: null arm alone, as .strict(), so a listing with a retryable send did not satisfy it, and no test had listed one. retry is now the union the Node sends (RetryOffer in packages/contract/src/schemas.ts), apps/node/worker/test/contract-responses.test.ts lists a refused send, and the title is gone: the state beside the button already says what the proof would.

The API says a domain is paused and a pause is lifted (G6). The screens moved to pause and lift in layer 2b (D3); the Node’s own English kept stop and restart, so an agent read “Mail from acme.example has been stopped … Any one administrator can restart the domain” beside a screen that said paused. The refusals, the withheld send’s sentence, doctor’s domain_paused finding, the audit vocabulary’s domain.pause_placed and the /api/domain-pauses summaries (and so the SDK’s comments, the Agent Skill’s table and MCP’s tool descriptions) now say pause and lift, as does the Skill’s reason for withholding the two acts from agents (packages/contract/src/agent.ts), and the audit vocabulary says a pause is lifted, not released, which is a held send’s verb. For an agent: every error code and field name is byte-identical (E_DOMAIN_ALREADY_PAUSED, domain_paused, pauseId and the rest), so matching on codes is unaffected; text matched on the old words (“stopped since”, “restart the domain”, “Stop sending to a domain”) no longer appears in new answers. A send withheld before this change keeps the sentence it was given in last_error, which is a record, not rewritten. Two of the summaries were wrong beyond the verb: they said sending to a domain, where a pause holds mail from one, and that a lift “takes more than one person”, where one administrator lifts alone.

The interface’s languages, layer 3: the pages before sign-in, and the pseudo-locale (2 October 2026)

Section titled “The interface’s languages, layer 3: the pages before sign-in, and the pseudo-locale (2 October 2026)”

Every screen is in the catalog now, the ones before sign-in included, and a pseudo-locale proves it. Layer 3 moved the framework-free scripts (the claim and its recovery codes, sign-in with a password or a passkey, the invitation, the session’s timer, the theme) into the catalog, so UNMIGRATED is empty. With no React before sign-in, the Node’s English is marked by a DOM twin of <NodeWords>. A refusal before sign-in used to be the Node’s four-part English alone, a code first; it now has a headline in the viewer’s language, keyed by a closed list in the contract (PREAUTH_ERRORS), with the Node’s sentence beside it, marked (D34). A reader on an English browser can switch language from the status strip, which reloads with ?locale= and needs no storage, and the claim and sign-in pages draw the 淼达 lockup. /app/locale.js grew from 3 KB to 16 KB raw (6.5 KB gzip) to carry it, still with no framework (ADR 30, ADR 46). The Doctor’s checks, a capability’s description and a body’s problem are keyed by their codes.

The pseudo-locale (T4) renders every route, empty, full and failed, and presses every button against a Node that refuses everything, then the pages before sign-in with each of their refusals. Any Latin letter outside the Node’s marked English, <code> or the fixtures’ data fails it. Its first run found fifteen places English was still bare: raw tokens (an audit action, a log event, SPF results, yaml), raw ISO stamps and an approval’s subject token in the notices band, a unit, a recipient’s error unmarked, a token inside a title. Identifiers are now in <code>, words a reader needs are keyed from contract lists (AUDIT_OUTCOMES, APPROVAL_SUBJECT_KINDS), and its fixtures were moved to the Node’s real shapes, which several were not (a scope of read, an audit outcome of allowed). It also found three defects that were not about language, each fixed in its own change with a test that failed first: Audit’s Verify read a refusal as “Chain broken at entry undefined”, Doctor’s Acknowledge threw on an empty field and stayed busy, and the first-run gate re-read the provider about once a second for a member, whose read is refused. Twenty-five new glossary rows are proposed for the owner, and zh-Hans stays a preview until that review.

The screenshots found one more, older than the layer: the claim’s recovery codes were taken off the screen at once. The claim adopts the session, adopting says “signed-in”, and the page’s listener hands a signed-in page to the shell, which replaced the ten codes (#134: shown once, never again) before anybody could copy them. The tests never saw it, because their session stub’s adopt is silent. The claim now holds the codes before it adopts, and only “I have saved these” hands the page over.

Round four was accepted, not reviewed. In the working session on 2 October 2026 the owner said “just accept them”, so every proposed row and every question was taken as proposed, with no review page, and the glossary’s record for the round says exactly that rather than linking a review (OWNER_ROUND_FOUR). No row is proposed now. Two questions changed the screen: the claim’s field says “Claim secret”, as the CLI and the glossary do (the Node’s own two messages still say bootstrap, a later change), and a Butler run’s state is keyed, stopped as 已终止 rather than 已停止 because 停止 is the pause row’s avoided word. Five questions older than or beside this layer (a supervised headline, the lockout’s plural, a text-only body’s problem, a failed read shown as “not an administrator”, CLI commands in <code>) are accepted follow-ups, each its own change, in docs/i18n.md. Ending the preview is not one of them yet.

Simplified Chinese is on offer (2 October 2026)

Section titled “Simplified Chinese is on offer (2 October 2026)”

zh-Hans left preview. The owner asked for the interface’s languages to be completed and merged “once things are ready”, and every glossary row was confirmed in rounds one to four. The four conditions ADR 46 sets were run before the flag changed, not read off a status line: UNMIGRATED is empty (T3 green), the pseudo-locale render of every route and every page before sign-in finds no bare English (T4, 101 tests), the Node’s own words are marked wherever they show (T4 and the goldens), and none of the glossary’s 130 rows is proposed (T2 with zh-Hans released). A browser asking for zh, zh-CN, zh-SG or zh-MY now gets 简体中文; zh-TW, zh-HK and zh-MO are Traditional and continue down the viewer’s list. A stored choice is honoured, Settings > Language lists both, and the switch before sign-in shows for an English reader too, which is the one change to the English pages’ goldens. ?locale= works as it did. The preview mechanism stays for the next locale, tested in a registry that has a preview in it, since against the real one its every line is now unreachable (docs/i18n.md, The preview flag).

The search index keeps word order, by decision (2 October 2026)

Section titled “The search index keeps word order, by decision (2 October 2026)”

ADR 28’s 30 September correction left one question open: message_body_search is detail=full, so a copy of the D1 database gives up each indexed body’s words in order, and a Chinese, Japanese or Korean body’s text. The alternatives store less but end phrase queries, which CJK search is built on. The owner chose on 2 October 2026 to keep detail=full. ADR 28, docs/receipts/d1-fts5-search.md and docs/message-search.md now say the trade is decided, not open. No code changed.

Taking over a forward rule no longer has to end the forward. A Cloudflare rule holds one action, so pointing me@ at the Node stopped me@’s mail reaching the inbox it used to forward to. The question the 1 October amendment left open, whether the Node’s own Worker can carry the forward, was answered by a drill on mailda.site on 2 October (docs/receipts/email-worker-forward.md): message.forward() works after the message has been read and stored, up to 24.4 MB, and the copy is the original (DKIM still passes). The one finding that shaped the design is the uncomfortable one: a Worker that catches a failed forward() and returns leaves the sender with no bounce and the destination with nothing. So the Node never bounces after storing, and instead records every call: a row per receipt, written with the receipt and settled after the call, shown on People and counted by doctor. A forward rule now offers three choices; keep is refused when the account lists the destination unverified; the destinations can be listed and registered from the CLI and Setup; a put-back clears the forward; an address keeping one cannot be removed. Not built: forwarding to a destination the rule did not have, and sending a copy instead when a forward is refused, which is next.

Copies when a kept forward cannot be used (3 October 2026, ADR 47 amended)

Section titled “Copies when a kept forward cannot be used (3 October 2026, ADR 47 amended)”

A forward to a destination nobody verified now has somewhere to go. message.forward() only reaches a verified Email Routing destination, so a kept forward whose destination was removed, or never verified, delivered nothing and told nobody. The owner approved the fallback that day, with one condition: “make sure to state properly what is happening”. An address may opt in to a copy: when the forward is refused as not verified, the stored message is filed, judged, and sealed as an ordinary send from the address, as a mailing list sends it, “Alice via <mailbox>” in the From display name, Alice in Reply-To, her body byte for byte. ADR 36 gains its one exception (a copy carries the external sender’s name, never staff’s, and nothing an author composes can set either header). No person releases each copy, so the administrator’s opt-in is the authority, read again at every copy and at dispatch, and the trail names the Node as the actor and that administrator as the one accountable. The copy is refused, with the reason on the attempt row, over 5 MiB, for a dangerous or unread attachment, a quarantined message, a DMARC failure, a non-UTF-8 8-bit body, or a destination on the organisation’s own domains; it counts against the daily limit and the breakers like any send, and a second seal of one delivery fails on a unique receipt. Unmeasured: how the copy’s headers fare on Email Sending’s raw path (whether Reply-To and the X- headers arrive, how a client shows “via”), which the next drill should look at.

A resend now carries what the first send carried (3 October 2026). resendMayDuplicate rebuilt its composition from the envelope and the typed text alone, so resending a person’s forward went out without the original, and resending a send with files went out without them: “the same content under a new key” was a different message. It now names the same original (forward_of_message_id) and re-attaches each file from its evidence, a dangerous part only when the first seal let it through on its author’s word. A copy is still never resent (E_RESEND_NOT_FOR_A_COPY).

mailda.site in Simplified Chinese (3 October 2026)

Section titled “mailda.site in Simplified Chinese (3 October 2026)”

The site has a Chinese landing page and Chinese menus over the docs, with the owner’s approval of 3 October. /zh-cn/ is the landing page in the glossary’s words, under 淼达 with a secondary Mailda as the app’s sign-in lockup draws it, its words and the English page’s from one table per locale (apps/site/src/landing-words.ts). The English page did not change in that move: dist/index.html differed from the build before it only in 27 whitespace runs HTML collapses and 3 apostrophes now escaped as &#39;, and a golden of its markup holds it from here. The README’s status rows stay English on the Chinese page, marked lang="en" under a heading that says they are the repository’s own words. /zh-cn/docs/* is Starlight’s fallback: Chinese menus and search over the English page, under the notice that it is not yet in your language. A first visit to / from a browser asking for Simplified Chinese goes to /zh-cn/, decided as the Node negotiates, unless the visitor chose English. The check is tested by running the script itself under node:vm.

Two things the build had been getting right by accident. The landing page found the README through import.meta.dirname, which in a build is the bundled chunk’s directory, not the page’s. It worked only because dist/pages/ sits four levels below the repository; the Chinese page, one directory deeper, read /home/README.md. Both pages now import the README as ?raw, which Vite resolves from the source. And Starlight finds its built-in words by stripping a region from lang with -[a-zA-Z]{2}, which turns zh-Hans into zhns. Under zh-Hans the docs had English menus until the build handed Starlight its own zh-CN translations under that tag.

The reason repair runs every minute (3 October 2026, #319)

Section titled “The reason repair runs every minute (3 October 2026, #319)”

0073 left a gap, and the plan to close it depended on release order. 0073_state_reason_invariant.sql cleared state_reason on sends neither awaiting nor withheld, and last_error on cancelled ones, once, in the expand step. The version before it keeps serving through the canary, and for good if the canary check fails, so it can write a stale pair after 0073 ran. The plan was to ship the same statements again as a migration in the next release. That only works if every Node upgrades through that particular release, and a Node that skips it, or whose canary for it fails, keeps the rows. Principle 5 calls that a stopgap.

Now the version that is serving repairs the rows itself. repairStaleReasons in src/outbound/dispatch.ts runs the same two statements from the scheduled handler every minute, at most 500 sends per statement, and logs outbound.reasons_repaired with the ids whenever it changes something. Once a version containing it is promoted, every stale pair any earlier version wrote is gone within a minute, whatever order the Node upgraded in. Nothing else has to ship. 0076_state_reason_repair_index.sql adds two partial indexes that hold only the rows the repair looks for. The fixed writers never produce such a row, so once the repair has caught up both indexes are empty and a pass costs two D1 statements and a constant 6 rows read. That was measured in test/breakers.test.ts with 500 valid sends present: 6 rows read, against 1,006 with the indexes dropped, on the local D1 the tests run against. No receipt governs the cron’s per-pass cost (cron-lateness is about timing, and doctor-check-cost covers doctor), so the figure is recorded here and asserted in the test, which checks that it does not grow with the table. Once the repair has caught up, any further outbound.reasons_repaired line means an older version is still serving, or a current writer has regressed.

Two docs are in Simplified Chinese, and a translation cannot fall behind its English silently (3 October 2026, with the owner’s approval). docs/cloudflare-settings.md and the runbook part of docs/disaster-recovery.md are translated in docs/zh-cn/, in the glossary’s words, and mailda.site renders each at its English page’s path under /zh-cn/ in place of Starlight’s fallback. Each records the SHA-256 of the English it covers, and apps/site/test/translations.test.mjs fails when that English changes, naming both files and saying to update the translation or remove it. For the runbook the hash covers only the translated part, from the top to the heading where the drill reports begin, so a new drill report does not fail it. The README’s opening part was translated as well and dropped before merging: hashed whole, it would have gone red at the README’s rate (71 commits in the month before), taxing every unrelated pull request, and the /zh-cn/ landing page already carries its pitch and status in Chinese. The same test holds each translation to its English’s code blocks byte for byte, its links, inline code, tables and headings, to the glossary’s NEVER list and to the register, and every one of those assertions was seen to fail on a mutation before it was kept. docs/onboarding-journey.md, proposed with them, was left in English: it is an analysis, and its operator steps are the README’s. Turbo’s cache for the site’s tests now keys on the README, docs/ and the glossary, so an English edit alone re-runs the hash check locally rather than only in CI.

Translating the README found two things wrong in its English. “One step neither path can do for you” said the delivery-events subscription is made only from the Setup screen with the Node’s own token, while the install paragraph above it said the install subscribes; the install is right (provisionNode in packages/cli/src/verbs/provision.mjs posts /api/provider/subscription with wrangler’s login, and attaches the queue’s consumer when nothing consumes it). The paragraph now says the installer does both, and that a button-deployed Node, or one whose setup step was declined, gets them from pnpm mailda setup or the Setup screen. And "Alice via <mailbox>" was raw HTML to a Markdown renderer, so the site drew “Alice via”; it is escaped, here and in the README.

The round-four follow-ups (3 October 2026)

Section titled “The round-four follow-ups (3 October 2026)”

The owner accepted layer 3’s questions in round four with seven follow-ups, each its own change, and on 3 October said “go close them all” (docs/i18n.md, Layer 3’s questions). Each is a commit of its own.

The Node says claim secret (D35). The claim’s field and the CLI said claim secret since round four, and the Node’s two refusals that name it still said “bootstrap secret”. They say claim secret now, as does the English headline above each, which is the Node’s sentence word for word. For an agent: the codes bad_secret and not_installed and the response’s fields are unchanged; text matched on “bootstrap secret” no longer appears.

A command in a sentence before sign-in is in <code> (H14). The claim’s secret hint, the recovery codes’ next step and two claim refusals showed their commands between literal backticks. The page now draws a backticked span of a catalog sentence as <code>, in every locale, with the catalogs’ text unchanged; backticks there already meant an identifier the glossary does not translate. The Node’s own English beside a headline stays as it was sent.

The lockout says “1 minute” and “5 minutes” (H8, D36). The Node’s locked_out sentence read “Try again in 5 minute(s).”. It now has a real plural. The page’s headline above it carries no count and did not change. For an agent: the code locked_out, the 429 and the retry-after header (the exact seconds) are unchanged; text matched on “minute(s)” no longer appears.

The notices band says “a supervised read (content)” (H6). The headline put the Node’s scope token where the noun goes: “was granted a supervised content of Support”. It now names the read and gives the scope beside it, and a grant whose scope was not recorded has its own sentence, where English said “a supervised read” by filling the token’s place with a word and Chinese said (范围:查阅).

A body’s problem is said when the plain text is shown instead, and an empty body says so (H12). A text-only body with a problem (an HTML part that sanitised to nothing or could not be rendered) showed the plain text with no word that the HTML had been set aside, though the Node sent the sentence. A message with neither HTML nor text showed an empty panel, the blank §5C and ADR 37 rule out. The first now shows the Node’s sentence for that case, in the viewer’s language, above the text. The second says “This message has no body.”.

“Not an administrator” only when the Node said so (H13). People, Rules, Butlers and Matters read any failed read as “No …, or you do not hold org.admin.”, so a Node that could not reach its catalog told an administrator they were not one. The Node answers a non-administrator 404 on People, Rules, Butlers and the holds list, and those screens now say it on a 404 only and show any other failure as failed, in the Node’s words, as Limits has since G5. The matters list is never refused to a member (it lists their own), so its sentence is gone and every failure there is failed.

The API says a Butler is paused and resumed (G6’s Butler half). The screens say a Butler is paused and an administrator resumes it (D26); the Node said a machine “stopped” it and an administrator “restarted” it, in the two /api/butler-pauses summaries (so the SDK’s comments), the audit vocabulary, the resume’s refusals and doctor. They say pause and resume now, and a resume no longer “releases” a pause, a held send’s verb. For an agent: every code and field name is byte-identical (E_BUTLER_PAUSE_REASON_REQUIRED, E_NO_BUTLER_PAUSE, E_BUTLER_PAUSE_ALREADY_RESUMED, E_BUTLER_PAUSED, butler_paused, butler.paused, butler.resumed, pauseId), so matching on codes is unaffected; text matched on “Restart a stopped Butler”, “Butlers a machine has stopped”, “This Node stopped a Butler” or “restarted a paused Butler” no longer appears. The audit vocabulary’s says is the code’s description of an action and no entry stores it; a stored butler_pauses.detail, and the said an audit entry recorded, are records and are not rewritten (both already said paused).

The Node’s English counts in words now (3 October 2026)

Section titled “The Node’s English counts in words now (3 October 2026)”

For agents that match the Node’s English: about 120 sentences changed, and no code, field or event name did. The Worker, the CLI and the Butler compiler wrote counts as message(s), address(es) or person(s), often followed by a verb that agreed with neither (“1 message(s) have”, “1 person(s) hold”). Each now reads in whole words, singular at exactly one and plural otherwise, zero included (“0 messages”). The verb agrees too, and so does the noun in a few irregular cases: person/people, address/addresses, batch/batches, query/queries. One helper does it, plural(n, one, other) in packages/runtime/src/plural.ts, with both forms written out because a suffix rule gets “person” and every verb wrong. The CLI imports it too, so @mailda/cli now depends on @mailda/runtime. Every stable code (E_…), finding check, log event and detail field is byte-for-byte what it was; only the prose around the numbers moved. Agents that match the old phrasing should match the code or the field instead. The interface’s own catalog (apps/node/worker/src/i18n/en/) had no (s) left; its counts were already CLDR plurals (docs/i18n.md, D11 to D25). D36 there, locked_out’s “Try again in 5 minute(s).”, was already fixed by H8’s follow-up. Left as they were on purpose: development scripts that print to whoever runs them (apps/node/worker/scripts/, packages/receipts/src/generate.ts), and recorded output quoted in comments, receipts and drill write-ups. Those quote what was printed at the time.

Matters no longer claims that nobody has opened one. For anyone who is not an administrator, GET /api/matters lists only the matters they opened. It answers with that filtered list, never a refusal. So a member with none saw “No matters have been opened.” while the organization could hold several. The empty state now says “No matters you can see. An administrator sees every matter; anybody else sees the ones they opened.” (没有你能查看的事项。管理员能查看所有事项,其他人只能查看自己开立的事项。). The screen does not know whether the viewer is an administrator, so it states the rule rather than a guess. test/route-authority-parity.test.ts now holds the behaviour the sentence relies on, over HTTP. A member gets 200 and an empty list for a matter somebody else opened, and 200 and that matter for one they opened. An administrator sees both.

The changed sentences, by file. N is a count, and {one|other} is the form at one and at any other count:

apps/node/worker/src/approval-plan.ts
- needs N distinct approver(s) holding X, and N remain after
+ needs N distinct {approver|approvers} holding X, and N {remains|remain} after
apps/node/worker/src/authz-read.ts
- N row preview(s) on a page
+ N row {preview|previews} on a page
apps/node/worker/src/butler/pause.ts
- re-triggered N time(s) by
+ re-triggered N {time|times} by
apps/node/worker/src/butler/simulate.ts
- this Butler has N problem(s):
+ this Butler has N {problem|problems}:
apps/node/worker/src/butlers.ts
- this Butler has N problem(s):
+ this Butler has N {problem|problems}:
apps/node/worker/src/doctor.ts
- N subrequest(s): N D1 query/queries, N R2 read(s).
+ N {subrequest|subrequests}: N D1 {query|queries}, N R2 {read|reads}.
- <reason> N finding(s) that would describe this organization's mail are withheld.
+ <reason> N {finding that would describe this organization's mail is|findings that would describe this organization's mail are} withheld.
apps/node/worker/src/doctor/butlers.ts
- N published Butler(s), none paused.
+ N published {Butler|Butlers}, none paused.
- N published Butler(s) have not run
+ N published {Butler has|Butlers have} not run
- N inbound message(s) carry an
+ N inbound {message carries|messages carry} an
- read, and Butlers have proposed N send(s).
+ read, and Butlers have proposed N {send|sends}.
- Butlers have proposed N send(s). The loop
+ Butlers have proposed N {send|sends}. The loop
apps/node/worker/src/doctor/delivery.ts
- N of M handed-over recipient(s) have an observed outcome, from N attributed event(s).
+ N of M handed-over {recipient|recipients} {has|have} an observed outcome, from N attributed {event|events}. (the noun agrees with M, the verb with N)
- N delivery event(s) reached
+ N delivery {event|events} reached
- N recipient(s) that a read
+ N {recipient|recipients} that a read
- N delivery event(s) this Node could not tie to a send were for
+ N delivery {event|events} this Node could not tie to a send {was|were} for
- N recipient(s) handed over more than N minutes ago were not shown
+ N {recipient|recipients} handed over more than N minutes ago {was|were} not shown
- N recipient(s) were handed over
+ N {recipient was|recipients were} handed over
- N recipient(s) were handed over
+ N {recipient was|recipients were} handed over
- N older one(s) are also unattributed and are not counted here:
+ N {older one is also unattributed and is|older ones are also unattributed and are} not counted here:
- N delivery event(s) were never matched
+ N delivery {event was|events were} never matched
- N delivery event(s) in the last N minute(s) could not be matched
+ N delivery {event|events} in the last N {minute|minutes} could not be matched
- N observation(s)
+ N {observation|observations}
- N address(es) keep a forward
+ N {address keeps|addresses keep} a forward
- N kept forward attempt(s) have no recorded answer
+ N kept forward {attempt has|attempts have} no recorded answer
- N address(es) whose latest
+ N {address|addresses} whose latest
apps/node/worker/src/doctor/evidence.ts
- N event(s) were tried and not published
+ N {event was|events were} tried and not published
- N unpublished event(s) older than
+ N unpublished {event|events} older than
- of N receipt(s) and
+ of N {receipt|receipts} and
- N object(s) examined under
+ N {object|objects} examined under
- N receipt(s) reference an evidence object
+ N {receipt references|receipts reference} an evidence object
- N object(s) have no receipt and are past the grace period
+ N {object has no receipt and is|objects have no receipt and are} past the grace period
- N object(s) examined under
+ N {object|objects} examined under
- N draft body object(s) have no drafts row
+ N draft body {object has|objects have} no drafts row
- N send(s) were withheld
+ N {send was|sends were} withheld
- N message(s) are waiting for the search index
+ N {message is|messages are} waiting for the search index
- N message(s) are waiting for the body index
+ N {message is|messages are} waiting for the body index
- N message(s) the body index
+ N {message|messages} the body index
- N message(s) have a body
+ N {message has|messages have} a body
- N message(s) have no row preview
+ N {message has|messages have} no row preview
apps/node/worker/src/doctor/governance.ts
- N day(s) ago
+ N {day|days} ago
- N legal hold(s) in force.
+ N legal {hold|holds} in force.
- N lift request(s) waiting
+ N lift {request|requests} waiting
- N hold(s) name a mailbox that no longer exists, so they enforce nothing
+ N {hold names|holds name} a mailbox that no longer exists, so {it enforces|they enforce} nothing
- N hold(s) in force cannot
+ N {hold|holds} in force cannot
- <hold> on mailbox <m>, where N person(s) hold approval.decide
+ <hold> on mailbox <m>, where N {person holds|people hold} approval.decide
- N notification(s) fell due and have not been delivered
+ N {notification fell due and has|notifications fell due and have} not been delivered
- N notification(s) have no due date and cite a matter that has already closed, so they can never fall due
+ N {notification has no due date and cites|notifications have no due date and cite} a matter that has already closed, so {it|they} can never fall due
- N supervised grant(s) taking effect
+ N supervised {grant|grants} taking effect
- N notification(s) for them.
+ N {notification|notifications} for them.
- N row(s) were removed outside the product.
+ N {row was|rows were} removed outside the product.
- N live agent(s) hold capabilities
+ N {live agent holds|live agents hold} capabilities
apps/node/worker/src/doctor/keys.ts
- N receipt(s) reference evidence
+ N {receipt references|receipts reference} evidence
apps/node/worker/src/doctor/node.ts
- Missing N table(s):
+ Missing N {table|tables}:
- N address(es) configured.
+ N {address|addresses} configured.
- N message(s) have been accepted
+ N {message has|messages have} been accepted
- Of N message(s) in the last
+ Of N {message|messages} in the last
apps/node/worker/src/doctor/recovery.ts
- N restore(s) collided
+ N {restore|restores} collided
- N restore(s) collided
+ N {restore|restores} collided
- installing N generation(s)
+ installing N {generation|generations}
- installing N generation(s) with no collisions.
+ installing N {generation|generations} with no collisions.
- **N generation(s) collided with a live key and were not installed**
+ **N {generation|generations} collided with a live key and {was|were} not installed**
apps/node/worker/src/exports.ts
- N message(s) were staged
+ N {message was|messages were} staged
apps/node/worker/src/index.ts
- N case(s) passed
+ N {case|cases} passed
- for N message(s) not yet
+ for N {message|messages} not yet
- sender of N message(s) that
+ sender of N {message|messages} that
- for N message(s) (never
+ for N {message|messages} (never
- of N message(s).
+ of N {message|messages}.
- N message(s) could not be projected
+ N {message|messages} could not be projected
- N notification(s) delivered.
+ N {notification|notifications} delivered.
- on N send(s) neither
+ on N {send|sends} neither
- on N cancelled send(s).
+ on N cancelled {send|sends}.
apps/node/worker/src/kept-forward.ts
- carries N attachment(s) this Node
+ carries N {attachment|attachments} this Node
apps/node/worker/src/outbound/manifest.ts
- carries N attachment(s) this Node
+ carries N {attachment|attachments} this Node
apps/node/worker/src/outbound/recheck.ts
- has N standing approval(s) and
+ has N standing {approval|approvals} and
- N no longer hold(s) approval.decide on N, or is the author of
+ N {no longer holds|no longer hold} approval.decide on <mailbox>, or {is|are} the author of
- The rule(s) that apply now: <rules>
+ {The rule that applies|The rules that apply} now: <rules>
apps/node/worker/src/policy.ts
- given N approval stage(s)
+ given N {approval stage|approval stages}
- to N more person(s), or
+ to N more {person|people}, or
- N more member(s) of team N have to hold
+ N more {member|members} of team N {has|have} to hold
apps/node/worker/src/reconcile.ts
- N collectable object(s) not collected
+ N {collectable object|collectable objects} not collected
- N body object(s) with no drafts row
+ N body {object|objects} with no drafts row
- N staged object(s) with no exports row
+ N staged {object|objects} with no exports row
- N staged object(s) with no send_manifests row
+ N staged {object|objects} with no send_manifests row
- scanned N object(s), N of M receipt(s)
+ scanned N {object|objects}, N of M {receipt|receipts}
- N receipt(s) reference absent evidence.
+ N {receipt references|receipts reference} absent evidence.
apps/node/worker/src/recovery.ts
- N generation(s) were installed before it stopped.
+ N {generation was|generations were} installed before it stopped.
- N escrowed key generation(s) could not be installed
+ N escrowed key {generation|generations} could not be installed
- ; N was installed
+ ; N {was|were} installed
apps/node/worker/src/routes/node.ts
- Applied N migration(s).
+ Applied N {migration|migrations}.
- Confirmed. N code(s) marked
+ Confirmed. N {code|codes} marked
apps/node/worker/src/routes/session.ts
- Too many failed sign-in attempts. Try again in N minute(s).
+ Too many failed sign-in attempts. Try again in N {minute|minutes}.
- Signed out of N session(s).
+ Signed out of N {session|sessions}.
packages/butler-ast/src/cost.ts
- N node(s) outside a loop cost N
+ N {node|nodes} outside a loop cost N
packages/cli/src/backup.mjs
- holds N object(s), and
+ holds N {object|objects}, and
- N object(s) in this backup are named by no live row.
+ N {object in this backup is|objects in this backup are} named by no live row.
- N fault(s) were already present
+ N {fault was|faults were} already present
packages/cli/src/deploy-parse.mjs
- the canary has N finding(s) the version
+ the canary has N {finding|findings} the version
packages/cli/src/support.mjs
- N thing(s) must be settled
+ N {thing|things} must be settled
packages/cli/src/verbs/backup.mjs
- N table(s) to export
+ N {table|tables} to export
- indexed. N object(s) had been listed.
+ indexed. N {object|objects} had been listed.
- N object(s)
+ N {object|objects}
- N object(s) listed
+ N {object|objects} listed
- N checked, N fault(s)
+ N checked, N {fault|faults}
- inventory N object(s), N
+ inventory N {object|objects}, N
- N problem(s) with this backup.
+ N {problem|problems} with this backup.
packages/cli/src/verbs/deploy.mjs
- compared N finding(s)
+ compared N {finding|findings}
packages/cli/src/verbs/provision.mjs
- N of N address(es) this Node
+ N of N {address|addresses} this Node
- N zone(s) were read before it.
+ N {zone was|zones were} read before it.
packages/cli/src/verbs/routing-step.mjs
- N rule(s) on N are not shown:
+ N {rule|rules} on N {is|are} not shown:
packages/cli/src/verbs/search.mjs
- == N message(s) the body
+ == N {message|messages} the body
- N attempt(s)
+ N {attempt|attempts}
- N message(s) re-queued.
+ N {message|messages} re-queued.
packages/cli/src/verbs/secrets.mjs
- N key generation(s) installed
+ N key {generation|generations} installed
- N escrowed key generation(s) could NOT
+ N escrowed key {generation|generations} could NOT
packages/cli/src/verbs/upgrade.mjs
- N release commit(s) behind
+ N release {commit|commits} behind
- N local commit(s) ahead
+ N local {commit|commits} ahead
packages/cli/src/verbs/verify-evidence.mjs
- covered N message(s) before this
+ covered N {message|messages} before this
- N fault(s)
+ N {fault|faults}
- N message(s) checked in N batch(es)
+ N {message|messages} checked in N {batch|batches}
- N fault(s) across N object(s) checked:
+ N {fault|faults} across N {object|objects} checked:

A mistyped password asks again instead of ending the upgrade (7 October 2026)

Section titled “A mistyped password asks again instead of ending the upgrade (7 October 2026)”

mailda upgrade asked for an administrator’s email and password once, then went on to the backup, where sessionCookie stops at the first 401. That is right for credentials from the environment, which will be wrong on every try, and wrong for a person at a terminal, whose likeliest 401 is a typo: the run ended with “could not sign in” and had to start over. Credentials typed at a prompt in mailda upgrade and mailda setup are now tried at once (packages/cli/src/credentials.mjs). A refusal says so and asks again, offering the email already typed, up to three times, which stays inside the Node’s lockout of auth.max_failed_logins_per_15min. A locked address stops at once with the Node’s own reason, and running out of tries names mailda set-password. A Node that cannot be reached keeps the credentials for the step that retries it. Credentials from MAILDA_EMAIL and MAILDA_PASSWORD are not prompted for and behave as before. Held by apps/node/worker/test/node/credential-prompt.test.ts.

An address forwards to several destinations, and a Worker rule can become forwards (7 October 2026)

Section titled “An address forwards to several destinations, and a Worker rule can become forwards (7 October 2026)”

The setup step offered a rule that sent an address to another Worker one change, receive here, which ended whatever that Worker did. On whymelabs.com that Worker’s whole code was a loop calling message.forward() for two inboxes, for eight addresses, so taking them over meant those two people stopped getting their mail. And a kept forward held one destination, on the address row.

Where an address forwards is now a list (forward_destinations, migration 0077), and every forward of every message is its own row (forward_attempts, per receipt and destination). email() calls forward() for every destination together and settles each on its own, so one refusal leaves the others handed over, at most forward.max_destinations per address (docs/receipts/forward-fan-out.md). Any address can be given a list on People (Change forwards) or with mailda provider --set-forwards <address> --to a,b; a Worker rule’s take-over has a third choice, receive here and forward to addresses you choose (forwardTo, --forward-to), which the listing offers with the verified destinations that Worker’s code names, read once per Worker with wrangler’s login and confirmed by the person, never applied by itself. A destination must be verified unless copies are on, in which case one copy per message goes to every destination refused as not verified, and none may be on a domain this organisation receives at. Doctor’s Kept forwards is now Forwards and counts destinations. ADR 47 is amended in the Blueprint.

The routing step can go back, and change one answer before applying (7 October 2026)

Section titled “The routing step can go back, and change one answer before applying (7 October 2026)”

On a zone with many rules, one wrong answer in mailda upgrade’s routing step meant answering N to the whole plan, or applying it and putting the wrong address back by hand. Each address’s choice now ends with ← back to the previous address, each mailbox choice with ← back to the choices for that address, and the plan numbers every offered address, the ones left as they are too: a number at Apply? asks that one again and shows the plan again. Nothing changes until the plan is applied, as before. Held by apps/node/worker/test/node/routing-step.test.ts.

People reads as a grid, and a read in flight shows it is moving (7 October 2026)

Section titled “People reads as a grid, and a read in flight shows it is moving (7 October 2026)”

People listed six checkbox lines with their whole descriptions for every person in every mailbox, with the mailbox’s id under each, and every address that forwarded carried the full copy statement; the owner found it hard to read. Each mailbox is now a card (its addresses, each with its forwards one per line and the copy statement folded, then a person-by-permission grid with one box per cell), the permissions are explained once per grid, Invite somebody comes first and offers the Node’s addresses nobody signs in with yet, and an invitation names its sender by address. Reading… now has a pulsing dot beside it (still under reduced motion) and is announced to a screen reader.

A design audit of every screen, and People on one compact page (8 October 2026)

Section titled “A design audit of every screen, and People on one compact page (8 October 2026)”

The owner found People still hard to read with real data: eleven addresses in one mailbox, eight forwarding to the same two inboxes, each repeating its destination lines, its copy setting and the whole copy statement, with the access grid two thousand pixels down. An audit of all eighteen screens at desktop and phone width (with data shaped like the owner’s) found the same habit elsewhere. Doctor said 1,745 words, its one warning below the fold under paragraphs explaining checks that passed, and on a phone its detail column stood one word to a line. The Queue put its settings above its work. Setup left an eight-row permission table open in an optional section. The Audit trail showed people as usr_01M…. People is now a folding card per mailbox with an address table and the copy statement once; Doctor leads with what needs attention and folds passing detail; record tables stack on a phone; the Queue’s and Setup’s settings fold; the Audit names people. Visible words fell to 410 on People (from 620), 876 on Doctor, 343 on Setup and 143 on the Queue. The design-review harness could not start its own Chromium here (the OS blocks its sandbox), so the audit ran on the repository’s Playwright.

Install, upgrade and setup say which step they are on, and how many are left (8 October 2026)

Section titled “Install, upgrade and setup say which step they are on, and how many are left (8 October 2026)”

mailda upgrade printed some forty == headers of equal weight (its own, the backup’s, the deploy’s), so an operator could not tell how far along a run was. Each of mailda install, mailda upgrade and mailda setup now lists its steps first, numbered, and prints a banner as each begins, ━━ Step 3 of 7 · Back up the Node ━━━ (packages/cli/src/progress.mjs). The deploy’s and the backup’s own == lines stay, as the parts of the step they sit under; the headers a banner now says (== backing up first, == what this does to the catalog, == setting up) are gone. Every banner a command asks for is checked against its declared steps by apps/node/worker/test/node/cli-progress.test.ts, which reads the commands with the TypeScript parser.

An upgrade asks for a hostname of your own (8 October 2026)

Section titled “An upgrade asks for a hostname of your own (8 October 2026)”

mailda install asked whether the Node should answer at a hostname of the operator’s own (a custom domain on the Worker, mail.example.com), but an upgrade never did, so a Node installed on its workers.dev address could only move with --hostname, which nothing mentioned. mailda upgrade now asks the same question in its Choose the Node step, while the Node is still remembered by its workers.dev address (asksHostname in packages/cli/src/upgrade-parse.mjs); once it has a name of its own the question stops, and later deploys keep the domain, since they upload versions and leave the Worker’s triggers alone. Choosing one says what changes: sign in again at the new address (a session and a passkey belong to the address they were made on), and the workers.dev address keeps working.

An upgrade backs up only when a migration is pending (8 October 2026)

Section titled “An upgrade backs up only when a migration is pending (8 October 2026)”

mailda upgrade took a backup before every deploy, before it had even read whether any migration was pending, so a code-only release cost a minute and the backup’s sign-in for a copy no migration needed. The pending migrations are now read first; with none, the deploy changes no table and the backup step says it was skipped (--backup takes one anyway). With any, the backup is taken after the operator agrees and before the schema is touched, and an upgrade that cannot take one still does not proceed. The administrator sign-in stays: the canary gate reads the whole doctor report with it on every upgrade.

A mailbox in the sidebar opens its own queue (8 October 2026)

Section titled “A mailbox in the sidebar opens its own queue (8 October 2026)”

The sidebar’s Workspace rows, one per mailbox, all linked to the Queue without saying which mailbox, so the Queue opened on the first one whichever was clicked, and the others looked like they did nothing. Each row now carries its mailbox in the address (/queue?mailbox=<id>), the Queue opens on the mailbox the address names (the first when it names none, or one this person cannot see), and the Queue’s own picker changes the address too, so Back returns to the mailbox before.

An upgrade asks Cloudflare whether the Node already has a hostname (8 October 2026)

Section titled “An upgrade asks Cloudflare whether the Node already has a hostname (8 October 2026)”

The upgrade offered a hostname whenever the URL this clone remembered was a workers.dev one, and that file is written only at the end of a run. On whymelabs.com a run attached mail.whymelabs.com and stopped before writing it, so every later upgrade offered the hostname again. It now reads the Worker’s custom domains from Cloudflare first (attachedHostnames in packages/cli/src/verbs/provision.mjs), says which are attached, and asks only when there are none; when Cloudflare cannot be read it falls back to the remembered URL, as before.

A custom domain no longer turns the workers.dev address off (8 October 2026)

Section titled “A custom domain no longer turns the workers.dev address off (8 October 2026)”

The config --hostname derives carried the custom-domain route and no workers_dev key, and wrangler treats a config with routes and no workers_dev as turning workers.dev off. So attaching mail.whymelabs.com by upgrade switched off the address the clone remembered, and the next upgrade’s canary check there answered 1042 and stopped (safely: the new version sat at 0%, the old one kept serving at the custom domain). The derived config now says "workers_dev": true beside its route, and an upgrade that finds a custom domain on the Worker checks the canary there and carries the domain into the config, so each deploy restates the Worker’s triggers and turns workers.dev back on.