CONTRIBUTING.md
Rama main · réplica de lectura del repositorio original.
# Contributing to MailFathom
Thank you for considering a contribution. This guide gets you from a clone to a passing verification run and states the few rules a pull request has to satisfy.
It is deliberately short. The detailed engineering rules live in [`AGENTS.md`](AGENTS.md), and this guide points at them rather than repeating them, because a second copy of a rule is a second thing to keep true.
By participating you agree to the [Code of Conduct](CODE_OF_CONDUCT.md).
## How this project is built
**MailFathom is developed AI-first, and close to zero-touch.** Nearly every line here was written by an autonomous coding agent working from an issue and the rules in [`AGENTS.md`](AGENTS.md); the maintainer sets direction, reviews, and decides, but rarely edits code by hand. That is the normal path rather than an experiment, and it is why the instruction files read as a prescriptive contract instead of as advice: they are what the agents execute.
**You are encouraged to work the same way.** Point an agent at your checkout — Claude Code, Codex, and anything else that reads `AGENTS.md` pick the rules up on their own — give it the issue, and let it produce the change, the tests, and the documentation in one pass. A hand-written patch is equally welcome and is judged identically; the point is that the conventions here are dense enough that an agent which has actually read them will satisfy them faster than a person skimming them.
**The skills run in your fork too.** `.agents/skills/` holds the ten workflow skills the maintainer's agents use, and Claude Code finds them through the `.claude/skills` symlink in any clone. [`get-started-contributors`](.agents/skills/get-started-contributors/SKILL.md) is the one written for you rather than for them, and the only one you invoke by hand rather than letting an agent reach for it: it welcomes you, walks you through what MailFathom is, how this repository is worked, where things live, and what the licence asks of you, and then takes the clone to its first green run — the platform check, the tools and how each is installed, the remote the gates measure against, the local file that says which role you are in, and the commands your agent has to be allowed to run. **Invoke it again whenever this repository has moved under your clone.** It records what it set up, in a file inside your clone's git directory that no commit can reach, so a second invocation refreshes rather than repeats: it works out what changed since — the SDK pin, the tools, the permission list, the wording of that local file, whether the maintainer has since granted you the board — and touches only that. Deleting the record is how you ask for the walkthrough again. Everything it says is in this guide too, and the setup sections below are its short form. `start-task` works out from your remotes that it is running in a fork and adjusts three things: your branch keeps the name you gave it, the base it verifies against is `upstream/main` rather than `origin/main`, and it opens an issue without trying to label it or place it on a board. `review-change`, `check-docs-licenses`, `add-migration`, and `closed-enumeration` need nothing from this repository and run unchanged. `read-design` runs here too, and it is the one worth knowing about before you touch a screen: what a client screen looks like is settled in [`design/`](design/README.md) — artboards written in this repository, their sample data, logic, and styles in files of their own, and a state inventory naming every state each one has, including the ones no screenshot reaches. So you build a screen from the same source the maintainer does, and `scripts/capture-design.sh` holds your running client against it in your clone as readily as in theirs. `finish-change` runs the same gates and opens the same pull request against `main` here, and reports the one step it does not take — the maintainer's planning board is private, so there is nothing for it to write and nothing missing when it does not.
**What waits for a maintainer.** The protected paths below are open to you and your agent like any other file, and a change to one belongs in the same pull request as the work that needs it. What they add is a wait: the pull request is held until a maintainer approves its current head, so it merges only if they decide they want the change, however good it is. `start-task` names the protected paths a task reaches, so you know before the work which approval the pull request will wait on.
Two things are unchanged by whoever or whatever typed the code:
- **You are responsible for what you submit.** Read the diff before opening the pull request. Output nobody has read is not a contribution, it is a request that someone else read it for you.
- **The gates are identical.** The same full verification run, the same coverage threshold, and above all the same [licensing obligations](#licensing-your-contribution) — a model that reproduced code from somewhere restrictive is yours to catch, and it is the one mistake a follow-up commit cannot fix.
## Before you start
**Every change starts from an issue.** Open one before writing code, or comment on an existing one to say you are working on it. That is not ceremony: it is where scope is agreed, and it is what a pull request closes. For anything larger than a typo, wait for a maintainer's reply before investing time — MailFathom is on its `0.x` line and its direction changes faster than its issue list.
**Everything you write here is in English.** [`AGENTS.md`](AGENTS.md#critical-repository-rules) states that rule once, for every artifact and every directory, and it reaches a contribution exactly as it reaches the maintainer's agents.
**Do not report a security vulnerability as an issue.** [`SECURITY.md`](SECURITY.md) has the private channel.
## What you need
MailFathom is developed and run on Linux.
- The .NET SDK version pinned in [`global.json`](global.json). `latestFeature` roll-forward applies, so a later feature band of that same major and minor version works and a different major or minor version does not.
- Node and pnpm, at the versions [`frontend/package.json`](frontend/package.json) names in `engines` and `packageManager`. The SDK does not bring them, and corepack no longer ships with Node, which is why the pnpm version is stated in that manifest rather than left to a shim. **Check `node --version` against that floor yourself** — nothing sets `engine-strict`, there is no `.nvmrc`, and pnpm installs against an older Node without a word.
- A browser, if you touch a screen: `pnpm --dir frontend exec playwright install chromium`, roughly 300 MB, and `--with-deps` when it installs but refuses to start, which is a missing system library rather than a Playwright fault. `pnpm install` does not bring it, no gate installs it, and three things want it — the browser suite, driving the running client, and the design-parity capture. **ImageMagick** joins it for the capture comparison, which probes for the version 7 `magick` binary and will not accept a version 6 that only provides `convert`.
- Docker, for the PostgreSQL container the local orchestration starts.
- Optionally the Aspire CLI and `dotnet-ef`, which only some workflows need, and — only if you build the desktop or Android head — a Rust toolchain and the platform packages [`docs/operations/local-development.md`](docs/operations/local-development.md) lists for it.
[`docs/operations/local-development.md`](docs/operations/local-development.md) is the full setup: tool versions and install commands, running the app model, development secrets, and the migration workflow. Read it once before your first change.
**`frontend/` is a pnpm workspace of React and TypeScript**, and [`frontend/README.md`](frontend/README.md) is its page. Install both toolchains whichever stack you mean to touch: a change to a file above them — `global.json` is the example — reaches both, so a machine holding one fails a gate on work nobody would have called cross-stack.
## From a clone to a green run
```bash
git clone https://github.com/<you>/MailFathom.git
cd MailFathom
git remote add upstream https://github.com/fathomise/MailFathom.git
git fetch upstream main
dotnet restore backend/MailFathom.slnx
dotnet build backend/MailFathom.slnx --no-restore
dotnet test --solution backend/MailFathom.slnx --no-build
pnpm --dir frontend install --frozen-lockfile
pnpm --dir frontend run typecheck
pnpm --dir frontend run test
```
**The test run takes `--solution` rather than a path.** This repository configures Microsoft Testing Platform, and that runner refuses a positional solution — it prints `Specifying a solution for 'dotnet test' should be via '--solution'` and exits zero while doing it, which is how a first run reads as green having tested nothing.
**Type these rather than reaching for the fast loop**, which is the next section: both gates pick their stack from the paths your branch changed, so on a clean clone they reach neither and say so. That is a true statement about a diff and no statement at all about whether your machine can build MailFathom.
**The `upstream` remote is not optional.** Every gate here verifies your branch against the base it will actually merge into, and in your fork `origin/main` is whatever you last synced. The scripts find that base by looking for the remote that points at `fathomise/MailFathom` — under any name, `upstream` is just the convention — and the full gate refuses to run rather than measure your work against the wrong base. Its refusal prints the two commands above.
## Tell your agent it is working in a fork
`AGENTS.md` is written for two roles — the maintainer's checkout and yours — and it cannot tell which one is reading it. `start-task` works that out from your remotes, but only once it runs, and plenty of sessions never reach it: a question about the code, a one-line fix, a review of a diff you already have. In those, an agent guesses, and the guess costs you a turn every time it goes the maintainer's way — a write to a private board that answers with a permission error, a label on an issue nothing can label, a push this repository refuses.
Say it once instead, in a file your agent loads before you type anything. **Claude Code** reads `CLAUDE.local.md` from the root of your clone straight after `CLAUDE.md`, adding to it rather than replacing it. **Codex** has no per-directory equivalent — it takes at most one file per directory and prefers `AGENTS.override.md`, so a file of that name at the root would *replace* this repository's instructions — and its global `~/.codex/AGENTS.md` is read first, which is where the same lines go. Another agent will have its own; the wording is what matters:
```markdown
I am an external contributor to MailFathom. `origin` is my fork and `upstream` is
`fathomise/MailFathom`, so the fork role in `AGENTS.md` governs every session in this
checkout. My branch keeps the name I gave it, and nothing is ever pushed to
`fathomise/MailFathom`.
The roadmap board, project `1`, is private to the maintainer and I have no access to it.
Do not read it, do not write it, and do not treat that as a step that failed: an issue I
open carries no issue type, no stack or design label, no milestone, and no `Area`, `Queue`, or `Size`
value by design, and the maintainer's triage supplies them. `$start-task` opens the issue and stops
there; `$finish-change` reports the board write as `not applicable (no board write)`.
Workflow runs on my pull request wait for a maintainer to approve them, so a check that has
not started is a queue rather than a failure to chase, and every push waits again.
`Fathom review` runs only when a maintainer asks for it in a comment; my own
pushes never start one.
```
**Ask what your credentials may actually do here before you copy any of it**, because a remote is evidence about that and not the answer. One call says what you may do in this repository:
```bash
gh api repos/fathomise/MailFathom --jq '.permissions'
```
`push` and `triage` both `false` with `pull` alone `true` is the ordinary contributor's answer, and it is what decides the three things the block above is about: the issue type and the stack and design labels, the milestone, and pushing a branch here. Your pull request still crosses from your fork, so nothing about contributing is closed by it. A `null` means the call went out unauthenticated — a public repository answers a read for anybody — so that is `gh` to fix rather than an answer to write down.
The board paragraph is the other one worth checking rather than copying, because the maintainer grants read or write on project `1` whenever they decide to, and a fork is no evidence either way. Ask, once, with the `project` scope on your `gh` credentials:
```bash
gh api graphql -f query='{ organization(login: "fathomise") { projectV2(number: 1) { viewerCanUpdate } } }'
```
No access prints the response body with `"projectV2": null` and a `NOT_FOUND` error, adds its own line on standard error — `gh: Could not resolve to a ProjectV2 with the number 1` — and exits `1`. That is not a wrong number and not a deleted board: GitHub hides a project you cannot see rather than telling you that you cannot see it, so the answer to *no permission* is worded as *does not exist*, and the one line `gh` puts in front of you is the half that says so least clearly.
If that is what you got, check your credentials before believing it: your account's access and your token's access are different things, and only the second is what the call sees. `gh auth status` has to list the `project` scope — `gh auth refresh -s project` adds it — and a `GH_TOKEN` or `GITHUB_TOKEN` in your environment displaces the credential `gh` stored, without `gh auth status` being able to show you its scopes. A fine-grained token issued for your own account never reaches the organization's board, whatever it was configured with; a classic token with `project`, or a plain `gh auth login`, is what does.
A `NOT_FOUND` from a credential that does carry the scope is the paragraph as written above. `viewerCanUpdate: false` makes it *the maintainer has granted me read access to project `1`: read it for context, and report every write as unavailable rather than attempting it*, and `true` makes it *the maintainer has granted me write access to project `1`, so the board steps in `start-task` and `finish-change` apply to me as written*. Nothing else in the block changes: your branch is still yours, your push still goes to your fork, and access to a board is not authority over a repository.
`.gitignore` covers `*.local.md`, so a file written that way cannot reach a commit by accident. Do not put any of this in `AGENTS.md` or `CLAUDE.md` themselves: it would be telling every other contributor's agent something true only of your machine.
## Let your agent run the loop
A verification loop that stops for your consent on every `dotnet` and every `scripts/…` invocation is a conversation rather than a loop, and the usual repair — allowing everything once — gives away the boundary worth keeping. Configure the permissions where your agent keeps them, in Claude Code's `.claude/settings.local.json`, in Codex's `~/.codex/config.toml`, or wherever yours reads. What is portable is the list, not the file:
- **allow** `dotnet`, `node`, `pnpm`, and everything under `scripts/` except `run-integration-tests.sh`, which starts containers and belongs in the last bullet, read-only Git (`status`, `diff`, `log`, `show`, `ls-files`, `rev-parse`, `merge-base`, `branch`, `fetch`, `add`), read-only `gh` (`pr list`, `pr view`, `pr diff`, `issue list`, `issue view`, `auth status`), and ordinary reading (`ls`, `cat`, `head`, `tail`, `wc`, `grep`, `rg`, `find`). Both gates run the client flow themselves, so allowing `scripts/` without `pnpm` stops a gate halfway through rather than at its start;
- **if your agent sandboxes the filesystem**, allow the writes both builds make outside the checkout whether or not anybody asked for them: `~/.nuget`, `~/.local/share/NuGet`, `~/.dotnet`, `~/.templateengine`, `~/.aspnet`, `~/.microsoft`, `~/.aspire`, and your temporary directory on the .NET side, and `~/.local/share/pnpm`, `~/.local/state/pnpm`, `~/.cache/pnpm`, and `~/.npm` on the client's — plus `~/.cache/ms-playwright` if you run the browser suite. Denied, they fail the restore, which reads as a broken repository rather than as a permission;
- **if it sandboxes the network**, allow `nuget.org` and `pkgs.dev.azure.com` for packages, `registry.npmjs.org` for the client's, `dot.net`, `aka.ms`, `*.microsoft.com`, and `dotnetcli.blob.core.windows.net` for the SDK, `github.com` and `*.githubusercontent.com` for `gh`, and `mcr.microsoft.com` with the Docker registries for the PostgreSQL image, plus binding on the loopback for the local app model and for the client's development and preview servers;
- **leave everything in the other direction to you**: pushing, force-pushing, merging, deleting a branch, and running the integration suite are decisions rather than steps.
`.claude/settings.local.json` is gitignored here, and everything else on that list lives outside the clone, so none of it can arrive in a pull request.
## The verification loop
Once your branch has changed something, run the fast loop instead of the commands above:
```bash
bash scripts/verify-fast.sh
```
It restores, builds, runs the unit tests, and then formats the C# files your branch changed — building every stack your change reaches before testing any of them, so a build error stops it before a suite starts. **It rewrites working-tree files by design** — that is how a style diagnostic reaches you in seconds rather than after the full gate has run. When the build and the tests passed and the formatter only repaired files, the run passes and records the formatted files: review what it changed and commit it with your change, with no second run.
It does that in whichever of the two stacks your change reaches, decided from the paths you touched rather than by you: the server's solution for a change under `backend/`, both stacks for a change to a file above them such as `global.json`, and neither for a change no build reads. The lists are the ones `ci.yml` uses, so the gate builds what the pipeline will. The client stack's half restores the pnpm workspace under `frontend/` in locked mode and runs its lint, its type check, and a formatting pass, so a change under `frontend/` needs Node and pnpm on the path exactly as a change under `backend/` needs the SDK.
Do not invoke `dotnet format` by hand. Both of its modes already run where they belong — the loop repairs the files you changed, the full gate verifies them — and a hand-run pass over the whole solution costs minutes to report what the build has already told you: a style rule with no automatic fix fails the Release build above, naming its file and line.
That same loop is the gate before you commit, so stage your files and run it again — unless the run before passed and said `no second run is needed`: then stage the formatter's rewrite and commit without another run, since staging changes what the record describes:
```bash
git add <your files>
bash scripts/verify-fast.sh
```
It fetches `origin main` — or your `upstream`, whichever remote points at MailFathom — and refuses a branch that does not contain that freshly fetched base, listing what arrived. Rebase when one of those commits conflicts with your change or touches something it relies on; when none does, run it again with `VERIFY_IGNORE_BASE_DRIFT=1`, which reports the same drift and verifies the branch on its own merge base. Nothing in the pipeline asks that question, which is why the loop does. In the server stack it runs the unit suites your change can have broken rather than the solution's whole suite, and it prints which ones those were and how many there are, because the rest is the pull request's answer rather than one you have.
The full gate is available and is not a step of every change:
```bash
bash scripts/verify-full.sh
```
Every verdict it produces arrives again on your pull request, from the same coverage target, the same contract suite, and the same client flow — on a runner per job rather than on your machine. What running it buys is that answer earlier, which is worth it when it is the one you are waiting on: a coverage figure, the whole solution's formatting after you touched an `.editorconfig` or a shared build file, a client bundle that has to build. It also rejects remaining untracked files, so a newly added file cannot slip past diff validation.
Neither script proves the same tree twice. Each records a digest of what it verified under `artifacts/verify/`, which is ignored and never staged, and a run handed a tree it has already passed over prints the earlier run and stops in under a second. So run the gate rather than working out whether the last run still counts: a failing run records nothing, a fast loop whose formatting pass only repaired files records the formatted tree as it stands unstaged — stage and commit it rather than running again, since staging changes what the record describes — and `VERIFY_FORCE=1` runs everything regardless.
Both scripts refuse to run on `main` or `master`. Check out your branch rather than working around the refusal.
The integration suite is not part of either script. It starts containers and is run deliberately, through `scripts/run-integration-tests.sh`, when a maintainer asks for it.
Neither script answers the third question a change raises, so there is one more command, and it takes a few seconds whatever your diff holds:
```bash
bash scripts/review-obligations.sh
```
It prints what your change obliges the rest of the repository to do — the tests naming each type you changed, the pages that document each path you touched, and the registers whose trigger you moved — each saying whether your change touched it. That is the part no diff shows, because there the defect is the *absence* of a second file. It gates nothing and asserts nothing: a row is a place to look, and a rename owes no test. The same index runs on your pull request, so what it says here is what the reviewer will see there.
## Making the change
- **Branch off `main`, never commit to it.** In a fork the branch name is yours to choose; with write access, name it `agent/<short-description>`.
- **Behavior changes come with unit tests.** The suite enforces at least 85% aggregate line coverage across every project under `backend/src/` except the `Host` and `AppHost` composition roots, and the gate fails below it. [`backend/tests/AGENTS.md`](backend/tests/AGENTS.md) is the unit-test policy — read it before adding tests, especially the rules on what belongs in the integration suite instead.
- **Update the documentation in the same change.** Stale guidance is a defect. [`docs/AGENTS.md`](docs/AGENTS.md) applies under `docs/`, and a new page there opens with a `describes:` marker naming the part of the repository it is written about — the contract suite fails a page without one, and a marker naming a path that no longer exists.
- **Warnings are errors.** `TreatWarningsAsErrors` is on and the analyzer set is configured in `.editorconfig`; suppress a diagnostic only at the narrowest scope, with the concrete reason stated.
- **Do not edit `CHANGELOG.md`.** It states what a release shipped and is written by the release pull request alone.
- **ADRs under `docs/decisions/` are working records until `1.0.0`.** Every one is `proposed`: read the relevant ones first for any architectural change, and correct, extend, or add one in the same pull request when your change departs from it or needs a new decision. The `Protected paths` check holds that pull request for a maintainer's approval.
**A push carrying something credential-shaped is refused, by GitHub, before the objects reach it.** Push protection is on for this repository. The refusal names the file, the line, and what it thinks it found, and it is not a review comment you can answer — the push simply does not happen.
Fix it in the commit rather than on top of it. A secret that reached a commit is in the history even after a later commit removes it, so amend or rebase the commit that introduced it, and rotate the credential if it was ever real. Every fixture in this repository uses a synthetic value for exactly this reason; if you need a realistic-looking one, invent it. If the block is a false positive — a value that looks like a token and is not — say so in the pull request and ask the maintainer rather than looking for a bypass; bypassing is a repository permission you do not have, and that is deliberate.
## Opening the pull request
- **Put `Closes #<issue>` in the body.** It closes the issue on merge, which is also how the maintainer's own planning view learns the work is done. That view is a private project board you neither see nor need; the issue is where the conversation lives.
- **One change per pull request.** Split out anything the reviewer would have to judge separately.
**Nothing runs until a maintainer approves the run.** A workflow triggered by a pull request from a fork waits for someone with write access to release it, so on your first push the checks sit unstarted rather than red, and every later push waits the same way. That is GitHub's protection against a fork running this repository's workflows unreviewed, not a fault in your branch and not something you can retry your way past. Two consequences worth knowing: a check that has not started tells you nothing about your change, so read it as a queue rather than as a signal; and if you are working with an agent, say so in its instructions, because an agent watching for a verdict will otherwise keep waiting for one that cannot arrive yet. Run the gates locally in the meantime — they are the same ones.
One check does not wait, and seeing it move alone is not a sign that the rest are stuck. `license/cla` runs on `pull_request_target`, which GitHub runs in the context of this repository's base branch rather than of your head — the base branch is trusted, so that trigger runs regardless of the approval setting that holds everything else. Nothing from your branch is checked out, built, or executed by it. It is also the one check you can turn green yourself, by accepting the agreement as [*Licensing your contribution*](#licensing-your-contribution) describes.
Six checks gate the merge. `license/cla` is the one described above and `Typo check` is the paragraph below; the other four always report once the run is approved:
- **`Required CI`** — the build, the unit tests, the coverage threshold, and formatting.
- **`Protected paths`** — holds a pull request whose author is not a maintainer — a member of the `maintainers` team, or the organization's owner — until a maintainer approves its current head, when it changes `.github/`, `.agents/`, `.claude/`, `.config/`, or `docs/decisions/`, an `.editorconfig`, `.gitattributes`, `.worktreeinclude`, `AGENTS.md`, `CLAUDE.md`, or `Directory.Build.props` at any depth, or the repository-root `CHANGELOG.md`, `CLA.md`, `LICENSE`, `NOTICE`, `NuGet.config`, `Version.props`, or `global.json`. Those decide how every other change is judged rather than being judged by it, so your change to one waits for a maintainer's own approval rather than resting on the rest of the review. `docs/decisions/` is there for that reason and not because it is documentation: an architectural decision record is what the next change is written to be consistent with, which is also why a pull request that adds or changes one waits for a maintainer's approval of its head; the ADR itself is a `proposed` working record, edited in the same change as the code it governs. The check names the protected paths it found either way, so a passing run tells you which ones your change moved. When a maintainer has read such a change and wants it, their approval of your pull request's current head turns the check green; push again and it waits for a fresh approval of what you pushed. So when your work needs one — a new local tool, a coverage setting, a package pin, a workflow step, a word for the typo checker's vocabulary — make it in the same pull request and expect this check to wait for that approval.
- **`Coherence`** — fails only when a change related to yours landed underneath it: the same Markdown file, or a page whose `describes:` marker covers a file the other side changed. It leaves every `AGENTS.md`, `docs/operations/agent-workflow.md`, `docs/operations/local-development.md`, `docs/operations/client-endpoint.md`, `docs/architecture/stored-email-schema.md`, and `THIRD_PARTY_LICENSES.md` out, because those relate to nearly every change; `scripts/report-related-pull-requests.sh` still names them before you publish. Code on one side that names a file the other side changed, and a file under `design/` both sides changed, are warnings and fail nothing. A branch that merely fell behind `main` passes. When it fails, its job summary names each pair of files and the pull request that introduced the landed side; read the two together, rebase once that pull request is on `main`, and push. [`docs/operations/agent-workflow.md`](docs/operations/agent-workflow.md#coherence) holds the relation in full.
- **`Duplicate definitions`** — skips on your pull request and asks only in the merge queue, where it fails when your change adds a definition — a helper, a test double, a type, a script — doing the job of one that landed while you were writing it, and the queue takes the pull request back out. Its job summary names each pair: point your callers at the definition that landed, or extend one of the two to serve both, and push. A pair worth keeping is answered with a comment saying why, and a maintainer adds the `duplicate-definitions-read` label. [`docs/operations/agent-workflow.md`](docs/operations/agent-workflow.md#duplicate-definitions) holds what it reads.
**`Typo check`** runs on every pull request that is not a draft and spell-checks the files you changed, annotating each finding in the Files changed view. A finding blocks the merge until it is answered — fix what it names, or, if it flagged a word this project uses on purpose or a misspelling that is deliberate, say so in the pull request. The vocabulary it reads is `.config/typos.toml`; add the word there with a line explaining which of the two it is, and expect `Protected paths` above to wait for it, because that file sits under a protected prefix. That is the check reporting what your change moved rather than a refusal to take the word — a maintainer's approval of your pull request's current head turns it green.
**`CodeQL`** also runs on every pull request that is not a draft, and analyzes the stacks your change reached — the service's C#, the client's TypeScript, the desktop shell's Rust — each in its own `Analyze` job. It runs GitHub's security queries over each, so a finding is a value followed from an untrusted source to somewhere it should not reach rather than a style objection. Findings appear as annotations on the pull request. It is the one check here that gates nothing, and for a reason worth knowing: the query pack updates upstream, so a change that was clean when it merged can become a finding later without anybody touching the code. Treat one as a question to answer in the pull request — fix it, or explain why the path is not reachable — rather than as something to route around.
Merging additionally requires an approving review from an account with write access to the repository — today, a project maintainer. Your branch does not have to be current with `main`: an approved pull request enters a merge queue, which tests it combined with `main` and merges it, and rebasing is needed only when `git` reports a conflict, or `Coherence` or `Duplicate definitions` names a related change that landed underneath it. [`docs/operations/local-development.md`](docs/operations/local-development.md#pull-request-checks) documents every workflow and the branch ruleset in full.
A separate `Fathom review` workflow may post an automated review on a published pull request. It reports no status check, and it approves only a maintainer's own pull request — on yours, when it finds nothing it says so in a comment — so treat it as a second opinion beside the maintainer's review.
**From a fork it runs only when a maintainer asks for it** in a comment. Your own pushes never start one, and that is the security boundary rather than an oversight — the workflow holds a credential, so nothing a contribution can do may start it. Two practical consequences: pushing a fix does not queue a re-review the way it does on a maintainer's own branch, so say in the pull request that you have pushed and let them ask again; and a review that has not appeared is waiting on a person rather than on a queue. Nothing is lost by not having one — the required checks and the maintainer's own review are what decide the merge.
## Licensing your contribution
MailFathom is licensed under the [GNU Affero General Public License, Version 3](LICENSE), SPDX identifier `AGPL-3.0-only`, and **your contribution is offered under the same license.** Unlike Apache-2.0, which MailFathom carried until 2026, the AGPL has no clause of its own placing a contribution under the project's terms by the act of submitting it, so the mechanism that does it is this repository's [contributor licence agreement](CLA.md), which you accept once, on your first pull request. The same acceptance covers what you contribute to any other repository the owner keeps in the [`fathomise`](https://github.com/fathomise) organization, public or private: the agreement is accepted here and recorded here, and no other repository there asks for it again. **You keep the copyright in what you write** — the agreement is a licence, not a transfer, and it is non-exclusive, so your own rights in your work are undiminished and you may do anything you like with it elsewhere.
It carries three things, and it is worth knowing which:
- **The licence that lets MailFathom use your contribution at all.** This is the part Apache-2.0 section 5 used to do by itself, and no AGPL clause replaces it.
- **The freedom to publish MailFathom under other terms later.** Without the agreement, one merged contribution would make any later licensing decision — a different open-source licence, or a commercial licence sold beside it — impossible to take without finding and asking every past contributor individually. The agreement keeps that decision available, and the move from Apache-2.0 to AGPL-3.0-only is the first time it was taken. It announces no further one.
- **Your statement that the code was yours to give.** A licence says which terms a contribution arrives under. It never says the person submitting it was entitled to submit it, and that is the subject of the next section.
Accepting is one comment on your pull request, from the account that opened it:
```text
I have read the MailFathom Contributor Licence Agreement and I accept it.
```
Post it as the whole of your comment — casing, surrounding whitespace, and a trailing full stop are ignored, but nothing else is, because the sentence quoted inside a question about whether to accept is not an acceptance. The `Contributor licence` workflow reads it, records the acceptance, and turns the `license/cla` status green. What it records — your GitHub account and its numeric id, the pull request and the comment, the version of the agreement, and the exact revision of `CLA.md` you accepted — is written to the [`cla-signatures`](https://github.com/fathomise/MailFathom/tree/cla-signatures) branch of this repository, in the open. Nothing is sent to a third-party service, and the record holds no more about you than the pull request already does. You are asked once, not per pull request; a bot and the owner are not asked at all, and everybody else is, including a member of the organisation or a collaborator with write access here. The one thing that asks again is a revision of the agreement itself, because a later version is a new acceptance — the comment then opens by saying so, and nothing you already accepted is withdrawn by it. [ADR 0015](docs/decisions/0015-contributor-licence-agreement-and-where-assent-is-recorded.md) records why the agreement exists and why the register lives where it does.
Clause 2.2 of the agreement is the patent grant that travels with your contribution. It matches section 11 of the licence and is stated in its own right so that it survives a change of licence made under clause 2.1.
What the license cannot check for you is whether the code was yours to give. Before you open a pull request, satisfy yourself that:
- you wrote the contribution, or you have the right to submit it under AGPL-3.0-only;
- any third-party code, snippet, asset, or generated output in it is permissively licensed, is compatible with AGPL-3.0-only, and is identified in the pull request;
- your employer's agreements do not claim it, if you wrote it on their time or equipment;
- it contains no credential, token, private key, real mailbox data, or anyone's personal information.
**Bringing anything into this repository that MailFathom could not distribute under AGPL-3.0-only, or that would stop it being relicensed later, is the one mistake that cannot be fixed by a follow-up commit** — it has to be removed from the history. Copying a function from a GPL project, pasting a snippet from an answer with restrictive terms, or vendoring a file whose license nobody checked all land there. That MailFathom is itself copyleft does not admit copyleft code into it: the register below is what states the allowed set, and it is unchanged by the licence this project carries. When in doubt, ask in the pull request before it is reviewed.
Adding a dependency, a service, a container image, or an externally sourced code sample also requires a row in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) in the same pull request. Copyleft and source-available licenses — GPL, AGPL, SSPL, BUSL, Commons Clause, and similar — are refused without the owner's explicit approval, because MailFathom must stay distributable under commercial closed-source terms as well as these. **MailFathom being AGPL itself changes none of that**, which is the one thing about the register worth reading twice: the project's own grant is the owner's to change and a dependency's is not, so a copyleft dependency would end the closed-source arm permanently. The register's acceptance policy states the allowed set.
### The file header
Every file in this repository carries the same three lines, and a new file is no exception:
```csharp
// Copyright © 2026 Krzysztof Kasprowicz
// Licensed under the GNU Affero General Public License, Version 3. See LICENSE in the project root for license information.
// Project repository: https://github.com/fathomise/MailFathom
```
**In a C# file you never type it.** `IDE0073` fails the build on a file that is missing it, and `scripts/verify-fast.sh` inserts it for you — which is one more reason to run the fast loop after adding a file rather than before pushing.
**Everywhere else you do type it**, because that analyzer reads C# and nothing else. The wording never changes; only the comment syntax does, and each form is the one that file's own readers already parse:
| Where | Form |
|---|---|
| `.yml` and `.yaml` | Three `#` comment lines, first in the file, then a blank line |
| `.sh` | The same three `#` lines, directly under the shebang, which has to stay first |
| `deploy/helm/mailfathom/templates/` | A `{{- /* ... */ -}}` comment, so the header stays in the template instead of being rendered into every Kubernetes object the chart applies |
| A skill's `SKILL.md` | `license: AGPL-3.0-only` and a `metadata` block naming the author and the repository, which is where the [Agent Skills](https://agentskills.io/specification) format puts them. A skill vendored from another repository keeps its upstream's `license`, names the upstream author and repository in `metadata` instead, carries the upstream licence beside it as `LICENSE.txt`, and is accepted only while `THIRD_PARTY_LICENSES.md` names that repository; `.agents/skills/README.md` lists which skills those are |
| `.ts`, `.tsx`, `.js`, `.mjs`, and `.cjs` under `frontend/` | Three `// ` lines, first in the file, then a blank line before the imports |
| `.css` | One `/* ... */` block holding all three lines, because CSS has no line comment to use instead |
| `.html` | One `<!-- ... -->` comment, first in the document |
A `.json` file carries no header. A `package.json` is strict JSON and has no comment syntax at all; a `tsconfig.json` does take a comment, since `tsc` parses it as JSONC, but that is one tool's parser rather than a form the header is written in.
`scripts/test-agent-workflow.sh` fails when one of those is missing, so a forgotten header is a red check rather than a review comment. It reads the expected text out of `.editorconfig`, which means the header is one decision written in one place no matter how many forms it takes.
**The fast loop will not tell you.** That script runs in the full gate and nowhere else, and neither eslint nor either formatter has a rule for the header — so a new client file without one builds, lints, type-checks, and passes `verify-fast.sh` cleanly, then stops the last gate before your commit. Adding a file is the moment to type the header, not the moment to rely on a check.
**Do not modify it, and do not add anything of your own beside it.** No second copyright line, no `@author` tag, no "modified by", no name, initials, handle, or contact detail in a comment, a file, or a header anywhere in the tree. A pull request that adds one is asked to remove it before review continues. The one `author` a skill's `metadata` block names is the copyright holder the header already states, spelled the way that format expects it; it is the same single record, not a second one, and it stays that name whoever edits the skill.
This is a consistency rule, not a claim about who wrote which line. The header states one project, one copyright holder of record, one license, and one place the project lives, so that a reader of any file — and a tool scanning the tree — gets the same answer everywhere, including from a file that has travelled far from this repository. It transfers nothing: **you keep the copyright in what you write**, exactly as the section above and the root [`NOTICE`](NOTICE) say, and the `NOTICE` is explicit that it claims nothing about contributions by other copyright holders. Authorship is recorded where it is actually durable and verifiable — in the commit history and in the pull request — rather than in a comment that the next refactor moves to a different file.
The files that carry the licensing decision itself — `LICENSE`, `NOTICE`, and `.editorconfig` — are all on the `Protected paths` list above, so a pull request touching one waits for a maintainer's approval of its current head. Say why in the pull request, because the effect reaches far past the diff: editing `LICENSE` turns GitHub's detected `AGPL-3.0` into `NOASSERTION`, and editing the `file_header_template` line rewrites every source file in the repository.
## Where the detailed rules live
| Document | What it governs |
|---|---|
| [`AGENTS.md`](AGENTS.md) | The non-negotiables, what holds across both stacks, the privacy and licensing obligations, the reliability and security rules — and a table naming every file below and when each one is read |
| [`backend/AGENTS.md`](backend/AGENTS.md) | The service stack's whole contract: the .NET and C# conventions every file under `backend/` follows — comments and XML documentation among them — the asynchronous return types, the architecture boundaries, and the email invariants |
| [`backend/src/AGENTS.md`](backend/src/AGENTS.md) | What holds for the service's production code alone: API and failure design, dependency injection and configuration, and outbound HTTP client lifetimes |
| [`backend/tests/AGENTS.md`](backend/tests/AGENTS.md) | The service suite's own policy, coverage rules, and what belongs in the integration suite |
| [`frontend/AGENTS.md`](frontend/AGENTS.md) | The client stack's whole contract: what the toolchain enforces and what to do when it blocks you, package pinning and the lock file, and driving the running client in a browser |
| [`frontend/src/AGENTS.md`](frontend/src/AGENTS.md) | What holds for the client's application source alone: the package boundary, the state model, component and module design, and the UX, UI, accessibility, and performance obligations every screen meets |
| [`frontend/tests/AGENTS.md`](frontend/tests/AGENTS.md) | The client suite's own policy: where a test file sits and why, what it may assert, and what belongs in the browser harness instead |
| [`design/AGENTS.md`](design/AGENTS.md) | How the design under `design/` is authored: the skills a screen is drawn with, and the bar an artboard's own code is held to |
| [`docs/AGENTS.md`](docs/AGENTS.md) | Documentation rules and the `describes:` marker every page carries |
| [`docs/operations/issue-tracking.md`](docs/operations/issue-tracking.md) | Which work needs an issue, what its body carries, and how a maintainer triages one that arrives from outside the project |
| [`docs/operations/agent-workflow.md`](docs/operations/agent-workflow.md) | The verification scripts and the skills at length, and how the automated review behaves |
| [`docs/decisions/`](docs/decisions/) | Architectural decision records — required context before an architectural change |
Those files are written for the autonomous agents that do most of the work here, so they are longer and more prescriptive than a contribution guide needs to be. They remain authoritative: where this guide and one of them disagree, they win, and please report the discrepancy.
## Questions
Use [Discussions](https://github.com/fathomise/MailFathom/discussions) — `Q&A` for questions, `Ideas` for proposals that are not yet scope. A question is not a unit of work and does not become one by arriving as an issue; one that turns out to be work gets converted.