Skip to content

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 be
committed 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 pointer

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/75567b56-a0bf-45f2-8611-30a59baac106.md
roles/bob/inbox/75567b56-a0bf-45f2-8611-30a59baac106.ref
roles/alice/sent/75567b56-a0bf-45f2-8611-30a59baac106.ref

The 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:

  1. Messages are write-once and named by UUID. A message file is created once at mail/<uuid>.md and 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.

  2. 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 changed
git 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:

ProtectsDoes not protect
Serializes writers on one machine so their add/commit/rebase/push sequences do not interleaveAnything 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:

Attempt12345678
Sleep after (s)12488888

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 on
origin/main; nothing was lost. Re-send is not needed. Retry delivery with:
pibmo-relay flush

Do 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 sync
pibmo-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.