Skip to content

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 no flags/<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 bytes

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

  1. 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}$.
  2. Non-empty: empty strings, missing UUIDs, and filenames resolving to mail/.md or roles/<role>/*/.ref are invalid. An id is validated before any write, not after.
  3. Case: generation uses lowercase hex; implementations must handle any case.
  4. 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.

WhereFormatExample
File bodies — message frontmatter, delivered_atextended, YYYY-MM-DDTHH:MM:SSZ2026-08-08T12:54:13Z
Marker filenames — the <ts> fieldbasic, YYYYMMDDTHHMMSSZ20260808T125413Z

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 recipient
type: info|request|report|ack|ping
subject: <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-bde2ab71b1b4
delivered_at: 2026-08-08T12:54:13Z

Two keys, and that is the whole file:

  1. id — the message UUID, matching both mail/<uuid>.md and the pointer’s own filename.
  2. 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 .:

KindNameFieldsBody
Grow-onlyread.<ts>read, <ts>empty (0 bytes)
Grow-onlyarchived.<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.
  • remove is 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 tokenFieldsShapeMeaning
read, archived2<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 with label- — 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 reported read_at is the earliest timestamp across the well-formed read.* 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 an inbox/ 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 write read.<now> — archiving implies reading. The delivery pointer is never moved or deleted.
  • list: the enumerable folders are inbox, archive, and sent. inbox and archive are two views over the same roles/<role>/inbox/ pointers, partitioned by the archived reducer; there is no archive/ 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, an inbox/ pointer for each recipient, and a sent/ pointer for the sender.
  • compaction (optional): doctor --fix, holding the lock, may collapse a marker set to its minimal equivalent — duplicate read.* 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/2

A 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.