open-standards/openpay/docs/16-persistent-graph-progress.md
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
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.
2026-05-23 07:58:09 -04:00

382 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
1. Embedded ("in-app like SQLite"), not a separate server.
2. Pure Rust. No heavy C/C++ build chains.
3. Real graph DB. Not "use SQL with self-joins."
4. Single-file durability ("like SQLite").
5. 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
```rust
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:
```rust
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
```rust
// 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:
1. **`graph_handle_persists_vertex_and_edge_across_reopen`** —
the low-level GraphHandle primitive.
2. **`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.
3. **`webhook_event_and_attempt_round_trip_across_reopen`** — the
same for webhook events and delivery attempts.
4. **`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 keeps `record_report`
idempotent 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:
1. Queries the current value for `(entity, attr)`,
2. Retracts every result (`(retract [[id :attr v]])`),
3. 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_type` walks 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 the `raw()` escape hatch; the
typed facade doesn't expose them. Coming in a later phase if /
when a use case lands.
## Bugs caught during construction
1. **`QueryResult` variant names**. First draft assumed
`QueryResult::Query(rows)` / `Transact(_)`. The actual variants
are `QueryResults { vars, results }`, `Transacted(tx_id)`,
`Retracted(tx_id)`, `Ok`. The internal `QueryRows::from` adapter
handles all four.
2. **`EntityId` is just `Uuid`**. Earlier draft called
`eid.into_uuid()` — the type is a literal `pub type EntityId =
Uuid`. Direct value use.
3. **Keywords come back with their colon.** Minigraf returns
`Keyword(":name")`, not `Keyword("name")`. The
`get_vertex_properties` adapter strips the leading colon so
callers query bare attribute names. Without this fix every
property read returned `None`.
4. **Bi-temporal history made `set` a no-op for "current value"
semantics.** Without retract-then-assert, two writes to
`:status` left both `"pending"` and `"posted"` queryable, and
`mark_posted` appeared not to have run. See Decision 2.
5. **The `find_by_external_id` cache-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_property` is 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_property` works (it shares `set_entity_property` with
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 `.graph` file
written by `op-graph v0.16` is 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 `.graph` file grows. Compaction is something Minigraf
supports but `op-graph` doesn'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_tx` edges from matched
pairs, now with the persistence to keep them.
- **Time-travel queries on the ledger.** Minigraf's `:as-of` is
free; expose a typed helper like
`balance_as_of(account, tx_count) -> Money` for auditors.
- **History compaction** — surface Minigraf's compaction so
long-running operators can prune fact history they've already
archived.
- **`op-router` integration** — 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.