Implementing a persistent store
Every DeRec SDK requires the application to supply five storage adapters and one outbound transport. This chapter is the schema-and-query level walkthrough of the five store adapters at the data-model layer — partition keys, conceptual storage shape, queries the library makes, and idempotency contracts. It is language-agnostic on purpose: the per-language wiring lives in the SDK chapters, and the shape documented here is the same across all of them.
Read first: the Rust, .Net, TypeScript, React Native, and Go SDK chapters for the language-specific wiring, and the Concepts chapters for protocol context. Two reference implementations ship in the lib-derec repository — SQLite under smoke-tests/sqlite/ and Postgres under smoke-tests/postgres/. Both implement all five stores durably over the same schema shape, both are intentionally small (a few hundred lines each), and either can be copied wholesale as a starting point for a new backend.
The five stores
The library splits persistence along five orthogonal axes: paired channels, the shares each channel holds, per-channel cryptographic secrets, the application-supplied Secret snapshot, and the orchestrator's in-flight protocol state. Each store is partition-keyed by secret_id so a single backing database can serve many Secrets on the same device without leakage — the library never queries across secret_ids.
The trait method names below are quoted verbatim from library/src/protocol/traits.rs; signatures are simplified to focus on the parameters and the return value. Each Rust method actually returns a typed future — SecretStoreFuture, ChannelStoreFuture, ShareStoreFuture, or StateStoreFuture — wrapping the value shown. The bindings differ in shape, not in contract: see Binding shapes.
DeRecChannelStore
Partition key —
secret_id(u64).Conceptual shape — a
ChannelRecord: an enum over two shapes that are stored and keyed differently.
ChannelRecord::Helper
HelperChannel — one Owner↔Helper channel
(secret_id, channel_id)
ChannelRecord::Replica
ReplicaMember — one member of the replica group
(secret_id, replica_id)
Both inner records carry transports: every endpoint the peer advertised, in the order it offered them. The library never ranks the list; it hands it to DeRecTransport::send. A recorded channel always has at least one endpoint.
A replica member is keyed by replica_id alone. This is the single most likely implementation error in the whole store surface.
Every member of a replica group shares one channel_id, so the channel cannot be the key — a channel-keyed table collides at the second member. And a member moves between channels during an admission handover while remaining the same member, so a lookup requiring both channel_id and replica_id to match misses it exactly when the move needs to be observed.
Keep two tables — helpers by channel_id, members by replica_id — not one keyed by the pair. On the member table, channel_id is an ordinary column, not part of the key.
Persist the serialized record verbatim. Where a store handles channel records as bytes — the TypeScript and React Native ChannelStore, or a Rust store that serializes with the serde feature, as the SQLite reference does — the record is a blob whose shape the library owns, leading with "schema_version": 3 (CHANNEL_RECORD_SCHEMA_VERSION). The library migrates old shapes itself on load:
A record without
schema_versionreads as version 0 and is upgraded.A legacy single
transportfield is lifted into a one-elementtransportslist.A record naming no endpoint at all — no
transport, andtransportsabsent or empty — is a load error, not an unreachable channel.A record with a
schema_versionnewer than the build understands is refused rather than read and written back truncated.
The .NET and Go IChannelStore / ChannelStore receive typed ChannelRecord values; the bridge does the encoding.
The store also owns the channel-link graph: an undirected, idempotent, transitive relation recording that two channels belong to the same Owner identity. It exists for one situation — an Owner that lost its state re-pairs with a Helper on a fresh channel, and the Helper must still find the shares it stored under the old one. The library never writes links; the helper-side application recognises a returning Owner (an out-of-band judgement the protocol cannot make) and records the link. The library only reads the graph, in two helper-side responders: answering GetSecretIdsVersions and answering GetShare.
Replica groups do not use it. Members share one channel_id and are identified by replica_id, so there is nothing to link; a catch-up resolves peers through the roster instead.
Required operations:
load(secret_id, query: ChannelQuery) -> Option<ChannelRecord>—ChannelQueryisHelper { channel_id }orReplica { channel_id, replica_id }save(secret_id, record: ChannelRecord) -> ()— the key is derived from the record, which is what stops a record being stored under the wrong oneremove(secret_id, query: ChannelQuery) -> bool—trueiff an entry was removed. Removing oneChannelQuery::Replicaremoves that member only; the group channel and every other member survivehelpers(secret_id, filter: HelperFilter) -> Vec<HelperChannel>— the helper channels in the partition thatfilterselects; see Channel filtersreplicas(secret_id, filter: ReplicaFilter) -> Vec<ReplicaMember>— the group members thatfilterselects, including this device's own row unless the filter excludes itlink_channel(secret_id, a, b) -> ()— record an undirected, idempotent, transitive edgelinked_channels(secret_id, channel_id) -> Vec<ChannelId>— the connected component, includingchannel_iditself
replicas must include this device's own row unless the filter excludes it. That is what makes the roster reconstructible from storage alone. A store that drops the local member on its own initiative breaks recovery of the group's own composition.
replicas ordering is your succession policy
The returned order is insignificant with exactly one exception, and that exception is a real design decision disguised as a query detail.
A replica group has exactly one member holding ReplicaRole::Source. When that member is removed, a successor must be chosen, and the protocol takes the first element returned for a filter that already excludes the departing member and admits only members not themselves leaving. Implementing replicas is therefore how an application chooses its own succession policy — order by an added_at column, by a user-chosen preference, by whatever a backend knows — without the protocol having to model one.
The choice is read once, on the single device that runs the removal, and is then published in the roster as the member's role. Every other member reads the result rather than repeating the decision, so implementations on different devices need not agree on order.
Returning an arbitrary order is correct — it simply delegates the choice to whatever the storage yields. Note that this is the default for the usual backings: hash-map iteration is unspecified and varies between runs, and so is a SQL SELECT with no ORDER BY. Add an explicit ordering to make succession predictable. The SQLite reference orders by replica_id.
A source that is the group's only member leaves no successor, and the group dissolves with it. See Replica removal.
Concrete SQLite schema (from smoke-tests/sqlite/migrations/0001_init.sql):
channel_links is the undirected adjacency list backing link_channel / linked_channels. The status, peer_role and role columns exist only so the listings can push the channel filter into a WHERE clause. Idempotency: re-saving a record replaces the row at its key; re-linking an already-linked pair is a no-op.
Channel filters
helpers and replicas take a filter, crate::protocol::types::ChannelFilter { ids, status, role, exclude }, instantiated twice:
Alias
Listing
ids are
role is
HelperFilter
helpers
HelperChannel::channel_id
the peer's HelperChannel::peer_role (SenderKind)
ReplicaFilter
replicas
ReplicaMember::replica_id
ReplicaMember::role (ReplicaRole)
Every field is a restriction, and every field's empty value means "do not restrict on this": an empty
ids,statusorexcludelist, and aroleofNone/null.Restrictions combine with AND.
excludeis applied last and overridesids: an id in both is omitted.
The unrestricted filter is Default::default() in Rust, HelperFilter.Any / ReplicaFilter.Any in .NET, the zero struct in Go (Role is a pointer, so nil means any role), and { ids: [], status: [], role: null, exclude: [] } in TypeScript and React Native.
Returning every record under secret_id and ignoring the filter is correct, and the implementation to write unless there is a measured reason not to. The library re-applies the filter to whatever a listing returns and drops what it excludes before anything acts on it.
Pushing the filter into the query — a WHERE clause, a key-condition expression — is an optimization. It saves transferring rows the caller discards, which matters on a metered backing that bills by bytes read. It also re-expresses the filter's semantics by hand, and the two ways of getting that wrong are not symmetric.
Returning too few rows is silently wrong. The library's re-check can drop rows, so returning too many costs only the transfer. It cannot recover a row that was never returned. A pushdown that under-selects raises no error, emits no event and logs nothing: the protocol simply fails to act — a share is never published, a departure is never reconciled. A classic case is translating an empty ids list into IN (), which selects nothing.
A store that pushes the filter down should be tested against library/tests/fixtures/channel_filter.json, a table of (records, filter, expected) cases covering empty-means-unrestricted per field, role as an optional, exclude applied after ids, and ids above 2^53. Every binding's own test suite drives it.
A store that cannot express the filter as a query can list and apply the same predicate the library re-checks with, in memory:
Rust
ChannelFilter::matches(&id, status, &role)
.NET
HelperFilter.Matches(ulong id, ChannelStatus status, SenderKind role) / ReplicaFilter.Matches(...)
Go
HelperFilter.Matches(channelID, status, role) / ReplicaFilter.Matches(replicaID, status, role)
TypeScript / React Native
channelFilterMatches(filter, id, status, role)
DeRecShareStore
Partition key —
secret_id.Conceptual storage key —
(secret_id, channel_id, version). Exactly one share exists per key: a version has exactly one writer.
Each entry is opaque protobuf bytes — the byte format depends on which side stored it (Owner-side stores a CommittedDeRecShare; Helper-side stores the full StoreShareRequestMessage). The store itself never decodes.
The secret_id argument is the partition, not share.secret_id. Every load* filters on the argument: it is this device's own namespace. Share::secret_id names the secret the bytes belong to, which on a Helper is the Owner's id and routinely differs from the partition. One partition can therefore hold several secrets at the same version, and share.secret_id is the only thing that tells them apart.
Key rows on the argument and carry share.secret_id alongside as data. Keying on share.secret_id puts every write where no read looks. Asserting that the two are equal is just as wrong: it fires on the ordinary case of a Helper holding someone else's share.
Required operations:
load(secret_id, channel_id, versions: &[u32]) -> Vec<Share>— emptyversionsslice means "all versions"; missing versions are silently skippedload_many(secret_id, channel_ids: &[ChannelId], versions: &[u32]) -> Vec<Share>— same shape across multiple channels in one round-trip; used by broadcast queries and by recovery, over the set returned bylinked_channelsload_all(secret_id, channel_ids: &[ChannelId]) -> Vec<Share>— every share for every listed channel and every version; used by Discoverylatest_version(secret_id) -> Option<u32>— highest version across all channels in the partition, orNoneif emptysave(secret_id, channel_id, share) -> ()remove_channel(secret_id, channel_id) -> ()— drop every share under the channel; idempotentremove_versions(secret_id, channel_id, versions: &[u32]) -> ()— drop the shares under(secret_id, channel_id)at each listed version. Idempotent: a version that is not stored is skipped, and an empty slice is a no-op. Never touch other channels or other partitions. A Helper calls it to apply the Owner'skeepListkeep_list(secret_id, version) -> Option<Vec<u32>>— Owner only: the versions every Helper keeps afterversionis distributed. See Owner keep list
Concrete SQLite schema (from smoke-tests/sqlite/migrations/0001_init.sql):
secret_id is the partition argument and leads the key. The share_secret_id column carries Share::secret_id as data; it is not part of the key and is not expected to match secret_id.
load_many return order is unspecified — never select by position. An implementation backed by a database is free to return rows in whatever order its query plan produces, and the library makes no ordering guarantee on your behalf. Callers must pick the share they want by inspecting the Share — its secret_id and version — rather than by index.
This is a contract on the reader, not a constraint on your storage: you do not need to add an ORDER BY. What you must not do is write a caller, or a test, that assumes load_many(...)[0] is the share it asked for. A store that happens to return insertion order today will not after an index change.
The Postgres binding (smoke-tests/postgres/migrations/0001_init.sql) expresses the identical key as CONSTRAINT shares_uniq UNIQUE (secret_id, channel_id, version) with BIGINT / BYTEA column types.
For how concurrent writers are resolved at the protocol level, see Replica conflict resolution and VERSION_CONFLICT.
Owner keep list
Every StoreShareRequestMessage carries a keepList: the complete set of versions the Helper keeps. A Helper deletes every stored version not on it, through remove_versions, once the incoming share is stored. An empty keepList deletes nothing. The Helper never deletes the version the request itself carries, and ignores the keepList of a request older than the latest version it holds on that channel, so a replayed or delayed request cannot delete newer shares. See Version retention.
The Owner builds the list by calling keep_list(secret_id, version) on its share store, where version is the version about to be distributed:
It is called once per sharing round, before anything is sent, on every round that distributes shares to Helpers: an application-started
ProtectSecretand the library-started publishes (pair completion and fingerprint confirmation) alike. Every Helper in the round receives the same list.Some(list): Helpers keep exactlylistplusversion. The library always addsversion, removes duplicates and sorts ascending, solistmay omit it.Some([])keeps only the version being distributed.None: an emptykeepListis sent, and Helpers keep every version they hold. An application that wants to cap how many versions Helpers retain returns that cap as a list.Replica pushes never consult
keep_listand always send an emptykeepList.
Only the application knows which versions committed: it learns that from SharingComplete { threshold_met } and ShareConfirmed, and nothing in the stores records it. The rule for a list is to keep every version that could still become the latest:
List every version that committed, such as those whose
SharingCompletereportedthreshold_met. The version being sent has not committed whenkeep_listis called, and its round may never reach threshold.List every version whose round is still open. If the Owner sends v5 while v4's round is open and leaves v4 out, Helpers delete v4's share. If v5 then fails and v4 commits, the version the Owner considers latest is gone from those Helpers.
Leave out only versions whose round failed, or that the user rolled back.
A replica member that became the source cannot tell which of the versions it mirrored committed on the previous source. Listing every mirrored version it holds is safe: at worst Helpers keep a version that was rolled back.
Over-pruning can make the secret unrecoverable. Helpers delete every version the list leaves out. A list that drops a committed version, or one whose round is still open, can leave no Helper quorum holding the version the Owner treats as current. When in doubt, return None: Helpers then keep everything, and the only cost is storage.
Binding
remove_versions
keep_list
Rust
remove_versions(secret_id, channel_id, &[u32])
keep_list(secret_id, version) -> Option<Vec<u32>>
.NET
void RemoveVersions(ulong secretId, ulong channelId, uint[] versions)
uint[]? KeepList(ulong secretId, uint version) — null for none
Go
RemoveVersions(secretID, channelID uint64, versions []uint32) error
KeepList(secretID uint64, version uint32) (versions []uint32, ok bool, err error) — ok=false for none
TypeScript / React Native
removeVersions(secretId, channelId, versions: number[]): Promise<void>
keepList(secretId, version): Promise<number[] | null | undefined> — null / undefined for none
The SQLite reference returns None from keep_list, so its Helpers keep every version.
DeRecSecretStore
Partition key —
secret_id.Sub-key —
(channel_id, kind)wherekindisSecretKind::{SharedKey, PairingSecret, PairingContact}.Conceptual shape — per-channel cryptographic material.
SharedKeyis the post-pairing symmetric channel key (sensitive — implementations should persist with keychain-grade protection).PairingSecretis the ephemeral ECIES / ML-KEM key material used during pairing, removed once the shared key is derived; the ML-KEM-1024 decapsulation key in it is stored as its 64-byte seed.PairingContactis theContactMessagethatcreate_contactstores inNoKeysmode, where no key material exists yet; it is removed once pairing completes.
The library decides which kind to store; the application implements blind CRUD.
Required operations:
load(secret_id, channel_id, kind) -> Option<SecretValue>load_many(secret_id, channel_ids: &[ChannelId], kind, missing_policy: MissingPolicy) -> Vec<(ChannelId, SecretValue)>— used by broadcast paths;missing_policyis eitherSkip(silently drop missing channels) orFail(returnMissingEntriescarrying the unmatched channel ids). The Rust trait only: the .NETISecretStore.LoadMany, GoSecretStore.LoadManyand TypeScript / React NativeloadMany(secretId, channelIds, kind)take no policy. They must return exactly one entry per requested channel, in request order, withnull(nilin Go) where nothing is stored. The library decides whether a missing entry is an error, and refuses an answer of the wrong lengthsave(secret_id, channel_id, value: SecretValue) -> ()—kindis inferred from theSecretValuevariantremove(secret_id, channel_id, kind) -> ()— idempotent
Concrete SQLite schema:
VSS guarantee: individual Verifiable Secret Sharing shares reveal zero information about the original secret, so share storage does not require keychain-grade protection — only this store does.
Expanded ML-KEM keys are deprecated. A PairingSecret stored by a release before 0.0.8 holds the 3168-byte expanded decapsulation key instead of the 64-byte seed. It is still accepted, with a warning under the logging feature, until 0.0.13. A store needs no migration: the value is opaque, and pairing secrets are removed once their pairing completes. See Deprecations.
DeRecUserSecretStore
Partition key —
secret_id.Conceptual shape — at most one entry per
secret_id— the latest application-supplied Secret snapshot. The library reads this so the pair-completion auto-publish hook can ship the current Secret to a freshly-paired Helper or Replica Destination without an explicit re-publish from the app.
Required operations:
load_latest(secret_id) -> Option<UserSecrets>save_latest(secret_id, value: UserSecrets) -> ()— overwrites any prior entry; the store keeps only the latest snapshotremove(secret_id) -> ()— idempotent
Concrete SQLite schema:
The version column mirrors UserSecrets::version — the monotonically increasing Secret version the protocol bumps on every publish. description is the optional human-readable label forwarded to helpers in StoreShareRequest.description. payload is the encoded UserSecrets::secrets blob.
author_replica_id mirrors UserSecrets::author_replica_id: the replica member that published the stored version, or absent when no group member did (an Owner with no replica identity, or a snapshot rebuilt by recovery). It is Option<u64> in Rust, ulong? in .NET, *uint64 in Go and an optional decimal string in TypeScript and React Native.
Persist author_replica_id and return it unchanged. A replica member decides whether an incoming copy of the version it holds is a re-send or a rival by comparing authors. A store that drops the field forces a fallback to comparing secret contents, which misjudges copies whose author differs but whose contents match.
DeRecStateStore
Partition key —
secret_id.Sub-key —
StateKey, a typed key discriminated byStateKind. There are five kinds:
Wire kind
StateKey variant
Secondary key
0
PendingVerification { channel_id }
channel_id
1
PendingRecovery { secret_id, version }
secret_id and version
2
PendingUnpair { channel_id }
channel_id
3
SharingRound { version }
version
4
PendingReplicaDiscovery
none — at most one row per secret_id
Conceptual shape — the orchestrator's in-flight protocol state: outstanding verification challenges, in-progress recovery accumulators, pending unpair acknowledgements, open sharing rounds, and the active replica catch-up. This is the state that makes
DeRecProtocoleffectively stateless — nothing is held in process memory between calls. In a long-lived process an in-memory map suffices; in a stateless or load-balanced deployment, back this with the shared database so in-flight flows survive process restarts and instance churn. See Failure Modes.
Two details of the key are load-bearing rather than cosmetic:
SharingRound is keyed by version — it is not a single slot. Several rounds can be open at once, because publishes are started by the pair-completion hook and by the promotion inside verify_fingerprint, not only by start(ProtectSecret), and both of those run while handling an inbound message. A single unkeyed row meant a round started that way silently replaced one already in flight, after which neither completed — responses for the replaced round arrived against state that no longer existed, and no SharingComplete was emitted for either. An application could not prevent this, because it cannot see the library's round state.
PendingRecovery is keyed by (secret_id, version), not version alone. The secret_id here is the secret being recovered, which is deliberately not the one naming the partition: a recovering device runs an ephemeral instance whose own id owns the partition, while the target belongs to the wire. Keying on it lets one instance recover several secrets concurrently at the same version without their accumulators colliding.
A store that hashes the whole key blob is unaffected by both. One that unpacks fields into columns must include version for kind 3 and both secret_id and version for kind 1 — otherwise distinct rows collide silently.
A store may derive the row key from the item. In the serialized form the TypeScript and React Native stores receive (and that the SQLite and Postgres references persist), an item's key fields — channel_id, secret_id and version — are present exactly when its key has them, with the same values. PendingReplicaDiscovery has no key field: the device's version at the start of the catch-up travels in local_version. Rows written before 0.0.6 that carry it in version still decode.
A PendingRecovery saved before 0.0.7 has no record of which channel each collected share arrived on; it decodes as having collected nothing, and the recovery re-collects its shares. The payload is opaque, so the schema is unaffected.
Two of the five kinds are accumulators — PendingRecovery (collecting shares toward the threshold) and SharingRound (tracking which Helpers have confirmed and which members have acknowledged). The library grows them via load-modify-save: it loads the current item, mutates it, and saves it back. save is a full-replacement upsert — there is no per-item merge or append at the store layer.
Required operations:
save(secret_id, item: StateItem) -> ()— full-replacement upsert keyed by(secret_id, item.key())load(secret_id, key: StateKey) -> Option<StateItem>— pure read;Nonewhen absentremove(secret_id, key: StateKey) -> bool— idempotent;trueiff a row was removedload_all(secret_id, kind: StateKind) -> Vec<StateItem>— every item of the given kind under thissecret_id, in no guaranteed order; used for timeout sweeps
A durable backend is a table keyed by (secret_id, kind, sub_a, sub_b) with an opaque StateItem payload blob. Two integer columns are needed because the secondary key differs per kind, and PendingRecovery needs both. This is the SQLite reference (smoke-tests/sqlite/migrations/0001_init.sql); the Postgres binding is identical modulo column types:
save upserts on the full primary key; load_all filters by (secret_id, kind).
Persisting this store is not optional in a deployment that can restart mid-flow. A response arriving after a process restart is dropped as unsolicited if the request that expected it did not survive. See Failure Modes.
Because the load-then-remove path during response handling is two round-trips, a load-balanced deployment may process a duplicate response — every library-emitted event is idempotent, so this is safe. Accumulator concurrency is a different matter and is the application's responsibility: see Concurrency and scheduling.
Common patterns and pitfalls
All five stores are partition-keyed by
secret_id. Multiple Secrets coexist in one database without leakage. The library never issues a cross-partition query. This is also what makes differentsecret_ids safe to drive concurrently with no coordination — see Concurrency and scheduling.Store methods are future-shaped in Rust and Promise-shaped in TypeScript and React Native; .NET and Go are synchronous. See Binding shapes.
The library holds each store by
&mut Self. Concurrent calls into the same store instance do not happen — implementations need no internal synchronisation. This is a statement about one protocol instance, not about the deployment: two instances against the samesecret_idmust be serialised by the application.secret_idpartitioning is enforced by the queries, not by the data type. Every public method takessecret_idas its first parameter; a backend that omits it from itsWHEREclauses will leak across Secrets.Share::secret_idis data, not the partition. On a Helper it is the Owner's id and routinely differs from thesecret_idargument. Key on the argument; never assert the two are equal. SeeDeRecShareStore.ChannelStatushas three variants, andUnpairingis replica-member only. It marks a member that has been told to leave and is awaiting the roster version that completes its removal, and is never set on a helper channel. A store that validates the enum on load MUST accept it — rejecting a value the core emits has been a shipped bug in more than one binding.
Every save must be idempotent. The library may re-send during pairing or sharing under failure (timeouts, connection drops, replays). A save that matches an existing entry on its full conceptual key MUST replace it in place; receivers MUST converge to the same end state regardless of how many times the same payload is delivered. This applies to all five stores.
For how the protocol resolves two members writing the same version, see Replica conflict resolution.
Binding shapes
The contract is the same in every SDK; the method shape is not.
Rust
Traits returning a typed future (ChannelStoreFuture<'_, T>, …). A sync backend returns Box::pin(std::future::ready(...))
Ok(None)
u64 / ChannelId
.NET
IChannelStore, IShareStore, ISecretStore, IUserSecretStore, IStateStore — synchronous methods
null (ChannelRecord?, uint?, …)
ulong
Go
ChannelStore, ShareStore, SecretStore, UserSecretStore, StateStore — synchronous methods
single-value loads return (value, ok, err); listings return (slice, err); Remove returns (existed, err)
uint64
TypeScript
ChannelStore, ShareStore, SecretStore, UserSecretStore, StateStore — every method returns a Promise
null or undefined
decimal strings
React Native
same interfaces as TypeScript, every method returns a Promise
null or undefined
decimal strings
In TypeScript and React Native, channel records and state items cross the bridge as opaque JSON bytes (Uint8Array) and must be persisted verbatim.
Choosing backends at run time
DeRecProtocol is generic over its five stores and transport, so by default the concrete backends are part of its type. To pick one at run time — Postgres in production, in-memory under test — use trait objects. The library implements every store trait for Box<T> and &mut T, and DeRecTransport additionally for Arc<T>:
A store behind an Arc does not compile: every store has &mut self methods, and Arc never yields &mut T. The transport is the one &self-only trait, and the one worth sharing across requests, since it is typically a pooled client.
The transport is a mailbox, not a request/response channel
DeRecTransport is not one of the five stores, but it is the sixth thing every integration implements and the one whose contract fails silently when misread.
Every peer has an address, and answering someone means posting to their address. A reply produced while handling an inbound message is not returned to the caller — it is handed to send, addressed to the peer's endpoint, while process() returns only the events describing what happened.
Where both sides are reachable services, that is all you need: the transport is a one-way push and an HTTP handler acknowledges rather than answers.
A peer with no address breaks this silently. A phone, a browser tab, or anything behind NAT advertised an endpoint at pairing time that nothing can actually reach. A push-style send drops the reply on the floor: nothing errors, process returned events, the handler looks successful — the peer just never hears back.
Such a deployment must answer on the connection the request arrived on, which means capturing what the protocol emits instead of pushing it:
Build the protocol per request, with a transport that collects into a buffer instead of sending. The buffer must belong to exactly one exchange, or concurrent requests mix their replies.
Return the collected message whose trace id matches the inbound envelope's. Every SDK exposes the same primitive for this:
read_trace_id(Rust),Envelope.ReadTraceId(.NET),envelope.ReadTraceID(Go),envelope_read_trace_id(TypeScript/WASM),read_trace_id_from_envelope(C FFI).Deliver the rest of the buffer. One
processcall can emit several messages — admitting a Helper also republishes to every other Helper — so the remainder is genuine fan-out and still has to reach its own endpoint. Returning the whole buffer, or blindly returning its first entry, is the mistake to avoid.
A compiled Collector / split_reply pair is in the DeRecTransport rustdoc; both are about twenty lines.
Reference implementations
SQLite
smoke-tests/sqlite/src/stores/
In-memory or on-disk. Seven tables: channels, replica_members and channel_links for the channel store, plus shares, secrets, user_secrets and protocol_state. Migrations 0001_init.sql and 0002_user_secrets_author.sql.
Postgres
smoke-tests/postgres/src/stores/
Same shape with Postgres column types. Smoke runs against the local docker container.
Both bindings implement all five traits durably over the same schema shape. They are intentionally small — a developer building a new backend (RocksDB, IndexedDB, a remote KV service, an in-process mock for tests) can fork either and substitute the storage primitives.
The Rust SDK chapter walks through wiring these into a DeRecProtocolBuilder, the .Net chapter shows the C# interface equivalent, the TypeScript and React Native chapters show the JS object equivalent, and the Go chapter shows the Go interface equivalent (constructed via protocol.New rather than a builder).
Last updated