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.
Vocabulary
Section titled “Vocabulary”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
- 1024
that 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.
Principles
Section titled “Principles”1. Boil the ocean
Section titled “1. Boil the ocean”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.
2. Every number needs a receipt
Section titled “2. Every number needs a receipt”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:
| Kind | Source of truth | Where it may live |
|---|---|---|
| Platform limit (25 MiB inbound, 50 recipients, D1 database ceiling) | The provider, detected at runtime | Adapter 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 runs | One named constant with a receipt ID beside it |
| Objective (p95 latency, RPO/RTO, freshness) | A verified observation, recomputed continuously | Computed 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.
Receipt format
Section titled “Receipt format”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-effectskind: measured-tripwire # platform-limit | measured-tripwire | slomeasured_on: 2026-08-03stale_when: a certified pack ships a legitimate fan-out above 200values: 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 claimed | why it passed anyway |
|---|---|
| MCP forwards a page cursor | an empty page reads the same whether or not it forwarded |
| the escrow is not openable from the table | it tried the wrong attack: the route, not the ciphertext |
| the parser keeps its balance at any depth | balanced nesting never reaches the capped counter |
| the deploy gate refuses a bad canary | if (false && verdict !== "ok") satisfied both lexical clauses |
| the deploy steps run in order | a renamed banner still matched as a substring |
| the empty inbox hides no reassurance | also true of a loading screen |
| the vault does not overwrite a live key | nothing asserted it at all |
| the SDK is regenerated from the contract | a 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:
- A type.
Record<RouteKey, Handler>,Record<AppRoute, Screen>: a missing entry is a compile error. - A registry.
ROUTES,BUDGETS,APP_ROUTES: the test reads the list, not the code that consumes it. - 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. - 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 acmeFour 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_idand plain-language reason. - A capability gap names the adapter or permission required, never just greys a control.
outcome_unknownsays “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.
4. Fight for the obvious solution
Section titled “4. Fight for the obvious solution”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.
6. Grow the system in layers
Section titled “6. Grow the system in layers”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:
| Layer | The Node can… | Proven by |
|---|---|---|
| 0 | Be deployed to a clean Cloudflare account and pass doctor | one-click and CLI reach equivalent healthy Nodes |
| 1 | Receive one real internet message, store it losslessly, show it to one authorized human | a real message from outside, visible in the web UI, original .eml exportable |
| 2 | Reply: sender authorization, policy, send intent, provider attempt, honest per-recipient state | accepted / bounced / outcome_unknown distinguished, never blurred. See the note below on which scale each word lives at |
| 3 | Share work: mailboxes, membership, assignment, collision, cases | two people work one queue without colliding |
| 4 | Automate: Butlers compiled, simulated against fixtures, published as immutable versions | replay causes zero provider calls |
| 5 | Govern: approvals bound to exact revisions, supervised access, audit, retention | editing an approval-bound field invalidates the approval |
| 6 | Extend: provider connectors, mail core, LLM profiles, external adapters | each 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.
Working rules
Section titled “Working rules”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.
Before you call it done
Section titled “Before you call it done”- 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.
- Every reachable limit produces an error naming budget, limit, ask, and the next permitted action. A limit is changed only through its governing contract.
- No
catchswallows. Each one re-raises, records, or surfaces an operational state. - Names don’t overclaim, and match across code, CLI, API and UI.
- The layer below still works.
- 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.
- You can answer “why is this here?” for every line, in one sentence, without reading it again.
- 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.
Agent skills
Section titled “Agent skills”Issue tracker
Section titled “Issue tracker”Issues and PRDs live as GitHub issues in Straits-AI/mailda, driven by the gh CLI.
See docs/agents/issue-tracker.md.
Triage labels
Section titled “Triage labels”The five canonical roles, used verbatim as label strings. See docs/agents/triage-labels.md.
Decisions
Section titled “Decisions”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).