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 v1 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
per-mailbox state lives under roles/<role>/.
<store_root>/ team.json # the roster (closed address allowlist) mail/ <uuid>.md # canonical message, write-once roles/ <role>/ inbox/<uuid>.ref # a pointer: unread | read sent/<uuid>.ref # the sender's own copy archive/<uuid>.ref # archived pointerA 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/75567b56-a0bf-45f2-8611-30a59baac106.mdroles/bob/inbox/75567b56-a0bf-45f2-8611-30a59baac106.refroles/alice/sent/75567b56-a0bf-45f2-8611-30a59baac106.refThe message file carries the content; the .ref files are four-line pointers
carrying only per-member state (read_at, status). The message is the same
bytes for everyone; the mailbox pointers are private to each member.
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. -
All mutable state is a per-member pointer. Marking read, replying, and archiving touch only files under the acting member’s own
roles/<role>/path. No two members ever write the same file in the normal course of operation.
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. The design converts what would be a distributed-locking problem into a set of non-overlapping file writes.
Read-state transitions are also idempotent and write-once at the field level:
read_at is set once and preserved on every later read, so re-reading a message
never rewrites history and never produces a spurious change to push.
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 commit looks like this — three files, nothing else:
mail(info): Deploy plan -> bob mail/75567b56-....md | 12 ++++++++++++ roles/alice/sent/75567b56-....ref | 4 ++++ roles/bob/inbox/75567b56-....ref | 4 ++++ 3 files changed, 20 insertions(+)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 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 after (s) | 1 | 2 | 4 | 8 | 8 | 8 | 8 | 8 |
The backoff accumulated before the eighth and final attempt is about 39 seconds; if that attempt also fails the loop sleeps once more before giving up, so the worst case is roughly 47 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.
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. Under the isolated-user topology you additionally get credential isolation,
which keeps members’ 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 — shared-user vs isolated-user and the security guard.
- Hooks — the session banner that surfaces unread mail.
- Troubleshooting and the FAQ.
- On-disk contract (v1) — the byte-level format.