Skip to content

AGENTS.md: how we work on Mailda

This is the working agreement for everyone touching this repository: humans and agents, equally bound. Mailda-Full-Engineering-Blueprint.md says what we are building. This file says how we decide, measure, name and ship it.

Read both before writing code. If the two ever conflict, the blueprint wins on product contract; this file wins on method.


We use these five words precisely. They are not decoration. They are the review language.

landmine: a decision that costs nothing now and blows up later. By the time it detonates it is load-bearing. An unmeasured limit. A silent catch. A hardcoded `25 * 1024

  • 1024that was true on the day it was typed. A field namedsynced` that only forwards.

receipt: the measurement behind a number. No receipt, no number. A receipt says what was measured, on what, when, and what makes it stale.

tripwire: a limit placed past where any good widget goes, so only broken things touch it. A good Butler, a good mailbox, a good message never feels it exists. If a good one touches it, the tripwire is wrong, not the widget.

simple: how cleanly the logic breaks down. Each step follows from the last, no step doing two jobs.

obvious: the next reader never asks “why is this here?”. Measured by the reader, not by the author. Not always simple; sometimes obvious has more parts.

When we argue, we argue in these terms. “This is a landmine” is a specific, answerable claim. “I don’t like it” is not.


Do not be afraid to propose seemingly insane solutions. We are rethinking what organizational mail infrastructure is: a mail system the customer owns outright, running in their own Cloudflare account, that keeps working after every Mailda service is disconnected. Nothing about that is an incremental improvement to an inbox.

Three things we refuse to trade against each other:

  • Ownership is absolute. No mandatory account, licence server, telemetry endpoint or hosted control plane. If a feature only works when Mailda Control is reachable, it is not a feature, it is a hostage.
  • Automation authoring must feel like the web. Butlers are the developer surface. Typed nodes, a real type checker, a real compiler, fixtures and simulation, round-tripped between a visual graph and text against one AST. A web developer or a coding agent should be productive in an hour, and the errors should be good enough that the agent can fix its own mistake without a human.
  • Efficiency is a feature, not a tax on the above. Workers CPU-ms, D1 rows and bytes, R2 objects, DO wake-ups and queue messages are the customer’s bill. We drive them down hard, and we do not buy a millisecond with a worse developer experience or a dishonest semantic.

If a proposal sacrifices any one of the three to get the other two, it is not the answer yet. Say so and keep looking.

Before choosing or changing a production capacity or performance tripwire, measure the relevant healthy workload and preserve the receipt. Reuse an existing receipt when its workload and assumptions still apply. If healthy use reaches that tripwire, remeasure before changing it.

Provider limits, protocol constants, security or policy thresholds, and user-specified budgets come from their governing source. Cite that source and preserve its meaning; healthy traffic reaching a limit does not authorize raising it. Keep existing schema, capability, and approval requirements.

Ordinary fixture values and UI presentation constants do not require production benchmarks. Explain their basis when needed. Label exploratory defaults as provisional assumptions; fixtures and provisional values do not establish production capacity or measured results.

If required measurement is unavailable, leave the affected production decision unqualified and continue work that does not depend on it.

Reserve generously and commit lazily where the system permits it. Keep the receipt, adapter, generated-budget, and observed-objective contracts below.

Operational budgets and reported objectives use these evidence contracts; each requires a receipt:

KindSource of truthWhere it may live
Platform limit (25 MiB inbound, 50 recipients, D1 database ceiling)The provider, detected at runtimeAdapter capability data only. Never a constant in application code.
Measured tripwire (fan-out ceiling, parser recursion depth, queue concurrency)Our own measurement of real corpora and real runsOne named constant with a receipt ID beside it
Objective (p95 latency, RPO/RTO, freshness)A verified observation, recomputed continuouslyComputed and displayed from live evidence, never a literal in the UI

Platform limits are adapter data, not assumptions scattered through application code (Blueprint §11B). They change under us. Cloudflare’s Email Sending entitlement is detected, never assumed. A healthy Node may be receive_only, and the code must say so rather than fail mysteriously.

