"Record Ownership: Which Side Is Right?"
A record produced on a microcontroller and consumed in a browser is still the same record. Same SchemaType. Same key. Same contract.
That is exactly why a new question appears the moment you cross runtimes: when the same record exists on both sides of a connector, which side is right?
Two Copies of One Record
Inside one process, this question barely exists. One buffer, one writer, one authoritative view.
Cross a connector and now there are two copies of the same record. The producer-side buffer holds what was published. The consumer-side buffer holds what was received. Those copies can drift by milliseconds or by minutes after a network interruption. In systems that allow both sides to write the same key, they can disagree.
The default reaction is to force convergence in the runtime: vector clocks, CRDTs, quorum reads, conflict-free machinery everywhere. AimDB takes the opposite approach: avoid creating that conflict in the first place. You declare record ownership in configuration and the runtime enforces it. Mixing .source() and .link_from() on the same key is a startup panic, before the database runs. The buffer policy is unchanged on each side. End-to-end consistency depends on transport behavior.
The Buffer Tells You What Kind of Conflict You Can Have
When you picked a buffer for your record, you encoded what "consistent" means for it. Connectors don't change those semantics, they extend them across the wire. So the conflict you can have is exactly the conflict the buffer permits.
| Buffer | What's shared across the seam | What conflict looks like |
|---|---|---|
| SPMC Ring | Nothing. Each side has its own bounded history | No shared authoritative history. During network gaps, copies diverge and reconcile according to transport behavior |
| SingleLatest | The current value | If multiple producers are allowed upstream, later updates overwrite earlier ones |
| Mailbox | The pending instruction | If multiple senders target the same stream, contradictory commands can race |
Ownership Is a Property of the Key
Here is AimDB's answer, plain:
Every record key has exactly one authoritative producer. Everyone else is a subscriber.
link_to declares "I produce this here." link_from declares "I observe this from elsewhere." On any single node, combining a .source() with a .link_from() on the same key is a hard error, the runtime panics at startup before the database ever runs. Ownership is enforced, not just documented.
// Sensor node — owner of the reading builder.configure::<Temperature>(ClimateKey::Reading, |reg| { reg.buffer(BufferCfg::SpmcRing { capacity: 256 }) .source(read_temperature_loop) .link_to("mqtt://climate/zone-a/reading") // I produce this .with_serializer_raw(Temperature::to_bytes) .finish(); });
// Browser dashboard — observer of the same reading builder.configure::<Temperature>(ClimateKey::Reading, |reg| { reg.buffer(BufferCfg::SingleLatest) .tap(render_chart) .link_from("mqtt://climate/zone-a/reading") // I observe what's produced elsewhere .with_deserializer_raw(Temperature::from_bytes) .finish(); });
Same key. Same record type. Different node. Different buffer choice on each side, because each side wants a different view of the same fact. The owner streams, the observer holds the latest. There is no question of whose value is right, because only one side claims to produce it.
This pushes the question back where it belongs: into design. Who produces this record? You answer it once when you wire the system. The runtime preserves the answer, it doesn't reinvent it.
What Travels Across the Wire
That depends entirely on the connector.
AimDB does not mandate transport semantics. The record contract is fixed, what the connector adds on top is its own. A WebSocket connector can attach server-side timestamps, an MQTT connector carries whatever you put in the payload, a KNX connector is wire-compact and treats the bus as the timebase.
The design is intentional. If your domain requires real-time guarantees, you implement a DDS connector. The buffer semantics stay the same, the transport changes. The staleness, reliability and ordering characteristics live in the connector, not in the record.
When You Actually Need Multi-Writer Convergence
Sometimes ownership-by-key isn't enough. Two operators each adjust a setpoint. Two services both publish a feature flag. Two browsers edit the same configuration. You want the system to converge, not pick a winner.
AimDB does not ship CRDTs and won't pretend to. That's a deliberate non-goal: the runtime is small, embedded-friendly and predictable. Merging logic belongs in your domain, not inside a buffer.
The pattern that works without CRDTs is to model the conflict as two records:
// Many writers — operator A, operator B, an autotuner — each own // their own request stream by carrying a distinct origin in the key. #[derive(RecordKey, Clone, Copy, PartialEq, Eq, Debug)] enum SetpointKey { #[key = "request/operator-a"] RequestOperatorA, #[key = "request/operator-b"] RequestOperatorB, #[key = "request/autotuner"] RequestAutotuner, #[key = "applied"] Applied, }
// One arbiter consumes all of them and emits a single applied record. builder.configure::<SetpointApplied>(SetpointKey::Applied, |reg| { reg.buffer(BufferCfg::SingleLatest) .tap(apply_setpoint) .transform_join(|b| { b.input::<SetpointRequest>(SetpointKey::RequestOperatorA) .input::<SetpointRequest>(SetpointKey::RequestOperatorB) .input::<SetpointRequest>(SetpointKey::RequestAutotuner) .on_triggers(|mut rx, producer| async move { while let Ok(trigger) = rx.recv().await { if let Some(req) = trigger.as_input::<SetpointRequest>() { if let Some(applied) = arbitrate(req) { let _ = producer.produce(applied).await; } } } }) }) .finish(); });
Each writer owns its own request stream. The arbiter consumes all of them and emits the applied result. The graph stays a DAG. Ownership stays singular per key. The conflict becomes a transform, not a merge inside a buffer.
The shape is familiar: a state machine, a queue or a worker. What changes is that expressing it as records with a transform makes the arbitration visible, observable and subject to the same backpressure as every other node in the graph.
The Decision Belongs in the Wiring
Connectors don't decide who owns a record. The transport doesn't decide. The runtime doesn't pick a winner when both sides write. You decide, once, at the key. The buffer choice tells you what kind of disagreement is even possible. The topology tells the runtime which side is right.
The work is up front, in the wiring, where you can see it. Not somewhere down the line in a conflict-resolution loop running per record.
In the next post, we answer the second question the connectors post raised: when a connector drives an output, what does the rest of the graph owe it?
Get Involved
AimDB is open source, Apache 2.0 and growing. Single-writer-per-key is enforced at startup; transport semantics are connector-defined by design.
Star AimDB on GitHub · Live demo · Docs
Graphics and spell checks in this post were created with the help of an LLM.