Skip to content

Plan

v0.1 roadmap (Python, the executable specification)

  1. ✔ Package skeleton, CI (pytest, ruff, mypy).
  2. ✔ Core without stores: identity, HLC, DSN parsing, mapping, result model.
  3. ✔ PostgreSQL + drevo adapters; meta-transactions with 2PC and retries; journal and recovery, including crash tests between prepare and commit; Unit of Work and transactional.
  4. ✔ Docker Compose for development and tests (below). ✔ ClickHouse branch: buffered rows, schema check at stage, rows in the journal, idempotent insert after the decision, roll-forward by recovery. ✔ Iceberg branch: data files written at stage, their paths in the journal, one tagged snapshot per table after the decision, roll-forward by recovery, staged files deleted on abort.
  5. ✔ Bitemporal reads (as_of, known_at, history), with the storage format of entity parts and repository persistence. ✔ Repository CRUD, versions, as_of in transaction time, hard and soft delete; parts in PostgreSQL, ClickHouse, drevo and Iceberg; the parts contract. ✔ Valid time: periods stored with every part, as_of / known_at reads.
  6. ✔ Migrations: one TOML format for every store, the history in the journal with transaction time, rhizodat migrate.
  7. ✔ Integration scenarios: cloud operations, story/book editor, IT task manager, ERP, bug tracker. Each is an end-to-end workflow in tests/scenarios/, run on the Python facade and on the native one, and closed with the same data worked from both facades in turn. ✔ Cloud operations (test_cloud_ops.py), all four stores: resources in PostgreSQL, their dependencies in drevo, telemetry in ClickHouse, the raw API requests in an Iceberg lake; a stack provisioned with its requests in one transaction (an interrupted one leaves neither), the blast radius of a failing database from the graph, the unhealthy service found in ClickHouse, scaled with its request and read as_of before the incident, its requests audited in the lake, a balancer decommissioned and restored, and the estate run from both facades in turn. It replaced a CBT journal scenario, a domain one SQLite file serves, which justified none of the stores. ✔ Bug tracker (test_bug_tracker.py): bugs across PostgreSQL and an Iceberg lake, triage in one transaction through uow.repository (an interrupted one leaves nothing), open bugs per assignee in native SQL over the latest versions, close / reopen with as_of reads, reopened bugs scanned in the lake with a row filter, a withdrawn report, and the same tracker worked from both facades in turn. ✔ ERP (test_erp.py): customers and orders across PostgreSQL and ClickHouse (money in integer cents, since a Decimal is a string there); a price agreement with valid time, a rebate that holds for its own days while the price known before it stays readable (as_of + known_at); orders placed in one transaction priced by the agreement holding on each delivery day; revenue per SKU in native ClickHouse over the latest versions, cancelled orders out. ✔ IT task manager (test_task_manager.py): tasks in PostgreSQL, "blocked by" edges in drevo; a Cypher migration step; a sprint planned in one transaction; what a task blocks asked of the graph and confirmed against the latest versions; a finished task releasing its dependents; archive / restore. ✔ Story editor (test_story_editor.py): chapter text in PostgreSQL, the cast in drevo, words and revision in an Iceberg lake; a revision as a new version with the first draft readable as_of; where a character appears from the graph; the long chapters from a lake scan; a cut chapter restored.

Out of scope for v0.1: async API, savepoints / nested transactions, cross-store query planning.

Docker Compose for development and tests

