Two compose files live here:
docker-compose.yml— single node. Demonstrates sender-side ingest + status lookup.DISTRIBUTION_STATUS_COMPLETEDhere is the vacuous-zero-peer case (no targets, no real transfer).docker-compose.two-node.yml— A and B peered together. Demonstrates actual file delivery: files sent from A appear on B's filesystem inbox. This is the flow operators care about.
The single-node setup below is the per-size benchmark for sender-side ingest. For the real delivery demo, jump to "Two-node delivery" below.
All compose files here pin
v0.4.8(the latest release), which satisfies every attachment feature in the table below. To test local changes ahead of a release, comment out theimage:line and uncomment thebuild:block in any of the compose files to build from the repo root.
| Capability | Min version | Notes |
|---|---|---|
PRD-006 attachment RPCs (SendAttachments, status lookup) |
v0.2.0 |
v0.1.x predates PRD-006 and fails with unimplemented: method not found. |
| Reliable cross-peer delivery | v0.3.0 |
v0.2.x carries the peat#864 substrate bug — the sender's SubscribeAttachmentBundle stream stalls one frame short of terminal on a real transfer. v0.3.1 relocated the receive lifecycle into peat-protocol (no behavior change). |
Deterministic identity + derive-id (multi-host peering) |
v0.4.4 |
Offline peer-id derivation; see Multi-host delivery below. |
Hands-off outbox watcher (PEAT_NODE_ATTACHMENT_OUTBOX_WATCH) |
v0.4.5 |
Auto-distributes any stable new file dropped in an outbox root — no SendAttachments call. |
| Inbox mirrors the sender's outbox layout | v0.4.8 |
#173; earlier images nested every delivery under inbox/{distribution_id}/{filename}. |
The two-node CRDT sync demo lives one directory up at
../docker-compose.yml; this one is the
smallest possible attachment-only example.
docker compose up -d
./send.sh # ingests outbox/hello.txt
docker compose logs peat-node # see the attachment events
docker compose down -vsend.sh reads outbox/hello.txt, computes its sha256 + size, POSTs a
SendAttachments request via the Connect JSON wire, and prints the
response. It then calls GetAttachmentDistribution to confirm the
bundle reached its terminal state (here, COMPLETED — zero peers means
the watcher's initial-status shortcut fires immediately).
docker-compose.yml sets one --attachment-root and accepts every
other PRD-006 default:
PEAT_NODE_ATTACHMENT_ROOT: outbox=/var/lib/peat/outboxThe host directory ./outbox is bind-mounted (read-only) into the
container at /var/lib/peat/outbox. Drop additional files into
./outbox/ to attach them — they're addressable from
SendAttachments as root_name=outbox + relative_path=<filename>.
Without this env var, the four attachment RPCs return Unimplemented —
the PRD-006 safety default operators opt out of by naming the readable
roots.
- Wire encoding. The Connect JSON shape (camelCase fields, base64
for the
sha256bytes field, thescopeoneof as{"allNodes":{}}). - Path validation.
outbox/hello.txt's resolved path stays inside the canonicalised root. - Streaming ingest. Tee-style hash + iroh content-address
(
create_blob_from_stream). - Hash verification. The declared sha256 matches the stream's computed sha256.
- Distribution document creation.
IrohFileDistribution::distributepublishes the record underfile_distributions(Automerge). - Status lookup.
GetAttachmentDistribution(distribution_id)resolves through the registry's reverse index and the runtime's per-distribution state. - Retention background task. Default 24h — eviction sweeps once a
minute. Override to a short value via
PEAT_NODE_ATTACHMENT_HANDLE_RETENTION_SECSif you want to see the bundle age out beforedocker compose down -v.
Real cross-peer file delivery (PRD-006 v1.1, post the inbox-watcher landing).
docker compose -f docker-compose.two-node.yml up -d
./peer.sh # bidirectional ConnectPeer
ENDPOINT=http://127.0.0.1:50061 OUTBOX_DIR=outbox-a ./send.sh
ls inbox-b/ # delivered files mirror the sender's outbox layout
docker compose -f docker-compose.two-node.yml down -v(Pulls ghcr.io/defenseunicorns/peat-node:v0.4.8. For testing local
changes, swap the image: line for the commented build: block in
both services.)
What the two-node setup wires:
peat-node-a(127.0.0.1:50061) — sender../outbox-abind-mounted read-only at/var/lib/peat/outbox.--attachment-root outbox=...set; no inbox.peat-node-b(127.0.0.1:50062) — receiver../inbox-bbind-mounted read-write at/var/lib/peat/inbox.--attachment-inboxset; the receive-side watcher polls the syncedfile_distributionscollection every 1s and fetches anything targeting B's iroh endpoint.
peer.sh issues ConnectPeer in both directions, which is required
for AllNodes-scoped distributions: A's resolve_targets builds
target_nodes from A.blob_store.known_peers(), which is populated
only by ConnectPeer into A. Without A → B as well, A's
distribution doc carries an empty target_nodes and B correctly
concludes "not for me."
When send.sh is pointed at A, each delivered file lands in B's inbox
mirroring the sender's outbox layout: outbox-a/hello.txt arrives at
inbox-b/hello.txt (and a file under outbox-a/sub/ at inbox-b/sub/),
byte-identical, latest-wins on re-delivery. Apps watching the inbox can still
correlate a delivery back to the sender via GetAttachmentDistribution — the
distribution_id travels in the synced file_distributions doc and the
receive-side log line, not in the on-disk path.
Inbox layout changed in v0.4.8 (#173). Earlier images nested every delivery under
inbox-b/{distribution_id}/{filename}; v0.4.8+ mirrors the sender's outbox path instead. A sender-supplied name that can't be safely resolved (absolute, or containing..) falls back to a flat{distribution_id}.binat the inbox root.
The two-node setup above puts both nodes on one Docker network and peers them
with peer.sh (a runtime ConnectPeer that reads each node's endpoint_id
from GetStatus). That doesn't work when the nodes are on different
machines and you can't reach the remote one to read its output, or when mDNS
is unavailable across subnets.
docker-compose.multi-host.yml solves this
with deterministic identity: a node's iroh endpoint_id is
HKDF-SHA256(shared_key, "iroh:" + node_id), so you compute both nodes' ids
offline, on one machine, before anything boots — then bake them into each
machine's PEAT_NODE_PEERS:
K="<base64 32-byte shared key>"
A_ID=$(peat-node derive-id --shared-key "$K" --node-id node-a)
B_ID=$(peat-node derive-id --shared-key "$K" --node-id node-b)
# Put $B_ID in machine A's PEAT_NODE_PEERS, $A_ID in machine B's. Done.Each machine runs one node with its own .env (node id, shared key, iroh UDP
port, and the peer's derived id @ its IP:port). No peer.sh, no GetStatus
round-trip, no mDNS.
Both machines must list each other (
A_IDin B'sPEAT_NODE_PEERS,B_IDin A's) — and this is mandatory, not symmetry for its own sake. A node'sknown_peersis populated only when it dials out; accepting an inbound connection doesn't register the peer. Attachment delivery readsknown_peerson both ends — the sender's set decides who a distribution targets, the receiver's set is where it fetches the blob. List only one side and the distribution document still syncs (CRDT gossip is transitive) but the file is never written: the "synced but nothing delivered" symptom. One iroh QUIC connection carries the bytes either way, so this is two dials, not two connections. Thepeer statuslog line (connected_peersvsknown_peers, every 30s) shows whether each side actually dialed the other. The requirement for adjacent peers is tracked for removal upstream in peat-node#170.
See the header of docker-compose.multi-host.yml for the
full per-machine .env and the firewall/UDP-publish note, and
docs/CONFIGURATION.md → Deterministic identity
for the full reference.
Deterministic identity +
derive-idrequire peat-node v0.4.4+. To run local changes ahead of a release, use the commentedbuild:block indocker-compose.multi-host.ymlto build from the repo root.
The multi-host nodes above sync CRDT documents. To also deliver files
(PRD-006), give the sender an outbox and the receiver an inbox — the
matching opt-in lines are stubbed in docker-compose.multi-host.yml:
- Sender: set
PEAT_NODE_ATTACHMENT_ROOT=outbox=/var/lib/peat/outboxand mount a host dir read-only at/var/lib/peat/outbox. - Receiver: set
PEAT_NODE_ATTACHMENT_INBOX=/var/lib/peat/inboxand mount a writable host dir at/var/lib/peat/inbox.
Send with SendAttachments (scope allNodes); the receiver's inbox watcher
fetches the blob over iroh and writes it to {inbox}/{relative_path} —
mirroring the sender's outbox layout (v0.4.8+; see the #173
note under "Two-node delivery" above) — byte-identical to the source.
Hands-off (synced-folder) mode. Set PEAT_NODE_ATTACHMENT_OUTBOX_WATCH=true
on the sender (requires v0.4.5+) and you don't call SendAttachments at all:
the sender's outbox watcher auto-distributes any stable new file in its
root, and the receiver's inbox watcher writes it — drop a file in the outbox,
it appears in the peer's inbox. The receive side is already automatic; this adds
the symmetric send side. Both are pure-polling (no inotify), reliable across
container bind mounts.
⚠️ ACOMPLETEDstatus with no connected peers is the vacuous zero-target case — nothing was transferred. ConfirmGetStatus.connectedPeers >= 1before trusting delivery.
This exact flow (outbox → inbox, sha256-validated on the receive side) runs in
CI via test/attachment-delivery-compose.sh
— the regression guard for "passes tests but delivers nothing."