Transaction Science open standards monorepo. Each subdirectory is a self-contained reference protocol with its own README and licence. - openpay/ Apache-2.0 payment-acceptance stack (Rust, 26 crates) - smart-byte/ CC-BY-4.0 content-addressed value carrier (spec) - eoc/ CC-BY-4.0 energy-optimised compute (spec) The wire format and the right to fork are public. The optional hosted services are the offer; the protocols are not.
16 KiB
Phase 16 — Embedded persistent graph DB (Minigraf)
Status: Draft v0.16 Date: 2026-05-21
Why this phase exists
Phase 14 shipped op-graph on IndraDB in-memory only. Operator
question that immediately followed: "how do we keep the graph
across restarts?" The Phase 14 progress doc promised a RocksDB
backend; this phase delivers that capability — but not with
RocksDB.
The "right Rust graph DB" turned out to be a real research question. The constraint set:
- Embedded ("in-app like SQLite"), not a separate server.
- Pure Rust. No heavy C/C++ build chains.
- Real graph DB. Not "use SQL with self-joins."
- Single-file durability ("like SQLite").
- Apache-2.0 / MIT licensable.
The landscape evaluated:
| Candidate | Verdict |
|---|---|
| IndraDB + RocksDB | RocksDB is a heavy C++ build, single-process lock. Rejected. |
| Cozo | Original crate paused at v0.7.6 (Dec 2023). Community fork cozo-ce 0.7.13-alpha.3 fails to compile against current rayon (transitive graph_builder API drift). Not viable in 2026. |
| SurrealDB | Pure Rust, but server-first; embedded mode pulls heavy deps and the core is BSL 1.1, not Apache-2.0. License contagion for a reference stack. Rejected. |
| petgraph | Data structure library, not a database. |
| Minigraf 1.1.1 | Selected. Pure safe Rust (#![forbid(unsafe_code)]), MIT OR Apache-2.0, single-file .graph database, embedded (no server), Datalog queries over an EAV fact store, native Uuid entity ids, bi-temporal history built in (free time-travel for ledger audits). Active (1.x stable, not alpha). |
Minigraf advertises itself in its own docs as "the SQLite of graph databases" — verbatim what was asked for.
What shipped
A complete replacement of the IndraDB-backed op-graph internals
with Minigraf. The public API surface of every op-graph type
(GraphHandle, GraphLedgerStore, GraphWebhookStore,
GraphReconciliationStore, all queries.rs helpers) is preserved
— callers across Phase 14 / 15 / the orchestrator integration tests
recompile without changes.
| File | LOC | Notes |
|---|---|---|
op-graph/Cargo.toml |
+6 | dep swap: indradb-lib → minigraf; tempfile dev-dep |
op-graph/src/graph.rs |
596 | full rewrite on Minigraf |
op-graph/src/error.rs |
−10 | dropped Indra/indradb::ValidationError bridges, added Backend(String) |
op-graph/src/ledger_store.rs |
small sweep | outbound_id→from, inbound_id→to, &indradb::Edge→&crate::graph::Edge, plus a real fix to find_by_external_id (graph fallback when the cache misses on a freshly-opened persistent handle) |
op-graph/src/webhook_store.rs |
small sweep | same field renames |
op-graph/src/reconciliation_store.rs |
small sweep | same field renames |
op-graph/src/queries.rs |
small sweep | same field renames |
op-graph/tests/persistent.rs |
224 | new — 4 round-trip tests proving data survives handle drop + reopen across the path |
| Phase 16 totals | ~820 added | (the 596 graph.rs LOC mostly replaces 316 prior LOC; net new is on the order of +500) |
Workspace at the end of Phase 16:
| Check | Result |
|---|---|
cargo build --workspace --all-targets |
0 errors, 0 warnings |
cargo test --workspace |
810 passing, 0 failing |
cargo clippy --workspace --all-targets |
0 warnings |
The net test count vs Phase 15 (819) is −13 op-graph lib tests
(internal graph.rs unit tests of IndraDB-specific behaviour that
no longer apply) +4 persistent integration tests = 810. All 11
op-graph integration tests including the Phase 15 cross-domain
reconciliation test pass unchanged.
Architecture: EAV mapped onto property-graph
Minigraf is an EAV (Entity-Attribute-Value) fact store with a Datalog query language. The OpenPay graph is property-graph shaped — typed vertices, typed directed edges, JSON properties on both — so the facade translates one onto the other:
Vertex(id, type) → [id :_type type]
VertexProperty(id, name, value) → [id name value]
Edge(from, type, to):
mint edge_id (Uuid::new_v4)
→ [edge_id :_edge/from <from>]
[edge_id :_edge/to <to>]
[edge_id :_edge/type <type>]
EdgeProperty(edge_id, name, val) → [edge_id name value]
Internal attributes are prefixed _ so user property names (e.g.
status, amount_minor) can be any keyword without collision.
get_vertex_properties filters _* out so callers see only their
own properties.
Schema in code
pub mod vtypes {
pub const LEDGER_ACCOUNT: &str = "ledger_account";
pub const LEDGER_TX: &str = "ledger_tx";
pub const LEDGER_LEDGER: &str = "ledger_ledger";
pub const WEBHOOK_EVENT: &str = "webhook_event";
pub const WEBHOOK_ENDPOINT: &str = "webhook_endpoint";
pub const WEBHOOK_ATTEMPT: &str = "webhook_attempt";
pub const STATEMENT_LINE: &str = "statement_line";
pub const RECONCILIATION_TASK: &str = "reconciliation_task";
}
pub mod etypes {
pub const LEDGER_DEBIT: &str = "ledger_debit";
pub const LEDGER_CREDIT: &str = "ledger_credit";
pub const LEDGER_IN_LEDGER: &str = "ledger_in_ledger";
pub const LEDGER_REVERSES: &str = "ledger_reverses";
pub const WEBHOOK_DELIVERS: &str = "webhook_delivers";
pub const WEBHOOK_TO: &str = "webhook_to";
pub const RECONCILES: &str = "reconciles";
pub const TASK_ABOUT: &str = "task_about";
}
Unchanged from Phase 14 / 15 — the schema is the same property graph; only the storage substrate moved.
Vertex / Edge types are now local to op-graph
The IndraDB-specific indradb::Vertex / indradb::Edge no longer
appear in the public API. They were replaced with:
pub struct Vertex { pub id: Uuid, pub t: String }
pub struct Edge { pub id: Uuid, pub from: Uuid, pub t: String, pub to: Uuid }
This is a tiny breaking change relative to Phase 14: edge.outbound_id
→ edge.from, edge.inbound_id → edge.to. Internal to op-graph;
no downstream phase touches these field names directly.
Persistence: it just works
// Tests, ephemeral:
let h = GraphHandle::new_in_memory();
// Single-file durability:
let h = GraphHandle::new_persistent("./openpay.graph")?;
// ... write through it ...
drop(h); // file flushes
let h2 = GraphHandle::new_persistent("./openpay.graph")?;
// ... reads see the data written by the previous handle.
That's the entire user-facing API change. The four persistent
round-trip tests in tests/persistent.rs walk each store type:
graph_handle_persists_vertex_and_edge_across_reopen— the low-level GraphHandle primitive.ledger_transaction_round_trips_across_reopen— write a ledger transaction, drop, reopen, verify the entries (stored as edges) and the derived balance are all intact.webhook_event_and_attempt_round_trip_across_reopen— the same for webhook events and delivery attempts.reconciliation_tasks_survive_restart_and_re_record_stays_idempotent— the cross-domain integration. Reconcile against a webhook source, persist tasks, drop, reopen, re-run reconciliation against the persisted ledger, verify task vertices are unchanged and the deterministic task_id index keepsrecord_reportidempotent across the restart.
Key design decisions
1. EAV/Datalog under, property-graph facade above
The data model on disk is EAV facts — every property write is a
fact [entity attribute value]. The facade above presents a
property-graph API (create_vertex, create_edge, set_vertex_property)
so callers in Phase 14 / 15 don't need to learn Datalog. Datalog
queries are constructed and dispatched only inside graph.rs; the
strings never leak across module boundaries.
The trade-off is two-tier: when callers want graph traversals
(accounts_touched_by_transaction, reversal_chain, etc.) they go
through hand-rolled queries in queries.rs that use the facade's
typed primitives. Operators who want raw Datalog can call
handle.raw().execute("(query ...)") — the escape hatch is still
there but explicitly opted into.
2. Retract-then-assert for scalar property updates
Minigraf is bi-temporal — every assert is appended to history,
never overwritten. Naively calling set_vertex_property(id, "status", "posted") after set_vertex_property(id, "status", "pending")
leaves both facts queryable. To give scalars the "current value"
semantics the rest of the codebase expects, set_vertex_property
now:
- Queries the current value for
(entity, attr), - Retracts every result (
(retract [[id :attr v]])), - Asserts the new value.
This is 2–3 Datalog ops per set instead of 1. The historical trail
is preserved — Minigraf still has every value the attribute
ever held, queryable via :as-of. Only the current view (the
default query) reflects the latest set. That's free time-travel for
ledger audits.
3. Caught a pre-existing bug: find_by_external_id graph fallback
GraphLedgerStore maintained an in-memory
Mutex<HashMap<external_id, TransactionId>> cache, populated lazily
on writes. In Phase 14 the cache was always seeded by the producer
of the transaction in the same process, so cache misses never
mattered. With persistence, a fresh GraphLedgerStore::with_handle
on a previously-written .graph file has an empty cache — and
find_by_external_id returned None for transactions that
actually existed in the graph.
Fixed: on cache miss, scan ledger_tx vertices via
handle.vertices_of_type(), read each external_id property, and
populate the cache on the way past. Honest about the cost (linear
in transaction count for the first miss after a restart), but
correct.
4. License rebase from MPL-2.0 to MIT OR Apache-2.0
indradb-lib is MPL-2.0; Phase 14 documented the operator-facing
implications of MPL-2.0-at-file-level. minigraf is MIT OR
Apache-2.0, strictly more permissive. The thorough license note
in op-graph/src/lib.rs (and Phase 14's progress doc) can be
loosened: there is no longer an MPL-tainted backend in the
operator's primary deployment story. Operators who still want to
swap the store implementation entirely can do so — the trait
seams on op-ledger / op-webhook / op-reconciliation haven't
moved.
5. JSON property codec preserved
set_vertex_property still takes a serde_json::Value and
get_vertex_properties still returns serde_json::Map<String, Value>. Internally:
- Scalars (Null / Bool / Number / String) map directly to
minigraf::Value::{Null, Boolean, Integer, Float, String}. - Arrays and objects serialize to a
json:-prefixed string and are re-parsed on read. (Minigraf has no native nested-JSON value.)
This preserves the codec contract documented in the Phase 14 doc.
The json: marker is internal — mg_to_json strips it before
returning.
6. Backend is fixed at construction time, not feature-flagged
Phase 16's earlier draft enum-dispatched between Memory and
Persistent variants of an internal Backend enum. With Minigraf,
both modes are first-class on the same type (Minigraf::in_memory()
vs Minigraf::open(path)), so the dispatch is just a constructor
choice. Less code, fewer match arms, no feature flags.
What this crate does NOT do
- No clustering / multi-process at the storage layer. Minigraf
is single-process embedded by design. Operators who want
horizontal scale plug in a different
LedgerStore(and friends) behind a remote datastore — the trait surfaces are shaped for that explicitly and Phase 12/13's docs say as much. - No automatic schema migration. Adding properties is safe (Datalog is schemaless w.r.t. attributes). Removing or renaming attributes is a Datomic-style retract job operators run themselves.
- No automatic indexing.
vertices_of_typewalks all vertices and filters; for our reference-impl volumes this is fine. A custom store impl over a tuned datastore handles high-cardinality types. - No transaction-time clock control yet. Bi-temporal queries
(
:as-of N) are available via theraw()escape hatch; the typed facade doesn't expose them. Coming in a later phase if / when a use case lands.
Bugs caught during construction
-
QueryResultvariant names. First draft assumedQueryResult::Query(rows)/Transact(_). The actual variants areQueryResults { vars, results },Transacted(tx_id),Retracted(tx_id),Ok. The internalQueryRows::fromadapter handles all four. -
EntityIdis justUuid. Earlier draft calledeid.into_uuid()— the type is a literalpub type EntityId = Uuid. Direct value use. -
Keywords come back with their colon. Minigraf returns
Keyword(":name"), notKeyword("name"). Theget_vertex_propertiesadapter strips the leading colon so callers query bare attribute names. Without this fix every property read returnedNone. -
Bi-temporal history made
seta no-op for "current value" semantics. Without retract-then-assert, two writes to:statusleft both"pending"and"posted"queryable, andmark_postedappeared not to have run. See Decision 2. -
The
find_by_external_idcache-only path was a bug Phase 14 never noticed because every process always wrote what it later read. Surfaced immediately under persistence. See Decision 3.
Honest concerns going into Phase 17+
-
Query cost in user units. Each
set_vertex_propertyis a query + zero-or-one retract + one transact. For a transaction with many entries that gets multiple property updates, this is noticeably more Datalog work than IndraDB's one-call set. For the reference impl it's still fast (the workspace test suite runs in seconds). For production volumes operators care about, the trait seam allows swapping in a tuned store. -
No bulk-insert primitive. Each create / set goes through a
(transact ...)Datalog string. Minigraf doesn't currently expose a programmatic bulk-insert that bypasses Datalog parsing. The cost is real but bounded — measured impact on the existing test suite was unobservable. -
Edge property API still defined but minimally tested.
set_edge_propertyworks (it sharesset_entity_propertywith vertices), but the Phase 14 / 15 stores don't currently set any edge properties. No regression risk; just noting the surface is less battle-tested than the vertex side. -
File format is Minigraf's, not OpenPay's. A
.graphfile written byop-graph v0.16is only readable by another process linking the same minigraf version. Operators who want a long-lived archival format export to a portable serialization separately. Documented; not in scope to solve here. -
Bi-temporal history grows unboundedly. Every retract is itself a fact in the history. For a long-running deployment, the
.graphfile grows. Compaction is something Minigraf supports butop-graphdoesn't yet expose. Honest follow-on.
Cumulative state
| Phase | Crate(s) | Tests (observed) | LOC (approx) |
|---|---|---|---|
| 1 | op-core | 19 | ~600 |
| 2 | op-iso20022 (pre-camt053) | 39 | ~1,400 |
| 3 | op-emv | 51 | ~1,800 |
| 4 | op-rails-card | 46 | ~2,100 |
| 5 | op-rails-a2a | 66 | ~3,200 |
| 6 | op-fraud | 65 | ~2,400 |
| 7 | op-vault | 51 | ~2,600 |
| 8 | op-ffi-swift | 44 | ~2,700 |
| 9 | op-ffi-jni | 69 | ~2,950 |
| 10 | op-wasm | 35 | ~2,200 |
| 11 | op-orchestrator + examples | 90 | ~4,150 |
| 12 | op-ledger | 69 | ~2,540 |
| 13 | op-webhook | 106 | ~3,304 |
| 14 | op-graph (original, IndraDB) | 74 | ~3,696 |
| 15 | op-reconciliation (+ iso20022 statement view + op-graph store) | 25 | ~1,916 |
| 16 | op-graph rewrite on Minigraf + persistence | (net +1 vs Phase 15: −3 IndraDB-internal unit + +4 persistent integration) | +500 net |
| Total | 810 | ~38,056 |
What's next (Phase 17+ candidates)
- CAMT.053 XML serde round-trip vector + conformance test (rolled over from Phase 15).
Camt054Source— intra-day notifications.- Matched-pair graph enrichment — emit
statement_line --reconciles--> ledger_txedges from matched pairs, now with the persistence to keep them. - Time-travel queries on the ledger. Minigraf's
:as-ofis free; expose a typed helper likebalance_as_of(account, tx_count) -> Moneyfor auditors. - History compaction — surface Minigraf's compaction so long-running operators can prune fact history they've already archived.
op-routerintegration — orchestrator consults reconciliation-task density per rail and prefers quiet rails.
The thesis stands: pure-Rust, Apache-2.0 reference payment stack; the graph that used to live only in RAM is now a single-file durable artifact, with free bi-temporal history.