Objectives that cannot be evidenced are not displayed. Admin and mailda doctor compute the achieved protection window from the last verified backup, bookmark and restore drill. We never print an aspirational RPO (Blueprint §24). That rule generalizes: an unverified number is worse than a blank, because a blank prompts a question and a wrong number ends one.

Every measured tripwire, platform limit and objective gets a file in docs/receipts/. The frontmatter is machine-readable, because the constants are generated from it:

---
id: butler-fanout-max-effects
kind: measured-tripwire # platform-limit | measured-tripwire | slo
measured_on: 2026-08-03
stale_when: a certified pack ships a legitimate fan-out above 200
values:
butler.fanout.max_effects: 500
---
**Measured:** 12,400 published Butler versions across the reference and certified packs;
p99.9 fan-out was 61 effects, maximum observed 143 (bulk-invoice-reconcile v3).
**Sized:** 500, which is 3.5× the worst real workflow. Only a loop bug reaches it.
**Cost if wrong:** a runaway Butler starves inbound receipt for the whole Node.

values is a map because one measurement often establishes several related numbers, and splitting them across files scatters a single receipt.

For generated budget constants, write the receipt. A build step emits packages/budgets from docs/receipts/*.md; that module is generated and never hand-edited. CI regenerates on every commit and fails on any diff. Benchmarks re-run nightly and flag drift against the recorded value. Not per commit, because timing benchmarks in CI are flaky, a flaky check gets muted, and a muted receipt check is worse than no check because it still reads as verified.

At review, a production tripwire, platform limit, or reported objective must have the receipt or adapter capability field required above. A fixed protocol value, policy threshold, or user-defined budget cites its governing source. Fixture and presentation values need a clear basis when it is not obvious; they must not be presented as operational measurements. These distinctions do not permit hand-editing generated budget constants or bypassing governance.

2b. Every assertion needs to be able to fail

Section titled “2b. Every assertion needs to be able to fail”

A test that passes against the code it was written to catch is worse than no test, for the same reason an unverified number is worse than a blank: it ends the question. The suite reports it as coverage and the next reader stops looking.

This is not a hypothetical. Eight of them were found in a single week, all by hand-mutating the fix and re-running the test, none by reading:

what the test claimedwhy it passed anyway
MCP forwards a page cursoran empty page reads the same whether or not it forwarded
the escrow is not openable from the tableit tried the wrong attack: the route, not the ciphertext
the parser keeps its balance at any depthbalanced nesting never reaches the capped counter
the deploy gate refuses a bad canaryif (false && verdict !== "ok") satisfied both lexical clauses
the deploy steps run in ordera renamed banner still matched as a substring
the empty inbox hides no reassurancealso true of a loading screen
the vault does not overwrite a live keynothing asserted it at all
the SDK is regenerated from the contracta top-level side effect regenerated it first

So: after writing an assertion, break the line it covers and watch that assertion fail. Restore it. If it did not fail, the assertion is about something else than you think.

pnpm --filter @mailda/worker mutants <source> <test> automates the loop for one file pair, weakening one line at a time. It reports rather than gates. A surviving mutant is often a legitimately unreachable branch or a deliberate redundancy, and telling those from a real gap is a reader’s judgement. Two rules for reading its output: a mutant that does not compile measured nothing, and a survivor you decide is fine gets a comment saying so, because the next person will run it too.

The related failure worth naming beside this: a module with a top-level side effect cannot be imported by the thing that checks it. The SDK generator’s writeFileSync regenerated the file before the test could read a hand edit. mailda.mjs dispatches on argv, so its parsers had to move to a file of their own. The seam is the pure part in one module and the effect in another.

2c. A closed world is held by a type or a registry, not by a scan of the source

Section titled “2c. A closed world is held by a type or a registry, not by a scan of the source”

A test that reads src/ as text to establish an invariant is coupled to the wording of the code, and it fails when the code is rewritten rather than when the invariant breaks. Fifty such files existed on 16 September 2026. The largest read the router with regular expressions to check that every path it decided on was registered, and became a mapped type (Handlers in src/router.ts) the day the router became a table.

The ladder, top rung first:

  1. A type. Record<RouteKey, Handler>, Record<AppRoute, Screen>: a missing entry is a compile error.
  2. A registry. ROUTES, BUDGETS, APP_ROUTES: the test reads the list, not the code that consumes it.
  3. A parse. When the source itself is the only witness (a gate reached from a handler, a table named in a query), read it with the TypeScript parser (test/node/support/handlers.ts) so a declaration split across lines or mentioned in a comment cannot fool the check.
  4. Never a phrase. A test that asserts a comment contains three sentences guards nothing a reader cannot delete along with the test.

A scan that survives at rung 3 keeps its anti-vacuity control. It must first find the sites, so a parser that stopped matching fails loudly rather than passing over an empty set.

3. A limit developers can hit is a limit they must see

Section titled “3. A limit developers can hit is a limit they must see”

Developers will not read our code. Their agents read our errors. An agent can fix max_recipients=50, asked for 63. It cannot fix a blank window, a spinner, or a message that arrived and vanished.

Every budget failure names the budget, the limit, and the ask. At compile time if it is knowable there (mailda butler compile, mailda deploy --plan, --dry-run), loudly at runtime if it is not. A silent budget is worse than no budget.

The shape:

E_BUDGET_EXCEEDED butler.fanout.max_effects=500, this run asked for 512
butler lead-response@v7 step notify_owners run run_01JQ...
receipt docs/receipts/butler-fanout-max-effects.md
raise mailda policy set butler.fanout.max_effects 750 --org acme

Four required parts: the stable code, the named budget with its number and the ask, the identifiers to find it, and the exact command that changes it if the answer is “raise it”.

This is not only about budgets. The same standard covers every refusal:

  • A policy denial exposes a policy_decision_id and plain-language reason.
  • A capability gap names the adapter or permission required, never just greys a control.
  • outcome_unknown says “the provider may have accepted this effect” and offers only the semantically safe actions. It never silently retries and it never rounds itself to “failed”.

Never swallow. A catch that does not re-raise, record an exception, or produce a visible operational state is a landmine with a timer on it. The most dangerous mail failure is “accepted but absent” (Blueprint §24), and every silent catch is a way to build one.

Measure twice, cut once. Understand the problem fully before building, because cleverness is what gets written when you haven’t. The biggest simplicity win is refusing to solve problems we don’t have. Good code is the most simple thing that delivers full functionality and performance: nothing traded away, nothing bolted on.

Push back when you see a more obvious way. That includes pushing back on this file, on the blueprint, and on whoever asked. State it once, clearly; if the decision stands, build the decided thing properly and note the concern where the next reader will find it.

A specifically Mailda flavour of obvious: names must not overclaim. The word in the code, the word in the CLI, the word in the API and the word in the UI are the same word, and that word is true. A forwarded copy is a copy, never a sync. A provider-native action is observed, never approved. An AI extraction returned a result; it did not understand. A reader who trusts a name and is wrong has been handed a landmine by the person who named it.

5. Architectural decisions are made for the long term

Section titled “5. Architectural decisions are made for the long term”

Do not accept a stopgap that only works for now and is meant to be replaced later. There is no later; there is only the next person who finds it load-bearing.

The decisions in Blueprint §29 are locked. You may reopen one. That is a real, allowed move, but reopening means amending §29 in the same change, with the argument written down. It never means quietly building against it.

If a shortcut is the right call, it is not a shortcut: write down why the constraint that justified it is permanent. If you can’t, it’s a stopgap. Don’t.

Start from the smallest version that works end to end, and add each capability on top of a product that already works. Never trade a working product for unfinished complexity.

Every layer is a Node someone could actually deploy and use:

LayerThe Node can…Proven by
0Be deployed to a clean Cloudflare account and pass doctorone-click and CLI reach equivalent healthy Nodes
1Receive one real internet message, store it losslessly, show it to one authorized humana real message from outside, visible in the web UI, original .eml exportable
2Reply: sender authorization, policy, send intent, provider attempt, honest per-recipient stateaccepted / bounced / outcome_unknown distinguished, never blurred. See the note below on which scale each word lives at
3Share work: mailboxes, membership, assignment, collision, casestwo people work one queue without colliding
4Automate: Butlers compiled, simulated against fixtures, published as immutable versionsreplay causes zero provider calls
5Govern: approvals bound to exact revisions, supervised access, audit, retentionediting an approval-bound field invalidates the approval
6Extend: provider connectors, mail core, LLM profiles, external adapterseach certified independently; none required by the layers below

Layer 2’s three words live at two different scales, and conflating them is the failure the layer exists to prevent. Submission is what this Node did with an envelope: it hands over, or it is throttled, refused, suppressed, cancelled, withheld, or its outcome is unknown. handed_over is the ceiling of what is knowable at that moment, since the transport took the bytes, and it is deliberately not called sent. Delivery is what the receiving world did with one address, and it arrives later, per recipient, on a queue: accepted when the receiving server returned a 250, bounced when it refused, deferred while retries continue, and unobserved when nothing has been reported at all.

So the ladder’s accepted is a delivery word, not a submission one, and outcome_unknown appears at both scales for different reasons. The fourth state, unobserved, is not in the row above and should be. It is the one people collapse into the other three, and the whole point is that “we have heard nothing” is a distinct and honest answer.

The rule that makes the ladder real: every layer stays green. A change that breaks layer 1 to build layer 4 is not progress, it is a regression with a roadmap attached.


The blueprint is the contract. Building something it doesn’t describe, or differently from how it describes it, means editing the blueprint in the same change. Divergence discovered later is treated as a bug in both places.

Contracts before channels. packages/contract (the route registry, its schemas, the relation and authority vocabularies) generates or validates UI, API, CLI, SDK, Skill and MCP behaviour. A capability that exists in one channel and not another is a parity bug, not a feature. Never hand-write what a contract can generate.

Authorization is server-side and live. Never trust a token claim for ACL, legal hold, classification or approval state. Every operation re-evaluates the live relationship.

Determinism by default. The CLI and Butler runtime are deterministic. AI is invoked only at an explicit LLM node or by an external agent through the deterministic interfaces. LLM output is data, never authority.

Evidence is immutable; everything else is a projection. Raw MIME, composition manifests and audit events are permanent. Parsed forms, search indexes and AI outputs are rebuildable derivatives, and must actually be rebuildable, tested.

Docs move with the code. Any architectural change, new feature, or removed feature updates README.md and the relevant technical docs in the same change. A receipt that no longer matches the code is deleted or remeasured, never left to rot.

A path in prose is a citation; a path in a fence is a literal. Comments and paragraphs here carry the evidence, so a path named inline in backticks must resolve. test/node/prose-references-world.test.ts scans every one of them and fails the build on a reference to a file that is not there. When you need to name a path that deliberately does not exist (a wrong reference quoted as evidence, an illustrative tree, a file a consuming repository is told to create), put it in a fenced block, which the scan skips. The measurement behind the rule, and the three detectors rejected as not worth their cost, are in false-claim-detectability. Existence is the part that mechanises; accuracy is not. So a claim about a file the reader can check cheaply, a count or a test name, is worth writing in a form that a check can resolve.

  1. Operational limits and reported objectives have their required evidence; fixed values cite their governing source, and fixture or presentation values do not masquerade as measured results.
  2. Every reachable limit produces an error naming budget, limit, ask, and the next permitted action. A limit is changed only through its governing contract.
  3. No catch swallows. Each one re-raises, records, or surfaces an operational state.
  4. Names don’t overclaim, and match across code, CLI, API and UI.
  5. The layer below still works.
  6. Blueprint, README and technical docs reflect what the code now does, and every path your prose names in backticks resolves to a file that is there.
  7. You can answer “why is this here?” for every line, in one sentence, without reading it again.
  8. Every new assertion has been seen to fail. Break the line it covers, watch it go red, restore it. Or run pnpm --filter @mailda/worker mutants <source> <test> and read the survivors.

Issues and PRDs live as GitHub issues in Straits-AI/mailda, driven by the gh CLI. See docs/agents/issue-tracker.md.

The five canonical roles, used verbatim as label strings. See docs/agents/triage-labels.md.

Architectural decisions are the numbered list in Blueprint §29. There is no separate ADR directory; reopening one amends §29 in the same change (principle 5).