One compose.yaml runs every store the tests need, locally and in CI alike (docker compose up -d --wait, then uv run pytest):

  • ✔ PostgreSQL with max_prepared_transactions > 0;
  • ✔ drevo from the pinned multi-arch image with the drevo.tx.* 2PC procedures (ice1x/drevo:0.0.43, ice1x/drevo#591), so CI now tests drevo over Bolt too;
  • ✔ healthchecks, and .env.test with the test DSNs;
  • ✔ ClickHouse behind the clickhouse profile, so a test run starts only what it needs;
  • ✔ Iceberg behind the iceberg profile: the Apache REST catalog fixture over S3-compatible object storage (rustfs; the MinIO image is no longer on Docker Hub), with a one-shot container creating the bucket. The adapter tests run against it and against a SQLite catalog with a local warehouse;
  • ✔ toxiproxy behind the faults profile: tests create proxies through its API and cut the network to the journal or a store at a chosen protocol step (the journal before / after the decision, PostgreSQL, ClickHouse or drevo after it) and check InDoubt / CommittedPending and what recovery does once the network is back.

Compose is for development, tests and demos on one host. It is not the production deployment.

Releases

  1. ✔ 0.1.0a1, the proof of concept (2026-11-01): the Python package with the Rust core as rhizodat[native], published from a v* tag by the Release workflow (rhizodat sdist and wheel, rhizodat-native abi3 wheels for Linux and macOS on x86_64 and arm64, a GitHub release, PyPI through trusted publishing).
  2. Feedback: the "Feedback on the proof of concept" issue template; every report becomes a plan item or a documented limit.
  3. 0.1.0, the MVP: ✔ the v0.1 roadmap complete (bitemporal reads and the integration scenarios of item 7), ✔ every store's documented limits stated in the stores page, ✔ the same suite green on both implementations in CI; the feedback addressed (none received yet, 2026-11-07). The release itself: version 0.1.0, the changelog, the v0.1.0 tag, the PyPI upload of the release assets.

Rust core

The parts that must behave identically everywhere move to one Rust implementation instead of separate ports per language:

Part Why Rust
Commit protocol, journal, recovery one implementation instead of N ports of a subtle protocol; states and outcomes enforced by types
HLC, UUIDv7, gid shared by every binding, bit-for-bit identical
Parallel prepare / finish async (tokio) shortens the lock window and drevo's prepared fence
Embedded drevo drevo is Rust: use drevo-core directly, without drevo-py or Bolt
Recovery worker, server long-running processes with predictable memory and latency

Speed of the coordinator itself is not the reason: commit latency is set by network round trips to the journal and the stores, not by CPU.

Steps:

  1. Conformance suite as data (JSON / YAML), run by every implementation: ✔ UUIDv7, HLC, gid, the journal transition table, DSN vectors and the crash matrix (failure point × state left × outcome after recovery) in conformance/, run against the Python reference.
  2. ✔ Driver check (2026-10-12, spikes against the compose stores): every step the protocol needs works in all four drivers; none stays on Python.
Store Crate Verdict Caveats for the port
PostgreSQL tokio-postgres 0.7.18 ready gids are SQL literals in PREPARE TRANSACTION (quoted by hand); SqlState gives 42704 / 40001; connection loss is is_closed() without a code
drevo (Bolt) neo4rs 0.9.0-rc.10 ready with caveats release candidate; Bolt 4.4; the prepared fence is a Transient error; an unknown gid is still recognised by its message only (ice1x/drevo#590); no default timeout on pool checkout, and result streams hold pooled connections until dropped
ClickHouse clickhouse 0.15.2 ready with caveats server error codes are parsed from the Code: N. message; journal batches need the formatted insert (JSONEachRow), not typed rows
Iceberg iceberg 0.10.1 + REST catalog + iceberg-storage-opendal ready with caveats 0.x API; snapshot summary properties, staged Parquet writes, fast append, snapshot listing, FileIO::delete and uuid columns work; building a DataFile from a path is crate-private, so the Rust journal payload carries the DataFile JSON (serialize_data_file_to_json, column statistics kept); the commit already rejects files referenced by the table; tables written by Rust read back in pyiceberg

Embedded drevo will use drevo-core directly and is checked with step 4. 3. rhizodat-core crate (crates/rhizodat-core): protocol, journal, recovery, HLC, identity, tested on fake stores like the Python core. ✔ Identity (UUIDv7), HLC, gids, the journal state machine and DSN parsing, passing the same vectors as Python. Writing them found two gaps in the reference, now fixed and pinned by new vectors: another spelling of a dat_txn (upper case, braces, no hyphens) was taken for a rhizodat gid, and an unbalanced IPv6 bracket in a DSN raised a bare ValueError. ✔ The journal interface with an in-memory journal, the coordinator and recovery, async and runtime-agnostic (the binding passes a Timer). Their tests are the port of the Python ones on the same fakes, including the all-or-nothing property test, and crash_matrix.json replays with identical step traces. A crash is a panic (or a dropped future), which the core never catches. The port found one more gap, fixed in both: when the coordinator gave up deciding and the journal then could not be read, the reference raised the driver's error instead of InDoubt. Branches are still prepared and finished one by one, as the traces pin down; parallel prepare / finish comes with the Rust adapters. 4. ✔ Rust adapters: PostgreSQL, drevo (embedded, and Bolt), the PostgreSQL journal. ✔ crates/rhizodat-postgres (tokio-postgres): the two-phase store, its participant and the PostgreSQL journal, on the same tables as the Python adapter. The journal's branches text is pinned in conformance/journal.json, so either implementation recovers the other's records. Tested against the compose PostgreSQL, including a meta-transaction over two stores, a crash after prepare (presumed abort) and a crash after the decision (roll-forward), both recovered by a worker with its own connections. Stores must not share a schema (see "One marker table per store"); the Python facade refuses it, the Rust crates document it until a Rust facade exists. ✔ crates/rhizodat-drevo (neo4rs, Bolt): the two-phase graph store, its participant and the drevo journal, on the same nodes as the Python adapter; tested against the compose drevo, including the prepared fence and a bug tracker over PostgreSQL and drevo with crashes after prepare and after the decision. neo4rs retries a failed auto-commit statement on transient errors for up to 60 seconds, which would hide the fence from rhizodat's retry policy: writes therefore run in explicit transactions (never retried), while reads and the drevo.tx.* procedures stay in auto-commit mode, since under the fence drevo rejects the commit of any explicit transaction and accepts the procedures only outside one (ice1x/drevo#608). ✔ Embedded drevo (drevo:///path, drevo+mem://) behind the crate's embedded feature, on the native engine of the pinned drevo commit (the one drevo-py uses), with typed two-phase errors; every drevo scenario runs on Bolt and embedded, and a durable embedded graph keeps a prepared transaction across a restart. Step 4 is complete.

Beyond step 4, so that no store stays on Python: ✔ crates/rhizodat-clickhouse (clickhouse crate): the one-phase branch applied after the decision, with the same count / delete / reinsert idempotency, deduplication tokens and dat_applied marker as the Python adapter. Its journal payload is the shared row format, now in rhizodat_core::rows and pinned by conformance/rows.json: a Python payload is applied by the Rust participant and the reverse (checked both ways). Rows travel as JSONEachRow (columns are known only at run time) with date_time_input_format = best_effort; since JSON strings are UTF-8, a bytes value must be valid UTF-8 there and anything else is refused at stage. The ClickHouse crate needs Rust 1.89. ✔ crates/rhizodat-iceberg (iceberg-rust 0.10, REST catalog, S3 through OpenDAL): the one-phase branch committed after the decision, with the same tagged snapshots and idempotency checks as the Python adapter. A branch buffers Arrow record batches; stage conforms them to the table's schema (by name, missing optional columns null, types cast) and writes Parquet files. The journal payload is the shared staged-files format, now in rhizodat_core::staged and pinned by conformance/staged_files.json: paths, plus the DataFile JSON of each file that iceberg-rust needs to commit it. A payload with paths only (the Python adapter's) is committed from the Parquet footers, and the Python adapter commits a Rust payload by its paths (checked both ways). Tested on iceberg-rust's in-memory catalog over a local warehouse and on the compose REST catalog, including an order in PostgreSQL with its sales in Iceberg, committed together and rolled forward by recovery after a crash. Not yet in Rust: partitioned tables (refused at stage) and the SQL / Glue catalogs. iceberg-rust 0.10 needs Rust 1.94, now the workspace MSRV. Every store now has a Rust adapter. 5. Bindings: the rhizodat Python package becomes a PyO3 wrapper over the core without changing its public API; a C ABI (cbindgen) for Go. Open questions to settle first: Python callbacks (transactional functions) into an async core and the GIL, and results across the boundary (Arrow). ✔ First step, crates/rhizodat-py: the optional module rhizodat_native (PyO3, abi3, built with maturin) with the pure parts of the core: UUIDv7, the HLC, gids and migration files, passing the same vectors. The package makes new_id with it when it is installed and stays pure Python otherwise. Callbacks and the GIL, settled for synchronous callbacks: the core holds a mutex while it calls its sources, so a generator or clock over the system sources (no Python) is shared between threads and releases the GIL, while one over Python callables is bound to the thread that made it; a callback's exception is kept and raised once the core returns. ✔ The coordinator across the boundary (rhizodat_native.Coordinator): a Python Unit of Work hands it its branches (any objects with the branch methods) and the core runs the commit protocol — one-phase, two-phase through the in-memory journal, roll back before the decision, the all-or-nothing property — calling back into each object's stage / prepare / finish and so on. The async core runs on a single-threaded executor with the GIL released (Python::detach) and each callback re-takes it for its one call (Python::attach), so nothing leaves the calling thread and there is no cross-thread hazard; the first exception a callback raises is kept and chained as the __cause__ of AbortedError / InDoubtError, as the Python reference chains the store's own error. Driven by fakes, like the Rust and Python coordinator tests, so no Docker. ✔ The first real adapter behind this surface (rhizodat_native.PostgresStore and rhizodat_native.PostgresJournal): a Unit of Work of native stores runs the whole protocol through the core against real PostgreSQL — one-phase for a single store, two-phase through the PostgreSQL journal for several, roll back before the decision, the all-or-nothing property, idempotent replay — with no Python callbacks on the path (a Unit of Work may still mix native stores and Python branch objects). A native commit runs on a current-thread tokio runtime (TokioTimer for the retry policy) that drives the adapter and its connection tasks on the calling thread, so it keeps the single-threaded property of the Python-callback path. tests/core/test_native_postgres.py drives it against the compose PostgreSQL. ✔ The drevo adapter behind the same surface (rhizodat_native.DrevoStore over Bolt, rhizodat_native.DrevoJournal): the stores now share one NativeStore path, so a Unit of Work mixes native adapters freely — a drevo store and a PostgreSQL store commit together as one meta-transaction through a journal (the first cross-adapter native Unit of Work), on the same current-thread runtime (neo4rs and tokio-postgres driven side by side). tests/core/test_native_drevo.py drives it against the compose drevo. ✔ ClickHouse behind the same surface (rhizodat_native.ClickHouseStore): the one-phase branch, applied after the decision. ClickHouse has no journal of its own, so a meta-transaction that includes it (for example with a PostgreSQL store) commits through another store's journal. tests/core/test_native_clickhouse.py drives it against the compose ClickHouse. ✔ The first data across the boundary (rhizodat_native.ClickHouseUnit, store.unit()): Python buffers a Unit of Work's rows into it with insert (the core's row format, so no Arrow dependency) and hands it to commit like a store, so a native ClickHouse commit writes real rows, not just the applied marker. A unit is spent by its commit. tests/core/test_native_clickhouse.py writes rows and reads them back, including a ClickHouse + PostgreSQL commit. ✔ The same row path for PostgreSQL (PostgresStore.unit()): a unit's rows are written inside the branch's own transaction at stage, so they commit atomically with the meta-transaction through the two-phase protocol. The target table carries the user columns plus dat_txn/dat_tx_time, stamped onto every row; values are bound as $n::text::<column type>, so the server parses each per its own column (scalars, decimals, bytes and temporal values; lists and maps not yet). A PostgreSQL unit may be reused across a retry. tests/core/test_native_postgres.py writes rows, reads them back, and replays a commit without duplicating them, including a unit + second-store meta-transaction. ✔ The data path for drevo (DrevoStore.unit()): a graph store buffers parameterized Cypher instead of rows (unit.run(cypher, params)), run inside the branch's two-phase transaction so the statements commit atomically with the meta-transaction. rhizodat injects dat_txn/dat_tx_time parameters into every statement, so a write may stamp its nodes with $dat_txn (those names are reserved). A drevo unit is reusable across meta-transactions. tests/core/test_native_drevo.py writes nodes, reads them back stamped, reuses a unit across two meta-transactions, and commits a unit together with a PostgreSQL store through a journal. ✔ The package's own transactional on the core (opt-in): a Rhizodat facade built with native store objects and a native journal (or none) runs its Units of Work through rhizodat_native.Coordinator — the whole protocol in Rust, no Python callbacks on the path. A Unit of Work buffers its data on each store's native unit (uow.store(name).insert(...) / .run(cypher, params)) and transactional's retry loop drives the native Unit of Work unchanged. Repositories, parts, execute and migrations are not on this path yet. tests/core/test_native_facade.py writes through the facade to one PostgreSQL store, one ClickHouse store, and a PostgreSQL + drevo meta-transaction through a journal. ✔ Iceberg behind the same surface (rhizodat_native.IcebergStore, REST catalog): the first Arrow across the boundary. store.unit().append(table, data) takes any object with __arrow_c_stream__ (or Arrow IPC stream bytes); stage writes Parquet files and the participant appends them after the decision, so an Iceberg unit and a PostgreSQL store commit together through the PostgreSQL journal, and the facade takes the native Iceberg store like the others. The data crosses as an Arrow IPC stream (one copy, safe Rust): importing the C stream directly needs unsafe (forbidden in the workspace) or an Arrow binding for pyo3 0.30 at iceberg-rust's Arrow 58, which does not exist yet. The store connects inside each commit's runtime (the REST client is bound to the runtime that made it), so it is built without I/O. tests/core/test_native_iceberg.py writes, casts, reuses a unit, aborts on a missing table or unfit rows, and rolls back PostgreSQL when Iceberg fails. ✔ Recovery on the core (rhizodat_native.Recovery(journal, stores)): the core's recovery worker over a native journal and the participants of native stores (or Python objects with the participant methods), rebuilt for each run on its own current-thread runtime; the native facade's recovery() returns the same RecoveryReport as the Python one. A participant's failure is reported and retried by a later run; a failing wall clock or journal fails the run. tests/core/test_native_recovery.py kills a child coordinator (os._exit) before the decision (rolled back) and after it (rolled forward, idempotently) with a PostgreSQL branch left prepared, and sweeps an orphaned prepared gid through the facade. ✔ Entity parts in PostgreSQL on the core (rhizodat_postgres::parts): the reference's part tables (same DDL, service columns and tagged JSON for jsonb fields, so either implementation reads the other's versions), writes buffered in PostgresParts and applied in order inside the branch's transaction at stage, and committed reads (read_part as of a time, part_history, part_versions, expired_parts). The first piece of repositories on the core. ✔ Repositories on the native facade, parts in PostgreSQL: rhizodat_native carries PartSpec, the store's committed reads and the unit's buffered part writes (values both ways: datetime, date, Decimal, UUID, bytes, nested lists and dicts); rhizodat.core.native_parts puts them behind PartStore / PartBranch, so r.repository(T) and uow.repository(T) run the same repository logic on the core, and either facade reads the other's versions. ✔ Entity parts in drevo on the core (rhizodat_drevo::parts): the reference's layout (one node per version labelled after the part, title as dat_title, fields native / UUID string / tagged JSON, reference edges to DatEntity anchors removed with their last version), writes buffered in DrevoParts and run in order inside the branch's transaction at stage, and committed reads in auto-commit mode, on Bolt and embedded. ✔ drevo parts across the boundary: rhizodat_native.DrevoPartSpec, the native DrevoStore's committed part reads and the DrevoUnit's buffered part writes, so the native facade's repositories hold a type in drevo, or split it across PostgreSQL and drevo under a PostgreSQL journal; either facade reads the other's versions. ✔ Erase batches in the core's row payload: rhizodat_core::rows::Batch carries the reference's erase flag (its rows name entities whose older versions are deleted), pinned by conformance/rows.json, and the Rust ClickHouse adapter applies them like the reference (erase first, skip a payload whose marker is there). Before this, the Rust decoder dropped the flag, so a Rust participant finishing a Python payload would have inserted the ids instead of erasing. ✔ Entity parts in ClickHouse on the core (rhizodat_clickhouse::parts): the reference's part tables (MergeTree ordered by (dat_id, dat_tx_time), Nullable native columns or tagged JSON in String), versions buffered in ClickHouseParts (the last write per entity wins) and staged into the journal payload as row batches, erases first, applied after the decision; committed reads as of a time. ✔ ClickHouse parts across the boundary: rhizodat_native.ClickHousePartSpec, the native ClickHouseStore's committed part reads and the ClickHouseUnit's buffered part writes (staged into the journal payload, applied after the decision), so the native facade's repositories hold a type in ClickHouse, or split it with PostgreSQL; either facade reads the other's versions. ✔ Copy-on-write erase in the Rust Iceberg participant: iceberg-rust 0.10 has no delete action and keeps TableCommit private, so the participant rewrites the data files holding an erased entity's older versions, writes the manifests and manifest list of one overwrite snapshot (with the branch's staged files) and commits it through the REST catalog protocol on condition that main has not moved. A payload the Python reference wrote is finished by native recovery and read back by pyiceberg (tests/core/test_native_iceberg_erase.py). ✔ Entity parts in Iceberg on the core (rhizodat_iceberg::parts): the reference's part tables in the parts namespace (ids and transactions as text, native columns for the scalar kinds, tagged JSON for the rest), versions buffered in IcebergParts (the last write per entity wins) and staged as one more data file of the branch, erases in the payload (committed copy-on-write with the files), committed reads as of a time. ✔ Iceberg parts across the boundary: rhizodat_native.IcebergPartSpec, the native IcebergStore's committed part reads (and its parts_namespace, the Python adapter's default dat) and the IcebergUnit's buffered part writes, so the native facade's repositories hold a type in any of the four stores, or split it across them; either facade reads the other's versions. ✔ Reads with Arrow for full results: execute on the native ClickHouse store (native SQL, {name:Type} parameters bound server-side, FORMAT ArrowStream for statements that return rows) and on the native Iceberg store (a scan with selected_fields, snapshot_id and limit — rhizodat_iceberg::Scan; UUID columns tagged arrow.uuid so pyarrow reads them as UUIDs). The core answers with the whole result as one Arrow IPC stream and rhizodat.core.native_result.from_ipc makes the facade's Result of it, so r.store(name).execute(...) works on the native facade and to_arrow() is the stream itself. row_filter too: rhizodat_iceberg::filter::parse_filter reads pyiceberg's filter syntax (comparisons either way round, BETWEEN, IS [NOT] NULL / NAN, [NOT] IN, [NOT] LIKE 'prefix%', NOT / AND / OR, parentheses, quoted columns and strings) into an iceberg-rust predicate, bound to the table's types by the scan; a filter that matches no row still answers with the columns. ✔ execute on the native PostgreSQL and drevo stores, rows across the boundary (those drivers give rows, not Arrow): rhizodat_postgres::query prepares the statement, binds $1..$n by the server's parameter types (an integer to int2 / int4 / int8 / float8, a string to text, uuid, json(b), …) and decodes each column by its type (bool, integers, floats, text, uuid, bytea, json(b), timestamp(tz), date); a value or a column type it cannot carry is refused with the cast that would ($1::text::numeric, price::text), and a server error carries its SQLSTATE and message. The drevo store's execute runs Cypher with named parameters through the core's write (fenced while a branch is prepared). Both answer through rhizodat.core.native_result.from_rows, so r.store(name).execute now works on every native store. ✔ Migrations on the native path, with the Python runner unchanged: the native PostgresJournal keeps the history in the reference's <table>_migrations table (applied_migrations / record_migration, so either facade continues the other's history) and MemoryJournal keeps it in memory; the native PostgreSQL store runs sql steps in one transaction (run_sql), ClickHouse one statement per request, drevo cypher steps in their own transactions; the core's AppliedMigration crosses as the reference's dataclass. The native Iceberg store runs iceberg steps through the Python adapter's declarative operations on a pyiceberg catalog: iceberg-rust 0.10 has add_column / delete_column but no column rename and no partition spec update, so the step itself waits for the library (or a hand-built REST table update like the erase's). Next, after the MVP: the C ABI for Go. 6. ✔ CI: cargo test, cargo clippy, cargo fmt --check (the rust job, on stable and the MSRV, against the compose stores), and the conformance suite against both implementations: the Rust vectors in cargo test, the Python reference in every test job, and the native module's run of the same vectors (tests/conformance/test_native_vectors.py) in drevo-embedded, together with the full suite on both facades.

Order: after step 4 of the v0.1 roadmap (or at least after the Iceberg driver check), so the port carries a finished protocol, including the non-2PC branches, rather than a moving target.

Production deployment

Every store is reached over the network, so clustered stores work unchanged; the guarantees then depend on how each store fails over.

  • Journal: the COMMITTED record is the atomic commit point. Its store must replicate synchronously (PostgreSQL synchronous_commit = on with a synchronous standby, or a quorum, e.g. Patroni). With asynchronous replication a failover can lose a decision, and recovery would presume abort for a transaction that is partly committed.
  • PostgreSQL: two-phase commit goes to the primary. Prepared transactions are in the WAL and survive failover to a synchronous standby; with an asynchronous one a prepared branch can be lost, which the dat_applied check reports as a heuristic outcome.
  • ClickHouse: ReplicatedMergeTree for deduplication tokens; the "already applied" check reads with sequential consistency or after quorum inserts.
  • drevo: the server over Bolt, not the embedded mode. A prepared transaction survives a restart (restored from the WAL); its behaviour on failover to a replica is still to be confirmed with drevo.
  • Iceberg: a highly available catalog; commit conflicts after the decision are retried.
  • Latency: a commit over several stores costs four journal writes plus a prepare and a commit per two-phase store. Keep the journal close to the coordinators. While a drevo transaction is prepared, all other writes to that graph wait, so graph write throughput is bounded by the prepare→commit window.
  • Clocks: NTP on every host; the HLC absorbs the remaining skew and refuses timestamps beyond max_offset_ms.
  • Operations: per-call timeouts, connection pools, metrics and alerts for stuck transactions and fence duration, and recovery as its own process. Deployed with container images and a Helm chart; managed databases are fine.

Server mode (after v0.1)

An optional rhizodat-server on top of the same core: clients in any language, one place for coordination, recovery and connection pools, and no single-process limit of embedded drevo. It adds a network hop and must run as several instances (the journal's compare-and-set already makes concurrent coordinators safe). The open design question is the transaction model over the network:

  • sessions (begin / execute / commit): flexible, but an idle client holds locks and the drevo fence until a timeout;
  • one request per Unit of Work: safe, but no application logic between statements.

The library stays the core; the server is built on it, not instead of it.