How it works
This page describes the mechanism, for someone deciding whether to trust it. Relay carries mail over an ordinary git repository. There is no server, no daemon, and no database. A member sends mail by writing a few files and making a commit; the commit is pushed to a shared remote if one exists. Correctness comes from the shape of the data and from git’s own conflict handling, not from a coordinator.
For the byte-level format these files must satisfy, see the on-disk contract. This page explains why that format is safe to write concurrently.
The store is a separate repository
The transport store is its own git repository, and it must live outside the repository you are adopting Relay into. The installer refuses any store path inside the adopting repo, because mail committed there would land on your product’s history and be pushed to its default branch:
install error: Refusing to place the transport store at '.../adopter/mailstore',which is inside the adopting repository '.../adopter'. Agent mail would becommitted to your product repository and pushed to its default branch.The default store location is a sibling directory named <repo>-mail. See
configuration for how the store path, remote, and branch
are set.
On-disk layout
A store holds two trees. Canonical messages live under mail/; each member’s
own delivery pointers and state live under roles/<role>/.
<store_root>/ .relay-version # the on-disk contract: "pibmo-relay/2" team.json # the roster (closed address allowlist) mail/ <uuid>.md # canonical message, write-once roles/ <role>/ inbox/<uuid>.ref # delivery pointer: this arrived for me sent/<uuid>.ref # delivery pointer: I sent this flags/<uuid>/read.<ts> # marker: I read it (0 bytes) flags/<uuid>/archived.<ts> # marker: I filed it away (0 bytes)A single send writes one message plus one pointer per recipient and one for the
sender. Sending “Deploy plan” from alice to bob produces exactly:
mail/b16e19d3-d363-422c-96cf-57c6edb41f82.mdroles/bob/inbox/b16e19d3-d363-422c-96cf-57c6edb41f82.refroles/alice/sent/b16e19d3-d363-422c-96cf-57c6edb41f82.refThe message file carries the content. The .ref files are two-line delivery
pointers — the message id and when it arrived — and carry no mutable state at
all. State is added later, as zero-byte marker files under flags/. The message
is the same bytes for everyone; the pointers and markers are private to each
member.
The exact byte format of all of this is the on-disk contract.
Why this is concurrency-safe
Two properties make concurrent writers safe with almost no coordination:
-
Messages are write-once and named by UUID. A message file is created once at
mail/<uuid>.mdand never modified or moved. The UUID is generated locally with the standard library; no registry is consulted, so two members writing at the same instant cannot collide on a name and never need to agree on one. -
State is added, never edited. Marking read, replying, and archiving create new zero-byte files under the acting member’s own
roles/<role>/flags/path. Nothing is read-modify-written, so there is no update for a concurrent writer to lose. A field that has to be rewritten in place is exactly where a concurrent pair of writers loses one of their updates; a file that is only ever created is not.
Because distinct writers touch distinct paths, two commits made concurrently on different machines merge cleanly: git rebases one on top of the other with no conflict, since they change different files. Better, two members recording the same fact write byte-identical zero-byte files, which git unions without a content conflict. The design converts what would be a distributed-locking problem into a set of non-overlapping file creations.
Read tracking is idempotent by construction: if the marker exists, nothing is written. If a duplicate ever appears, the earliest timestamp wins, so the first read time is the true one and no later write can move it.
Path-scoped commits
Every write is committed with an explicit path list, never git add -A. The
engine stages only the files it just wrote, verifies they actually introduce a
change, and commits with those same paths:
git add -- <listed paths>git diff --cached --quiet -- <listed paths> # skip if nothing changedgit commit -q -m "<message>" -- <listed paths>A real send commit looks like this — three files, nothing else:
mail(info): Deploy plan -> bob
mail/b16e19d3-....md | 12 ++++++++++++ roles/alice/sent/b16e19d3-....ref | 2 ++ roles/bob/inbox/b16e19d3-....ref | 2 ++ 3 files changed, 16 insertions(+)And here is bob archiving it, which is the marker model in one diffstat:
mail(archive): Deploy plan [b16e19d3-d363-422c-96cf-57c6edb41f82]
.../bob/flags/b16e19d3-.../archived.20260808T125853Z | 0 .../bob/flags/b16e19d3-.../read.20260808T125853Z | 0 2 files changed, 0 insertions(+), 0 deletions(-)Two files changed and nothing inserted or deleted: the entire content of a marker is the fact that it exists. Note also that archiving an unread message recorded both facts, so archived never implies an unread message that was somehow filed away, and that the delivery pointer was not touched.
Path-scoping means an unrelated dirty file in the working tree can never be swept
into a mail commit, and a git add that fails (for example, the store shadowed by
a .gitignore) is treated as a failed delivery rather than a silent drop.
The writer lock
Before staging and committing, the engine takes an exclusive flock on
<store_root>/.mail.lock. Be precise about what this does and does not protect:
| Protects | Does not protect |
|---|---|
Serializes writers on one machine so their add/commit/rebase/push sequences do not interleave | Anything across machines — an flock is local to one host’s kernel |
Cross-machine safety does not come from the lock. It comes from the
rebase-before-push loop below, which reconciles with whatever other members have
already pushed. The lock’s job is narrower: on a single machine (notably the
shared-clone topology, where several checkouts share one
clone), it stops two local processes from running git operations against the same
repository at the same time. sync takes the same lock, because a rebase rewrites
the working tree and index and must not race a local commit.
The rebase-before-push retry loop
When the store has a remote, a committed write is not yet delivered — it must reach the shared branch. Another member may have pushed in the meantime, so a plain push can be rejected as non-fast-forward. The engine handles this by rebasing onto the current upstream and retrying, with backoff:
for each attempt: git fetch <remote> git -c rebase.autostash=true rebase <remote>/<branch> if rebase succeeded: git push <remote> <branch> if push succeeded: done else: git rebase --abort if not the last attempt: sleep(backoff)Because every writer only ever adds files under distinct paths, the rebase is almost always a clean replay. The retry exists for the ordinary race — someone else pushed first — not for merge conflicts.
The backoff schedule has eight attempts:
| Attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Sleep before the next attempt (s) | 1 | 2 | 4 | 8 | 8 | 8 | 8 | — |
The backoff is only paid between attempts, never after the last one — sleeping and then giving up would waste the final delay for no benefit. So the worst case is the seven gaps before the eighth attempt, about 39 seconds of sleep in addition to the git operations themselves. In practice a busy human-scale team rarely reaches even the second attempt.
When the push is exhausted
If all eight attempts fail (the remote is unreachable, or contention never clears), the commit is already made locally and nothing is lost. The engine raises with a message that says exactly this:
Push failed after 8 attempts. 1 commit(s) are committed locally and NOT yet onorigin/main; nothing was lost. Re-send is not needed. Retry delivery with:pibmo-relay flushDo not re-send. pibmo-relay flush re-runs the same rebase-and-push loop against
the unpushed local commits. This split — the local commit always succeeds, the
network push may be retried later — is what lets a write be durable before it is
delivered.
An unsettled store is refused
That promise — “committed locally and nothing was lost” — is only true if the
local commit is somewhere it will survive. Two states break it, and the engine
refuses to write in either. sync unwinds what it can rather than leaving the
clone in one of them, and every mutating command checks before it writes.
A rebase or merge in progress. HEAD is detached, so a commit made now lands on the detached head and is discarded the moment the operation is resolved:
$ ./tools/mail.sh --role alice send --to bob --subject x --body ypibmo-relay: Cannot write to this store: this store is in the middle of a rebaseor merge, so HEAD is detached and anything committed now would be discarded whenthat operation is resolved. Finish it first -- resolve the conflicts and 'git -C<store> rebase --continue' (or 'merge --continue'), or abandon it with 'git -C<store> rebase --abort' (or 'merge --abort') -- then retry.Unresolved conflicts in the index, even with no rebase in progress. This is the subtler one: a rebase whose autostash fails to re-apply exits 0 and leaves conflict markers behind with no rebase directory at all, so “is a rebase running” does not catch it. The index is unmerged, no push from the clone can succeed, and mail committed here would pile up undelivered:
pibmo-relay: Cannot write to this store: this store has unresolved conflicts inconflict.txt. Those files hold conflict markers and the index is unmerged, so nopush from this clone can succeed and anything committed now would sit hereunpublished. Resolve them ('git -C <store> status', edit, then 'git add'), ordiscard the conflicted side with 'git -C <store> checkout --merge -- <path>'. Ifa sync's autostash caused this, 'git -C <store> stash list' still holds it.Read-only commands such as who and list still work in both states, so you can
inspect the store while you decide what to do with it. The refusal is deliberately
not an attempt to reconcile someone’s half-finished merge: it stops and says so.
Local-only mode
Running with no remote at all is fully supported and is the most common
first-run state for a single-machine team. If the store has no configured remote,
commit_and_push stops after the local commit: the mail is durable in the local
repository and nothing is pushed. send, reply, read, and archive all work
normally. Only sync and flush require a remote, and they report plainly when
there is none:
$ pibmo-relay syncpibmo-relay: No 'origin' remote configured on this clone.Add a remote later (see configuration) and the same writes begin pushing; nothing about the stored mail changes.
Git timeouts
Network git operations run while the writer lock is held. A single stalled
fetch would otherwise wedge every other writer on the machine, so every git
invocation has a timeout (120 seconds by default). A hang becomes an error rather
than an indefinite block:
pibmo-relay: Git command timed out after 120s ('git fetch origin -q').The remote may be unreachable.Performance envelope
Be candid about what this is for. Relay is designed for human-scale message rates — a handful of agents exchanging addressed messages as they work, not a high-throughput bus. Each write is a git commit and, with a remote, a fetch/rebase/push round trip serialized behind a per-machine lock. That is cheap at the rate real coordination happens and deliberately not engineered for thousands of messages per second. If you need queue throughput, a git-native transport is the wrong tool.
What this does not give you
Trust also means being clear about the boundary. Relay provides addressing and
read-tracking. It does not provide message confidentiality between members. A
git-native transport gives every member a full clone, and a clone holds every
message — including messages addressed to other members. Git has no path-level
access control, so roles/bob/inbox/ is readable by anyone who can clone the
store. Running members as separate operating-system users adds credential isolation,
which keeps their machines and push credentials separate; it does not keep
messages private from members. If a team needs per-member message
confidentiality, a git-native transport is the wrong architecture. This is stated
again, without softening, in the FAQ and
topologies pages.
Sender attribution is likewise not authenticated: the from: header is
self-asserted by the sending seat. The one thing the engine does enforce is that a
subject or header value cannot contain a newline, because a newline could inject a
second from: key and forge that attribution — such a value is rejected at write
time.
Related
- Getting started — install and send your first message.
- Concepts — roles, threads, and message types.
- Configuration — store path, remote, and branch.
- CLI reference — every command and flag.
- Topologies —
per-member-clonevsshared-cloneand the security guard. - Hooks — the session banner that surfaces unread mail.
- Troubleshooting and the FAQ.
- On-disk contract — the byte-level format.
Verified against pibmo-relay at commit ca19ce6 on 2026-08-09. The tool moves; if a
command here disagrees with the one on your machine, the tool is right.