On-disk contract
This specification defines the on-disk data format and directory layout for a Pibmo Relay store. Any implementation that agrees with it can interoperate on a shared git transport store.
The organizing idea is that nothing is ever edited in place. A message is written once. A delivery pointer is written once. Mutable per-member state — read, archived, and future flags — is not a field anyone rewrites but the presence of an immutable, append-only marker file, and current state is a pure reduction over the set of markers. Reading, modifying and writing back a shared field is how two members acting at once overwrite each other; creating a distinct file cannot lose an update, and git merges concurrent creations by union.
1. Directory layout
<store_root>/|-- .relay-version # contract discriminator; one line, "pibmo-relay/2"|-- team.json # the roster (closed allowlist)|-- mail/| `-- <uuid>.md # canonical message files (write-once)`-- roles/ `-- <role>/ # one directory per team member seat |-- inbox/ | `-- <uuid>.ref # delivery pointer: delivered to me as a recipient |-- sent/ | `-- <uuid>.ref # delivery pointer: I sent this `-- flags/ `-- <uuid>/ # the per-(role, message) marker set |-- read.<ts> # grow-only marker: message was read `-- archived.<ts> # grow-only marker: message was archived<uuid>is a valid, non-empty UUID (section 2).<role>matches^[A-Za-z0-9_-]+$.- There is no
archive/folder. Archived is a marker, not a location. flags/<uuid>/exists only once it holds at least one marker, because git does not track empty directories. A message that is delivered but carries no facts has a delivery pointer and noflags/<uuid>/directory; the reducer reports it unread and un-archived.
Here is a real store, after lead sent one message to analyst, who read and
then archived it:
acme-app-mail/ .relay-version pibmo-relay/2 team.json README.md written by the installer .gitignore mail/ .gitkeep bec95ea6-8f76-46a3-9f12-bde2ab71b1b4.md the canonical message roles/ lead/ inbox/ .gitkeep sent/ .gitkeep bec95ea6-....ref lead's copy of what lead sent analyst/ inbox/ .gitkeep bec95ea6-....ref delivered to analyst sent/ .gitkeep flags/ bec95ea6-8f76-46a3-9f12-bde2ab71b1b4/ read.20260808T125413Z 0 bytes archived.20260808T125413Z 0 bytesThe .gitkeep files are why a mailbox directory is never empty: git does not
track empty directories, so without them a fresh clone would arrive with no
mailboxes at all. The installer also writes a README.md and a .gitignore, and
each machine keeps an untracked .mail.lock at the root.
Note what did not happen: archiving did not move the pointer out of
inbox/. The pointer is where delivery put it, and archived is a fact recorded
beside it.
2. UUID validation and identity
- Format: every UUID matches
^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$. - Non-empty: empty strings, missing UUIDs, and filenames resolving to
mail/.mdorroles/<role>/*/.refare invalid. An id is validated before any write, not after. - Case: generation uses lowercase hex; implementations must handle any case.
- Marker tags are independently generated UUIDs of the same grade — globally unique, coordination-free, and free of dots.
3. Timestamp formats
Two encodings of the same instant are used, because a marker’s timestamp lives in a filename, and the ISO extended format’s colon is not a legal filename character on FAT, exFAT, NTFS, or WSL’s DrvFs.
| Where | Format | Example |
|---|---|---|
File bodies — message frontmatter, delivered_at | extended, YYYY-MM-DDTHH:MM:SSZ | 2026-08-08T12:54:13Z |
Marker filenames — the <ts> field | basic, YYYYMMDDTHHMMSSZ | 20260808T125413Z |
The basic format is the extended one with the - and : separators removed. It
is second-resolution with a terminal Z, no fractional seconds, and no non-Z
offset. Converting between the two is pure and reversible. Basic-format strings
are fixed-width, so lexical order equals chronological order — which is what
makes the earliest-read rule in section 7 well defined.
4. Canonical message format
Location: mail/<uuid>.md. Single-line key-value
frontmatter bounded by ---, then a blank line, then the body:
---id: <uuid>thread: <uuid>in_reply_to: null|<uuid>from: <role>to: [<role_list>]cc: [<role_list>] # present only if there is at least one cc recipienttype: info|request|report|ack|pingsubject: <single-line string>created: <extended timestamp>---
<body>Keys appear in exactly that order. Header values may not contain newlines or
control characters, and a duplicate or unknown key makes the message invalid at
read time — a newline in a header could otherwise inject a second from: line
and forge attribution at the file level. Canonical message files are
write-once: once created they are never modified or moved.
5. Delivery pointer format
Location: roles/<role>/{inbox,sent}/<uuid>.ref.
A delivery pointer records that a message was delivered to this member in a role
— inbox for a recipient, sent for the sender. It is write-once: never
edited, never moved. It carries no mutable state at all — state lives in the
markers described in section 6.
id: bec95ea6-8f76-46a3-9f12-bde2ab71b1b4delivered_at: 2026-08-08T12:54:13ZTwo keys, and that is the whole file:
id— the message UUID, matching bothmail/<uuid>.mdand the pointer’s own filename.delivered_at— when the pointer was written, extended format, write-once.
A pointer is well-formed only if it has exactly these two keys, id is a
valid UUID equal to the filename’s, and delivered_at parses. A pointer that is
not well-formed — a missing or extra key, an id that disagrees with its
filename, a malformed timestamp, a truncated file — is surfaced as corruption
and excluded. It is never silently skipped.
The inbox pointer is written by the sender at send time. That is the one
place a member writes into another member’s subtree. A role may not address
itself, so no UUID ever appears in both inbox/ and sent/ under one role.
6. Marker files
Location: roles/<role>/flags/<uuid>/<marker-name>. A marker records one
per-member fact about one message. Markers are append-only: created once,
never edited, never moved, and never deleted except by an explicit compaction
(section 8) or a reconciling doctor — never blind-deleted. A member writes
markers only under their own roles/<self>/flags/, so the marker set is
strictly self-written.
6.1 Marker filename grammar
A marker name is a fact-type token followed by dot-separated fields. Every field
other than the fact token is free of dots, so the grammar parses unambiguously
by splitting on .:
| Kind | Name | Fields | Body |
|---|---|---|---|
| Grow-only | read.<ts> | read, <ts> | empty (0 bytes) |
| Grow-only | archived.<ts> | archived, <ts> | empty (0 bytes) |
| Observed-remove add | <flag>.<ts>.<tag> | <flag>, <ts>, <tag> | empty (0 bytes) |
| Observed-remove tombstone | <flag>.remove.<ts>.<tag> | <flag>, remove, <ts>, <tag> | the add tags it observed, one per line |
<ts>is a basic-format timestamp (section 3): free of colons and dots.<tag>is a fresh UUID, also free of dots. Two adds, or two removes, therefore never share a name.removeis a reserved discriminator, always the field immediately after the fact token. Because every field to its left is dot-free, an add and a tombstone can never be confused for one another.- Every marker file is zero bytes, with the single exception of a tombstone, whose body lists the add tags it observed. Zero-byte markers with the same name are byte-identical, so git merges concurrent identical writes by union with no conflict; tombstones never share a name, so their bodies never collide. A tombstone is identified by its name, not its size — a tombstone that observed no adds has an empty body and is still a tombstone.
The observed-remove rows describe the mechanism reserved for toggle-able
flags (star, snooze, label-<name>). Those are designed here and are not
required of an implementation; the two facts a store actually carries today are
read and archived.
6.2 Well-formedness and disposition
A marker name is parsed by splitting on . and dispatching on the first field
and the field count:
| Fact token | Fields | Shape | Meaning |
|---|---|---|---|
read, archived | 2 | <fact>.<ts> | grow-only assertion |
star, snooze, label-<name> | 3 | <flag>.<ts>.<tag> | observed-remove add |
star, snooze, label-<name> | 4, second field remove | <flag>.remove.<ts>.<tag> | tombstone |
A <ts> is valid only if it matches ^[0-9]{8}T[0-9]{6}Z$ and denotes a
real UTC instant — month 01-12, a day that exists in that month, hour 00-23,
minute and second 00-59. A <tag> is valid only if it is a valid UUID. A
label-<name> token is valid only if <name> is non-empty and contains no .,
/, or control character. A grow-only or add marker is well-formed only if its
body is empty; a non-empty body on one of those is corruption.
What happens to a marker that is not well-formed depends on whether it is recognized at all, and the distinction is deliberate:
- An unrecognized fact token — one that is not
read,archived,star,snooze, and does not begin withlabel-— is ignored. It may belong to a later contract, and a reader from the future should not trip an older engine. - A recognized token that is malformed — wrong number of fields, an invalid timestamp or tag, an empty label name, a body where there should be none — is surfaced as corruption and excluded from the reduction, the same way an unparseable pointer is. It never contributes a fact, and a malformed timestamp never enters the earliest-read calculation.
A tombstone body is UTF-8, zero or more lines, each a valid tag UUID naming an observed add. A trailing newline is permitted and ignored, blank lines are ignored, and a line that is not a valid UUID is ignored — it cancels no add.
7. The state reducer
Current state for a (role, uuid) pair is a pure function over the marker set,
and it is total — there is no input for which it fails to produce an answer.
- read if at least one well-formed
read.*marker exists. The reportedread_atis the earliest timestamp across the well-formedread.*markers — earliest-read wins. A marker written later with a later timestamp therefore cannot move the reported read time forward. Note what this does not promise: the value is the minimum over the set, so a marker carrying an earlier timestamp — a clone with a skewed clock, say — does lower it when it arrives. The rule is “earliest wins”, not “first observed wins”. - archived if at least one well-formed
archived.*marker exists. - inbox view = has an
inbox/pointer and is not archived. archive view = has aninbox/pointer and is archived. - sent: a message with only a
sent/pointer is reported read and is not archivable — read and archive are properties of received delivery. - toggle-able flags (future): live if some add tag appears in no tombstone. Resolution is by observed tags, never by wall-clock, so there is no same-instant or clock-skew loss.
8. Operation semantics
Every state-changing operation either creates one or more new, uniquely-pathed files, or does nothing. No operation reads a shared field and writes it back.
- mark read: if the reducer already reports read, do nothing. Otherwise write
flags/<uuid>/read.<now>and commit that one path. - archive: valid only on a received message. If already archived, do nothing.
Otherwise write
flags/<uuid>/archived.<now>, and if the message is not yet read, also writeread.<now>— archiving implies reading. The delivery pointer is never moved or deleted. - list: the enumerable folders are
inbox,archive, andsent.inboxandarchiveare two views over the sameroles/<role>/inbox/pointers, partitioned by the archived reducer; there is noarchive/directory to enumerate. Results are sorted lexically by UUID, so a listing is stable across runs and across implementations. - send, reply, broadcast: write the canonical
mail/<uuid>.md, aninbox/pointer for each recipient, and asent/pointer for the sender. - compaction (optional):
doctor --fix, holding the lock, may collapse a marker set to its minimal equivalent — duplicateread.*markers down to the earliest, and a cancelled add dropped. A tombstone is removed only together with the add it cancelled, so the pair leaves in one commit and nothing a tombstone was suppressing can outlive it and come back to life. A set whose surviving marker is not yet committed is left alone entirely. The result is checked by re-running the reducer over it rather than argued to be safe, so what the reducer reports never changes, and nothing is deleted that was not proved redundant. The reducer is correct on an uncompacted set; compaction only bounds growth.
8.1 Concurrency and the lock
Because every write creates a distinct path, the message-state model needs no
lock for correctness. Concurrent operations cannot lose an update or corrupt a
merge, under either topology. The advisory lock (.mail.lock) is retained only
to serialize concurrent commits into a shared git index — under shared-clone,
or when two operations run in one clone — because git itself admits one
committer at a time. Shared files that are not markers, such as team.json,
still need the lock or a merge.
9. The store’s format stamp
A store carries its format in a single file, .relay-version, at the store root,
holding one line:
pibmo-relay/2A trailing newline is tolerated. The engine parses the number and refuses to operate a store stamped newer than the format it implements, rather than misreading it — so an older client meeting a store written by a newer one fails closed instead of corrupting it. The file is written exactly once, when the store is initialized, under the lock.
pibmo-relay who says nothing about the stamp while a store is current, because
there is nothing useful to say. It reports the version only when the store is
behind the client — which is the one moment the number tells a person
something they can act on — and names the remedy with it:
contract: pibmo-relay/1 (behind pibmo-relay/2; run 'pibmo-relay migrate')doctor --json and list --json carry the version unconditionally, since a
script should be able to branch on it.
10. What the contract guarantees
- Messages are write-once. A canonical message is never modified or moved.
- Per-member state lives in that member’s own subtree. The marker set is
strictly self-written; the sender-written
inbox/pointer is the one pre-existing exception. - Ids are unique without coordination. Locally generated UUIDs, extended to marker tags.
- Read and archived are both recorded, and stay consistent. Folder membership encodes delivery role, not archived state; archiving an unread message also records that it was read.
- A read timestamp is effectively write-once, preserved by taking the earliest marker.
- Operations are idempotent and missing-safe. Marking read twice is a no-op, not an error.
- Addressing is a closed allowlist, checked before any write.
- The sender keeps their own copy, as the
sent/pointer.
See also
- Concepts — messages, pointers, markers, and the store, in prose.
- How it works — the engine and the transport around this format.
- CLI reference — the commands that produce and read these files.
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.