DaoStream: Inspired by Plan 9, Simpler Than 9P
The Plan 9 Dream
In the late 1980s, the creators of Unix at Bell Labs asked themselves: what if we took "everything is a file" seriously? Really seriously?
The result was Plan 9, an operating system where:
- Network connections are files you
read()andwrite() - Process control is writing commands to
/proc/123/ctl - Graphics are drawing commands written to
/dev/draw/data - Even window management is file operations
One API—open(), read(), write(), close()—for everything.
It was beautiful. It was elegant. It almost worked.
The Problem: 9P Had to Pretend Everything Was a File
To make "everything is a file" work over networks, Plan 9 invented the 9P protocol. And 9P is... complicated.
It has 13 message types:
Tversion/Rversion- negotiate protocol versionsTattach/Rattach- attach to a file treeTwalk/Rwalk- navigate directory hierarchies (with multi-hop path resolution!)Topen/Ropen- open filesTread/Rread,Twrite/Rwrite- actual data transferTstat/Rstat,Twstat/Rwstat- get/set file metadataTcreate/Rcreate,Tremove/Rremove- create/delete filesTclunk/Rclunk- close filesTflush/Rflush- cancel pending operations
Why so complex? Because 9P had to maintain the illusion that remote resources were files:
- Files have hierarchical paths → need
Twalkto navigate/net/tcp/clone - Files have permissions and owners → need
Tstat/Twstatfor metadata - Files have handles (fids) → need server-side state management
- Files can be created/deleted → need
Tcreate/Tremove
9P tried to make the network transparent by making everything look like POSIX files. This was both its genius and its burden.
What if We Don't Pretend?
At Datom.world, we're building a distributed system inspired by Plan 9's philosophy, but we asked a different question:
What if we didn't have the burden of files?
What if we embraced what communication actually is—message passing—instead of pretending it's filesystem operations?
Files are wonderful for persistent, randomly-accessible storage. But communication isn't that. Communication is temporal, flowing, directional. It creates dynamic topologies, not static hierarchies. It needs different concerns: handling out-of-order delivery, timestamp-based ordering, subscriber backpressure—not seek positions and fids.
9P's complexity comes from forcing one metaphor (files) onto a different reality (network communication). What if we just admitted what streams actually are?
The result is DaoStream, which takes Plan 9's elegant API (open/read/write/close) but throws away the burden of pretending streams are files.
The Datom: A Self-Contained Packet
In DaoStream, the fundamental unit is a datom—a 5-tuple:
[e a v t m]
e: entity (what this is about)
a: attribute (what property)
v: value (the actual data)
t: timestamp (when)
m: metadata (context)A datom is self-contained. It carries everything needed to understand it:
- What it's about (entity)
- What it means (attribute)
- The actual payload (value)
- When it happened (timestamp)
- Any additional context (metadata)
Unlike a byte stream that needs framing, or a message that needs headers, a datom IS the protocol unit.
The Protocol: Just Route Datoms
Here's the entire DaoStream wire protocol:
┌─────────────────────────────────────────┐
│ Stream ID (8 bytes) │
│ Sequence Number (8 bytes) │
│ Flags (1 byte) │
├─────────────────────────────────────────┤
│ Datom: [e a v t m] │
└─────────────────────────────────────────┘That's it.
- No
Twalkto navigate paths. Stream IDs are flat hashes. - No
Tstatfor metadata. It's in themfield of the datom. - No
Tattachfor authentication. Authentication and encryption happen at the transport layer (WireGuard-style), orthogonal to the datom protocol. - No
Tclunkto manage server state. The protocol is stateless.
The network layer just forwards datoms to streams. Nothing more.
UDP-Based: Ordering is in the Interpreter
DaoStream runs over UDP, not TCP. This is a deliberate choice. For a deep dive into why TCP's semantics contradict Datom.world's axioms, see Why TCP Is Too Semantic for Datom.world.
TCP provides ordered, reliable delivery—but at a cost. TCP maintains connection state, requires handshakes, blocks on packet loss, and ties you to a single network path. This doesn't work for mobile agents, mesh networks, or intermittent connectivity.
UDP is stateless and unreliable—which sounds terrible, until you realize the datom already contains everything needed to handle disorder:
- Timestamp (t) — Every datom has a logical timestamp. Interpreters can sort by
tto establish causal order. - Sequence number — The packet header has a sequence number. Interpreters detect gaps and request retransmission if needed.
- Metadata (m) — Context like
{:reply-to ...}or{:version ...}helps interpreters handle duplicates or reorderings.
Ordering is not a protocol concern. It's an interpreter concern.
Some interpreters need strict ordering (e.g., a database log). They sort by timestamp and buffer out-of-order packets. Other interpreters don't care about order (e.g., sensor readings where latest value wins). They just process whatever arrives.
By pushing ordering to the edges, DaoStream stays simple and works naturally over:
- BLE mesh networks (lossy, connectionless)
- Agent migration (changing IP addresses mid-stream)
- Offline operation (no connection at all, sync later)
- Multi-path routing (packets arrive via different routes)
TCP would force a single, stable connection. UDP lets datoms flow wherever they can reach, and interpreters reconstruct meaning from whatever arrives.
Everything Through Datoms
Want to subscribe to a stream? Send a datom:
[:agent-123 :subscribe [:sensors :temp] (now) {:from 0}]Want to publish data? Send a datom:
[:sensor-42 :temperature 23.5 (now) {:location "room-5"}]Want to discover streams? Send a datom:
[:agent-123 :list [:food :**] (now) {}]Get response datoms back:
[:responder :streams [[:food-trail] ...] (now) {}]Want to unsubscribe? Send a datom:
[:agent-123 :unsubscribe stream-id (now) {}]Control operations are datoms. Data is datoms. Everything is datoms.
Stateless Protocol, Stateful Interpreters
Here's the key insight that lets us avoid 9P's complexity:
State doesn't live in the network protocol. It lives in interpreters at the edges.
The network layer just forwards datoms to streams:
(defn forward-datoms []
(loop []
(let [packet (udp-recv)
stream-id (:stream-id packet)
datom (:datom packet)]
;; Just append the datom to the stream
(append-to-stream stream-id datom))
(recur)))Interpreters open streams, read datoms, and process them:
(ns interpreter.monitor
(:require [daostream.core :as stream]))
(defrecord TemperatureMonitor [alert-threshold])
(defn run [monitor stream-path]
;; Open the stream
(let [s (stream/open stream-path)]
;; Read datoms from the stream
(doseq [datom (stream/read s)]
(let [[e a v t m] datom]
;; Process each datom
(when (= a :temperature)
(when (> v (:alert-threshold monitor))
(log-alert! "High temp" v t)))))))
;; The interpreter reads from the stream
;; The stream is just a flow of datomsThe protocol layer doesn't know about subscriptions, buffers, or replay. Those are interpreter concerns.
This is the end-to-end principle: intelligence at the edges, simple forwarding in the middle.
The Fundamental Difference: Protocol-Defined State vs Interpreter-Defined State
The most important architectural difference between 9P and DaoStream isn't the number of message types or the wire format. It's who defines state transition semantics.
9P: Protocol Defines State Transitions
9P bakes state transition semantics into the protocol. The protocol specifies exactly how servers must maintain state about every client connection:
- File identifiers (fids) — When a client opens
/proc/123/status, the server allocates a fid (file ID) and tracks which client owns it. The fid is just a number; the server must remember what it refers to. - Seek positions — If a client reads 100 bytes, then reads again, the server must remember the offset. The client doesn't tell the server "read from byte 100." It says "read next," and the server maintains cursor state.
- Open file table — The server tracks which files each client has open, their read/write modes, and their access permissions.
- Authentication context — After
Tauth/Tattach, the server remembers who the client is and what they're allowed to access.
The protocol defines expected state transition semantics. Clients send Twalk, Topen, Tread messages expecting the server to maintain fids, offsets, and authentication context. A server could ignore these semantics, but then it breaks client expectations—clients won't work correctly. If the server crashes, all client state is lost. If a client migrates to a different network, it can't resume—it must re-authenticate, re-open files, and re-establish position.
To meet client expectations, 9P servers implement explicit lifecycle management:
Tclunkmessages to close files and free fids- Timeouts to detect dead clients and clean up their state
- Reference counting to prevent resource leaks
Tflushto cancel operations and clean up partial state
The protocol embeds state transition semantics in the message types. Even if you're building a synthetic filesystem with different semantics, clients sending 9P messages expect fid-based state management, offset tracking, and lifecycle operations. You cannot choose different state transition semantics without breaking compatibility with 9P clients.
DaoStream: No Protocol-Level State Semantics
DaoStream takes a radically different approach: the protocol has no state transition semantics. It just forwards datoms to streams. Interpreters at the edges define their own state models.
Here's how it works:
The network layer just forwards datoms to streams:
;; The network has NO state about clients
(defn forward-datom [packet]
(let [stream-id (:stream-id packet)
datom (:datom packet)]
;; Just append to the stream
(append-to-stream stream-id datom)))
;; No fids. No authentication context. No seek positions.
;; Just: "Here's a datom for stream X. Forward it."The network doesn't know or care who sent the datom, whether there are subscribers, or what position they're at. It just forwards datoms to streams.
The interpreter opens a stream and processes datoms:
(ns interpreter.database
(:require [daostream.core :as stream]
[datomic.api :as d]))
(defrecord DatabaseLogger [conn])
(defn run [logger stream-path]
(let [s (stream/open stream-path)]
;; Read datoms from the stream
(doseq [datom (stream/read s)]
(let [[entity attr value time metadata] datom]
;; Interpreter chooses how to handle each datom
(case attr
:temperature
(d/transact! (:conn logger)
[{:db/id entity
:sensor/temperature value
:sensor/timestamp time
:sensor/location (:location metadata)}])
:pressure
(d/transact! (:conn logger)
[{:db/id entity
:sensor/pressure value
:sensor/timestamp time}]))))))
;; State lives HERE, in the interpreter, not in the protocol
;; The interpreter opens, reads, and processesThis is a profound difference:
- No protocol-mandated state transitions — The protocol doesn't say "you must track subscribers" or "you must maintain positions." The interpreter chooses to track these because it's useful for this use case. A different interpreter might not.
- State model is interpreter-specific — When a client sends
[:client :subscribe stream-id (now) {:from 1000}], the{:from 1000}is just metadata. The interpreter decides what it means. One interpreter might replay from position 1000. Another might ignore it and send only new datoms. The protocol doesn't care. - No protocol-level handles — There are no fids that the protocol tracks. Stream IDs are content-addressed hashes, but they're just routing keys. The network layer doesn't maintain any association between clients and streams.
- Cleanup is interpreter concern — There's no
Tclunkequivalent. If an interpreter wants to time out inactive subscriptions, it can. If another interpreter wants to keep them forever, it can. The protocol doesn't mandate lifecycle management.
Why This Matters
9P's protocol-defined state creates fundamental constraints:
- Single state model — Every 9P server must implement the same state semantics: fids, offsets, authentication. You can't choose a different model better suited to your use case.
- Protocol complexity bleeds everywhere — Because the protocol mandates state transitions, every implementation must handle
Tclunk,Tflush, timeouts, reference counting. This complexity is unavoidable. - Hard to evolve — Want different semantics? You need a new protocol version or extension. The state machine is baked into the wire format.
- Centralization pressure — Because the protocol defines client-server state relationships, it naturally leads to centralized servers. Peer-to-peer or mesh topologies are awkward.
DaoStream's interpreter-defined state provides freedom:
- Multiple state models coexist — One interpreter tracks subscribers and replays history. Another is stateless and just forwards. A third implements CRDT merge semantics. They all use the same protocol.
- Protocol stays simple — No
Tclunk, noTflush, no mandatory lifecycle. Just: forward datoms to streams. Interpreters handle complexity appropriate to their needs. - Easy to evolve — Want new semantics? Write a new interpreter. The protocol doesn't change. Old interpreters keep working.
- Natural distribution — Because the protocol doesn't assume client-server state, peer-to-peer, mesh networks, and mobile agents work naturally. Interpreters can run anywhere.
Why This Works Better
9P's Challenge
Beyond the stateful protocol burden, 9P had to emulate POSIX semantics:
- Hierarchical paths → complex
Twalkwith multiple hops - File handles (fids) → server must track open files per client
- Seek positions → server maintains offset state
- Permissions → authentication and authorization in protocol
- Cleanup → explicit
Tclunk, timeout handling
All this state management made 9P servers complex and added failure modes.
DaoStream's Simplicity
DaoStream datoms are self-contained:
- Stream addressing → flat hash, simple lookup
- No handles → datoms flow, no server-side tracking needed
- Position in metadata → client says
{:from 1000}, interpreter handles it - Auth/encryption at transport → WireGuard-style security layer, orthogonal to datom protocol, no protocol overhead
- Cleanup → stateless, no explicit teardown needed
The protocol stays simple. Complexity moves to interpreters, where it can be:
- Pluggable (different interpreters for different needs)
- Testable (no network state to mock)
- Evolvable (update interpreters without changing protocol)
Reliability, Ordering, and Persistence as Libraries
Here's where DaoStream's approach becomes truly elegant: reliability, ordering, and persistence are not protocol concerns—they're composable libraries. This is only possible because DaoStream runs over UDP, not TCP. For why TCP's baked-in semantics would prevent this design, see Why TCP Is Too Semantic for Datom.world.
In TCP, reliability is baked into the protocol. You can't opt out. In 9P, ordering and fid management are protocol requirements. Every implementation must handle them.
DaoStream pushes these concerns outside the protocol and into libraries that interpreters can choose to use. And because these libraries themselves expose the stream interface, they compose naturally:
(ns app.reliable-sensor
(:require [daostream.core :as stream]
[daostream.reliable :as reliable]
[daostream.ordered :as ordered]
[daostream.persistent :as persistent]))
;; Open the raw stream
(def raw-stream (stream/open [:sensors :temperature]))
;; Wrap it with reliability (handles retransmission)
(def reliable-stream (reliable/wrap raw-stream {:ack-timeout 500}))
;; Wrap with ordering (buffers out-of-order packets)
(def ordered-stream (ordered/wrap reliable-stream {:buffer-size 100}))
;; Wrap with persistence (writes to disk)
(def persistent-stream (persistent/wrap ordered-stream {:path "/data/sensors"}))
;; Now read from the composed stream
(doseq [datom (stream/read persistent-stream)]
(process-datom datom))Each library is just a stream transformer: it takes a stream, adds behavior, and returns a stream. The application code doesn't change—it's still open/read/write/close.
The key insight: these are optional and composable:
- Sensor readings might skip reliability entirely—latest value wins, dropped packets don't matter
- Chat messages might use reliability + ordering, but skip persistence—messages are ephemeral
- Database logs might use all three—reliability, ordering, and persistence are critical
- Video streams might use ordered delivery but not reliability—better to skip a frame than wait for retransmission
Because these are libraries, not protocol features, you can mix and match. You can write your own reliability layer with different semantics (e.g., forward error correction instead of retransmission). You can implement CRDT-based eventual consistency instead of ordered delivery. The protocol doesn't care—it just forwards datoms.
This is fundamentally different from TCP or 9P, where protocol features are mandatory. In DaoStream, every interpreter chooses the guarantees it needs, and composes libraries to get them. The protocol stays simple. The power comes from composition at the edges.
The Beautiful Parallel
| Aspect | Plan 9 | DaoStream |
|---|---|---|
| Philosophy | Everything is a file | Everything is a stream |
| API | open/read/write/close | open/read/write/close |
| Protocol | 9P (13 message types) | Datom streaming (1 packet type) |
| State Semantics | Protocol-defined (must implement fids, offsets) | Interpreter-defined (protocol agnostic) |
| Navigation | Hierarchical (Twalk) | Flat (hash addressing) |
| Metadata | In protocol (Tstat) | In datom (m field) |
| Auth/Encryption | In protocol (Tauth) | Transport layer (WireGuard-style), orthogonal to protocol |
| Mobility | Mount remote filesystems | Stream names as values (π-calculus) |
We kept the elegant API, but we didn't force network communication to look like filesystem operations.
Real-World Example: Stigmergy
In our ant-inspired stigmergy system, ants coordinate by modifying their environment. Here's how it works with datoms:
Ant discovers food:
(ns stigmergy.ant
(:require [daostream.core :as stream]))
(defn ant-discovers-food [ant-id location]
;; Open the pheromone trail stream
(let [s (stream/open [:food-trail])]
;; Write pheromone datoms to the stream
(stream/write s
[[ant-id :pheromone 0.9 (now)
{:location location
:food-direction :north}]])
(stream/close s)))Other ants sense and follow:
(defn ant-listen-for-discoveries [self-id]
;; Open the food trail stream
(let [s (stream/open [:food-trail])]
;; Read datoms from the stream
(doseq [datom (stream/read s)]
(let [[e a v t m] datom]
(when (= a :pheromone)
;; Found a pheromone!
(when (> v 0.5) ;; strength threshold
(move-toward (:location m))
;; Leave our own pheromone
(let [my-stream (stream/open [:food-trail])]
(stream/write my-stream
[[self-id :pheromone (* v 0.9) (now)
{:location (current-location)
:food-direction (:food-direction m)}]])
(stream/close my-stream))))))))This is π-calculus mobility: channels (streams) as first-class values that can be passed in messages. It's what makes dynamic topology possible.
This is π-calculus in action: the very topology of communication evolves at runtime. Agents don't need to know all possible streams at start time—they discover streams dynamically through communication itself. When an ant finds food, it doesn't just send data about the food—it implicitly shares the [:food-trail] stream by writing to it, and other ants discover this stream by observing pheromone datoms. The network topology adapts organically as agents learn about new communication channels from the messages themselves.
Try doing this cleanly in 9P. It's awkward—9P wasn't designed for mobile channel references.
A More Complete Example
Here's how you might use DaoStream's API in Clojure:
(ns datom.examples
(:require [daostream.core :as stream]))
;; Simple publisher
(defn temperature-sensor []
(with-open [h (stream/open [:sensors :temperature]
:write
{:create true})]
(loop [temp 20.0]
(stream/write h [[:sensor-1 :temperature temp (now)
{:unit "celsius" :location "lab"}]])
(Thread/sleep 1000)
(recur (+ temp (- (rand) 0.5))))))
;; Simple subscriber
(defn temperature-monitor []
(with-open [h (stream/open [:sensors :temperature]
:read
{:from 0 :subscribe true})]
(doseq [datom (stream/seq h)]
(let [[e a v t m] datom]
(println "Temperature:" v "°C at" (:location m))
(when (> v 30.0)
(alert! "High temperature!"))))))
;; Discovery and dynamic subscription
(defn sensor-aggregator []
;; Find all sensor streams
(let [sensor-streams (stream/list [:sensors :**])]
(println "Found sensors:" sensor-streams)
;; Subscribe to each one
(doseq [stream-path sensor-streams]
(future
(with-open [h (stream/open stream-path :read {:subscribe true})]
(doseq [datom (stream/seq h)]
(store-to-database! datom)))))))
;; Stream name passing (π-calculus mobility)
(defn share-stream [announcer-id private-stream]
;; Open announcement channel
(with-open [h (stream/open [:announcements] :write {})]
;; Send stream name as VALUE
(stream/write h [[announcer-id :new-stream private-stream (now)
{:description "Private sensor data"}]])))
(defn listen-for-announcements [listener-id]
(with-open [h (stream/open [:announcements] :read {:subscribe true})]
(doseq [[e a v t m] (stream/seq h)]
(when (= a :new-stream)
(println "Discovered new stream:" v)
;; Now subscribe to the stream we just learned about!
(future
(with-open [h2 (stream/open v :read {:subscribe true})]
(doseq [datom (stream/seq h2)]
(process-private-data datom))))))))
;; Pheromone interpreter
(defn pheromone-interpreter []
(let [subscribers (atom #{})
pheromone-map (atom {})]
(reify StreamInterpreter
(process [_ datom]
(let [[e a v t m] datom]
(case a
:subscribe
(do
(swap! subscribers conj e)
(doseq [[location strength] @pheromone-map]
(send-to e [location :pheromone strength (now) {}])))
:pheromone
(let [location (:location m)]
(swap! pheromone-map assoc location v)
(future
(Thread/sleep 5000)
(swap! pheromone-map update location * 0.8))
(doseq [sub @subscribers]
(send-to sub datom)))
:unsubscribe
(swap! subscribers disj e)))))))When Simplicity Wins
We're building DaoStream for:
- BLE mesh networks (intermittent connectivity, low power)
- Agent migration (processes moving between devices)
- Distributed stigmergy (emergent coordination through environment)
- Local-first systems (works offline, syncs when online)
In these environments, 9P's complexity would be a liability:
- Connection state doesn't survive device migration
- Hierarchical paths are awkward for flat hash addressing in mesh networks
- Authentication handshakes waste precious BLE radio time
- Complex cleanup is fragile when nodes crash or disconnect
DaoStream's stateless, datom-based approach is robust by simplicity.
The Lesson
Plan 9 taught us that universal interfaces are powerful. One API for everything is beautiful.
But Plan 9 also taught us that forcing everything into one metaphor has costs. 9P's complexity comes from pretending network operations are filesystem operations.
DaoStream takes Plan 9's lesson and asks: what if we just admit what communication actually is?
- Communication is message passing, not file I/O
- Communication creates dynamic topologies, not static trees
- Communication is temporal (datoms have timestamps), not just spatial
- Communication needs different QoS levels, not one-size-fits-all
By embracing these truths instead of hiding them, we get a protocol that's:
- Simpler than 9P (one packet type vs. 13 messages)
- More capable than 9P (π-calculus mobility built-in)
- More robust than 9P (stateless, survives disconnection)
Everything is a Stream—Interpretation at the Edges
Files are persistent, randomly accessible, hierarchically organized resources.
Streams are temporal, forward-progressing, dynamically discovered channels.
They're different—and that's okay.
But here's the beauty of Datom.World: streams can be interpreted as files if you need them to be. The protocol doesn't force file semantics, but it doesn't forbid them either. An interpreter can implement seek, hierarchical navigation, and random access—all on top of the simple datom stream.
This is the power of interpretation pushed to the edges. 9P bakes file semantics into the protocol, making it complex for everyone. DaoStream keeps the protocol simple—just forwarding datoms—and lets interpreters add file-like behavior only where it makes sense.
Want POSIX-like file operations? Write an interpreter that maintains offsets and implements seek. Want a time-series database? Write an interpreter that materializes aggregates in real-time—windowed averages, percentiles, rates of change. Want stigmergic coordination? Write an interpreter that tracks pheromone trails and emergent patterns. The protocol stays simple. The semantics emerge from interpretation.
Plan 9 showed us the power of universal interfaces.
DaoStream shows us we don't need to bake semantics into protocols to achieve them.