ADR-013
CRDT Adapter for Distributed Knowledge Objects: G-Set + Merkle Tree (Stage 1)
Section titled “CRDT Adapter for Distributed Knowledge Objects: G-Set + Merkle Tree (Stage 1)”Status: Implemented (Stage 1 of 3)
Date: 2026-08-06
Category: Architecture Decision Record
Context
Section titled “Context”ADR-008’s GossipAdapter reconciles RuntimeSession snapshots between
replicas by reusing the existing ADR-007 three-way merge
(MergeOperation). That works well for a session’s structure
(relations, field-level conflicts), but it has two properties that
motivate this ADR:
- When the session-level merge finds a genuine conflict, the merge is
not applied – the conflict is published as
GossipConflictDetectedand left for a Critic agent (or a human) to resolve. AnyKnowledgeObjectthat only appears in the losing/conflicting side is, until that resolution happens, not durably recorded anywhere on this replica. - Reconciling two replicas’ full knowledge (rather than one shared session) still costs an O(n) structural diff per gossip round, with no cheaper way to first check whether two replicas already agree.
We want a second, independent layer underneath the session merge: a
plain grow-only set of every KnowledgeObject any replica has ever
produced, with no possibility of write-write conflict (since it only
ever adds), and a way to compare two replicas’ sets cheaply before
doing any real work.
Decision
Section titled “Decision”Add a cks_runtime/crdt/ module implementing:
-
CRDTStore– a G-Set (grow-only set) ofKnowledgeObjects, keyed by the object’s own SHA-256 leaf hash (KnowledgeObject._hash, hex-encoded), not by its application-levelObjectIdentity.id. Content-addressing is what makes “insert if id absent” a correct, order-independent, conflict-free merge: two replicas that independently produce bit-identical objects converge on one record automatically, and there is no way for a re-delivered gossip message to double-count. Backed bySQLiteCRDTStore,PostgresCRDTStore(async), andInMemoryCRDTStore(tests) – deliberately separate tables (cks_knowledge_objects,cks_crdt_state) from the existingsessions/versionstables, soSQLiteStorage/PostgresStorageneed no changes. -
MerkleTree– a radix-16 prefix tree over the 64-hex-character object ids. Level 64 nodes are the leaves (ids themselves, already content hashes); each levelL < 64node hashes its (up to) 16 children at levelL+1, with a well-knownEMPTY_SUBTREE_HASHstanding in for absent children. Inserting one object touches exactly the 65 nodes on its root-to-leaf path (update_merkle_path) – O(1) in the number of objects already stored, not O(n). Two replicas can compare root hashes first (get_root_hash); on a mismatch, walk down only the differing branches viaget_children_hashes(prefix)instead of diffing every object.SQLite has no stored procedures, so
SQLiteMerkleTreerecomputes the path in Python on every insert. PostgreSQL gets the same computation twice, deliberately: a PL/pgSQL trigger (update_merkle_tree_on_insert, attached tocks_knowledge_objects) keeps the tree correct for any client that inserts a row, including ones that bypass this Python layer entirely;PostgresMerkleTree’s own Python methods use the identical algorithm so the two paths always agree, and serve as a fallback for a table created without the trigger installed (e.g. a database without thepgcryptoextension available at the timeensure_schemaran). -
VersionVector(cks_runtime/crdt/version_vector.py) – a small, separate type fromcks_runtime.versioning.version_vector. VersionVector(ADR-007). The ADR-007 vector is anchored toRuntimeSession.metadataand itsdominates/absorbAPI exists specifically to driveMergeOperation’s fast-forward/no-op decision. The CRDT layer’s vector instead tracks, pernode_id, how many CRDT records that node has locally produced, persisted incks_crdt_state, and only ever needs a symmetricmerge(pointwise max) plusseen/observe– there is no “does A dominate B” question to answer for a G-Set, since merging two G-Sets is always safe regardless of either side’s vector. Reusing the ADR-007 type would have overloaded it with a second, unrelated persistence contract, so a new type was created instead of adapting the existing one in place. -
Gossip integration –
GossipAdaptertakes an optionalcrdt_storeconstructor argument (Noneby default; existing callers are unaffected)._apply_remote_session_lockednow calls_merge_crdt_objects(remote_session)before any session-level dominance/fast-forward/merge decision, unconditionally adding every object in the remote snapshot into the local G-Set. This guarantees the G-Set reflects everything this replica has ever observed, even for a remote session whose session-level reconciliation ends in an unresolved conflict.
Explicitly out of scope (Stage 1)
Section titled “Explicitly out of scope (Stage 1)”- MV-Register / fork detection – deferred to Stage 2. Stage 1’s
G-Set has no notion of “the same logical slot with two competing
values”; every distinct
(identity, structure)pair is just another set member. - Last-Write-Wins – deferred to Stage 2/3. This layer never removes or overwrites a record once added.
- Changes to
SQLiteStorage/PostgresStorage– the CRDT adapter owns its own tables and never touchessessions/versions.
Consequences
Section titled “Consequences”- Every gossip round now does one extra pass over the incoming
session’s objects (
O(objects in that session)), each a cheap “insert if absent” plus, for genuinely new objects, 65 Merkle-node upserts. This is strictly additive to the existing session merge cost and does not change its control flow or outcome. - A future Stage 2 (MV-Register/fork detection) can build directly on
CRDTStore.list_objects()/the Merkle tree’s divergence-localization without touching Stage 1’s schema, since G-Set membership is monotonic and Stage 2 only needs to add resolution semantics on top, not change what’s stored. get_root_hash()/get_children_hashes()are ready for a future gossip-transport addition that exchanges root hashes before whole session snapshots, letting two already-converged replicas skip a round entirely – not implemented in Stage 1, sinceGossipAdapter’s existingstructurally_equivalentcheck already provides an equivalent short-circuit at the session level.
Stage 2: MV-Register, causality, fork detection, quarantine
Section titled “Stage 2: MV-Register, causality, fork detection, quarantine”Stage 1 answers “have I seen this object before” (a G-Set). Stage 2 answers the question Stage 1 explicitly deferred: “which object is the current version of this logical slot” – e.g. “what does concept X currently say”, not merely “here is everything anyone has ever said about X”. That requires a mutable pointer, and a mutable pointer across replicas needs a real conflict story: two replicas can update the same pointer independently, with neither aware of the other, and that has to be detected and escalated rather than silently overwritten.
Decision
Section titled “Decision”-
cks_runtime/crdt/causality.py–causality_check(vv_a, vv_b). Replaces last-write-wins for pointer updates. Returns one of"dominates"/"dominated"/"concurrent"/"equal"by comparing twoVersionVectors pointwise pernode_id(missing entries treated as clock 0)."concurrent"– neither vector has observed the other’s advances – is the fork signal the rest of Stage 2 is built around. Deliberately not a method onVersionVectoritself and not reusing ADR-007’sdominates(): this needs a four-way classification (Stage 1’s own docstring already explains why the ADR-007 type is kept separate for an unrelated reason – overloading either type further would blur two already carefully-separated concerns). -
MV-Register (
cks_mv_register), added to all threeCRDTStorebackends alongside Stage 1’scks_knowledge_objects/cks_crdt_state:CREATE TABLE cks_mv_register (pointer_key VARCHAR(255) NOT NULL,object_id VARCHAR(64) NOT NULL, -- G-Set object id (Stage 1)vector_clock JSONB/TEXT NOT NULL,origin_node VARCHAR(64) NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT now(),PRIMARY KEY (pointer_key, object_id))The composite primary key is the load-bearing design choice: it is what lets
pointer_keyhold more than one liveobject_idat once (a fork), rather than forcing every write to be insert-or-replace the way a single-column key would.object_idhere is always a Stage 1 G-Set id (crdt_store.object_id_for), so a pointer never names an object that isn’t independently verifiable against its own Merkle leaf hash.update_pointer(pointer_key, object_id, vv, origin_node)compares the incomingvvagainst every existing pointer for that key viacausality_check: an existing pointer the new write dominates is deleted (superseded); a pointer that dominates the new write causes the write itself to be discarded (stale replay, mirroring the G-Set’s own “already known” no-op); anything concurrent is left standing alongside the new write – this is the fork case, and is the only wayget_pointers(pointer_key)ever returns more than one row.resolve_pointer(pointer_key, winner_object_id)is the sole way to collapse a fork back down to one row, and is never called byupdate_pointeritself – only by a Critic Agent that has actually arbitrated the fork (see below). -
cks_conflict_events, mirroring Stage 1’s Merkle-tree design philosophy of “make divergence a first-class, queryable fact” rather than a side effect discovered by polling application state:CREATE TABLE cks_conflict_events (event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),pointer_key VARCHAR(255) NOT NULL,conflicting_object_ids JSONB NOT NULL,vector_clocks JSONB NOT NULL,status VARCHAR(32) NOT NULL DEFAULT 'PENDING',created_at TIMESTAMPTZ NOT NULL DEFAULT now())escalate_forkwrites one row per detected fork and, on PostgreSQL, also issuesNOTIFY cks_fork_detected, '<event_id>'. SQLite has no NOTIFY equivalent –list_pending_forks()is the polling fallback, and is also what a genuinely separate Critic-Agent process uses regardless of backend, the same claim/poll patternclaim_conflict_taskalready uses forcks_outbox_tasks(ADR-006) rather than relying on in-process pub/sub a second OS process could never observe. -
GossipAdapter._handle_fork/_detect_and_handle_fork._merge_crdt_objects(Stage 1) now also callsupdate_pointerfor every incoming object, keyed by the object’s application-level identity (identity.id) rather than its Merkle-hashobject_id– a pointer names a logical slot (“concept X”), and different revisions of concept X necessarily have different content hashes, so the G-Set id can’t be the pointer key. Wheneverupdate_pointerreports a successful (non-discarded) write,_detect_and_handle_forkre-readsget_pointersfor that key; more than one row means_handle_forkruns:escalate_forkpersists the row, and a new event,CRDTForkDetected, is published on the Runtime’s event bus for any in-process subscriber – the same escalation shapeGossipConflictDetected(ADR-008) already established for session-level conflicts, deliberately not that event reused: a CRDT fork has nosession_id/source_replica_idto speak of, only apointer_keyand the competingobject_ids. -
CRDTQuarantine(cks_runtime/crdt/quarantine.py), adapted from BlackSwan’sQuarantineBuffer. BlackSwan gated admission on a reputation score; CKS has no such social-trust layer, so the gate here is purely structural:cks.validate()plus confirming the object’s claimed identity is actually derivable from its own Merkle leaf hash (reusingobject_id_for, which computes an id from the object rather than trusting a separately-carried field – so an object that fails to produce an id at all has already failed the identity check). An object failing either check is never passed toCRDTStore.add_object, so it can never enter the G-Set, be pointed at by an MV-Register entry, or affect the Merkle tree. -
refresh_from_storage(), adapted from BlackSwan’sCRDTAdapter.refresh_from_storage. There, it resynced an in-memory cache against a SQLite file a second OS process might have written to. CKS’sSQLiteCRDTStore/PostgresCRDTStorehave no such cache – every read already goes straight to the connection/pool – so both implementations are documented no-ops, kept only for interface symmetry so a caller (e.g.GossipAdapter) can call it unconditionally regardless of backend.InMemoryCRDTStore’s is a no-op for the opposite reason: there is no persistent storage, and no second process can ever share one Python process’s memory.
cks-mcp integration
Section titled “cks-mcp integration”cks_mcp.gossip._build_crdt_store(runtime)constructs aSQLiteCRDTStore/PostgresCRDTStorewrapping the same connection/ poolruntime.storagealready holds (unwrappingSyncStorageAdapterfirst), so CRDT tables live in the same database as everything else. ReturnsNoneforInMemoryStorage, the same condition that already rules gossip out viaruntime.replica_id is None.setup_gossipsubscribes toCRDTForkDetected(mirroring the existingGossipConflictDetectedsubscription): each fork is buffered intoConflictInbox.record_crdt_forkfor a same-process reader, and dual-written into the persistent outbox under task_type"crdt_fork"(payload:pointer_key,conflicting_object_ids,event_id;session_idis set topointer_key, since a CRDT fork has no session) for an out-of-process Critic Agent worker to claim via the existingclaim_conflict_tasktool.critic_agent.pyadds"crdt_fork"to_TASK_TYPES/_RESOLVERS.resolve_crdt_forkis deliberately mechanical (no LLM), the same “safe default policy” philosophy asresolve_temporal_conflict/resolve_contradiction_conflict: since every competingobject_idis a content hash every replica computes identically, picking the lexicographically-last id as the winner is an arbitrary but replica-agnostic deterministic tie-break – every Critic Agent across every replica converges on the same winner independently, with no coordination needed, unlike “keep whichever this replica saw first”. This is explicitly a placeholder policy, not a claim that lexicographic order is semantically meaningful; a future content-aware arbitration (construct_knowledge/arbitrate_inference_conflict-based) can replace it without changing the task/payload shape.
Explicitly out of scope (Stage 2)
Section titled “Explicitly out of scope (Stage 2)”- Semantic/content-aware fork arbitration –
resolve_crdt_fork‘s lexicographic tie-break is a deterministic placeholder, not an attempt to judge which side is “right”. Teaching the Critic Agent to inspect both objects’ actual content before choosing is future work. - Postgres
LISTEN-based consumption –escalate_forksendsNOTIFYon PostgreSQL, but cks-mcp does not yet run aLISTENloop to react to it instantly;list_pending_forks/the outbox dual-write are the only consumption paths wired up so far. TheNOTIFYis emitted now so a future listener can be added without touchingcrdt_store.pyagain. - Changes to
SQLiteStorage/PostgresStorage– unchanged from Stage 1’s constraint; MV-Register/conflict-event tables live entirely inCRDTStore’s own schema.
Consequences (Stage 2)
Section titled “Consequences (Stage 2)”update_pointeris called once per object per gossip round (same frequency as Stage 1’sadd_object), each a small read (existing pointers for that key – normally 0 or 1 rows, 2+ only mid-fork) plus at most one insert and a handful of deletes. Cheap relative to the G-Set insert it accompanies.- A pointer that has forked stays forked – consuming zero, one, or
many concurrent rows via
get_pointers– until a Critic Agent (or directresolve_pointercall) collapses it. Nothing else in this layer resolves a fork on its own; that is intentional, matching howGossipConflictDetected/InferenceConflictDetectedare never silently auto-resolved by the runtime that raises them either.