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 14 — op-graph graph-backed stores
Status: Draft v0.14 Date: 2026-05-18
Why a graph database, not Postgres
OpenPay's data is graph-shaped. A relational schema flattens that shape into joins; a graph schema preserves it. Two examples:
-
A ledger transaction is a hyperedge connecting 2+ accounts. "Which accounts did this transaction touch?" in relational SQL is
SELECT a.* FROM entries e JOIN accounts a ON e.account_id = a.id WHERE e.transaction_id = $1. In a graph it's one traversal of out-edges. The graph version is also faster on wide ledgers because the edges sit next to the transaction vertex on disk. -
Reversal chains are subgraphs. Tracing
tx → reverses → tx → reverses → txin relational SQL is a recursive CTE. In a graph it's a four-line loop. Same with fraud-graph queries ("which accounts share a device fingerprint via attempted chargebacks"), which become first-class operations.
We chose IndraDB because:
- Pure Rust, embedded
MemoryDatastoreavailable out of the box, - TAO-inspired (Facebook's graph datastore) so query semantics scale to the size of a real ledger,
- Mature (~10 years, 2.4k stars), latest release 5.0.0 (2025-08-16),
- Pluggable datastores — operators can swap in RocksDB for persistence later without touching our code.
License compatibility
This is the section I want to be honest about up front: IndraDB is MPL-2.0, and OpenPay is Apache-2.0. They are compatible in the way that matters for OpenPay operators: a downstream deployment that links against IndraDB from an Apache-2.0 binary does not have to relicense the rest of its code. The MPL-2.0 obligation is at the file level: any modifications to IndraDB's own source files must remain MPL-2.0. We never modify IndraDB; we only link.
This is the same composition model that ships in Firefox-embedded
applications across the industry. Operators who want a single-
license deployment can substitute a different LedgerStore /
WebhookStore implementation — the trait surfaces in op-ledger
and op-webhook exist precisely for that.
The license note is reproduced in crates/op-graph/src/lib.rs
and called out explicitly in this doc so no operator is surprised
later.
What shipped
A new crate crates/op-graph plus an integration test suite that
exercises a single shared GraphHandle across both ledger and
webhook stores. Total: 74 tests (64 unit + 10 integration),
~3,696 LOC. Cumulative across all 14 phases: ~870 tests,
~35,640 LOC.
| Module | Unit tests | LOC |
|---|---|---|
lib.rs |
— | 117 |
error.rs |
— | 105 |
graph.rs (typed facade over IndraDB) |
13 | 453 |
ledger_store.rs (GraphLedgerStore) |
20 | 1,135 |
webhook_store.rs (GraphWebhookStore) |
20 | 1,056 |
queries.rs (read-side helpers) |
11 | 380 |
tests/integration.rs |
10 | 450 |
| Phase 14 total | 74 | 3,696 |
Architecture
crates/op-graph/
├── Cargo.toml — deps: op-core, op-ledger, op-webhook,
│ indradb-lib = "=5.0.0" pinned tight
├── src/
│ ├── lib.rs — module roots + schema doc + license note
│ ├── error.rs — 9-variant Error with From<indradb::Error>,
│ │ From<indradb::ValidationError>,
│ │ From<serde_json::Error>, plus bridges to
│ │ op_ledger::Error and op_webhook::Error
│ ├── graph.rs — GraphHandle: typed facade over
│ │ Database<MemoryDatastore>. vtypes / etypes
│ │ string constant modules.
│ ├── ledger_store.rs— GraphLedgerStore: implements
│ │ op_ledger::LedgerStore. JSON property
│ │ codecs; HashMap<String, TransactionId>
│ │ external_id cache for idempotency.
│ ├── webhook_store.rs— GraphWebhookStore: implements
│ │ op_webhook::WebhookStore. Base64 codec for
│ │ binary payload + secret. CSV codec for
│ │ event_filters. Per-event-type endpoint
│ │ index.
│ └── queries.rs — 4 read-side helpers
└── tests/
└── integration.rs — 10 cross-domain tests
Vertex / edge schema
Vertex types: ledger_account, ledger_tx, ledger_ledger,
webhook_event, webhook_endpoint, webhook_attempt.
Edge types:
ledger_debit:ledger_tx→ledger_account(amount_minor,currency_code,currency_exponent)ledger_credit:ledger_tx→ledger_account(same props)ledger_in_ledger:ledger_txorledger_account→ledger_ledgerledger_reverses:ledger_tx→ledger_tx(the original)webhook_delivers:webhook_event→webhook_attemptwebhook_to:webhook_attempt→webhook_endpoint
Why edge-per-entry instead of entries-as-properties
Two options for storing transaction entries:
- Property blob: serialize the entire
Vec<Entry>as JSON and stash on the tx vertex. - Edge per entry: each entry becomes a typed edge from the tx vertex to the account vertex.
Option 1 is simpler but makes "which accounts did this tx touch?" an O(N) scan of every tx in the database. Option 2 makes it a single out-edge traversal — which is the whole reason we picked a graph database. So edge-per-entry it is.
Cost: a tx with k entries is k+1 vertices' worth of edges (plus
the ledger_in_ledger edge to the parent ledger). Acceptable.
Key design decisions
1. GraphHandle is the seam
The crate exposes a thin facade rather than letting callers pass
raw Database<MemoryDatastore> references around. Rationale:
the OpenPay schema (vtypes, etypes, property names) lives in one
place, and operators who want to swap to a different graph
database (e.g. via a custom datastore implementation) can do so
by replacing one file.
GraphHandle::raw() is the escape hatch for advanced queries
the facade doesn't model. Calling raw() opts out of stability.
2. Shared GraphHandle across stores
GraphLedgerStore::with_handle(handle.clone()) and
GraphWebhookStore::with_handle(handle.clone()) let both stores
write to one graph. This unlocks the cross-domain queries:
"this webhook event was emitted because this ledger transaction
was posted — show me both in one read." Demonstrated in
integration test #9.
3. Idempotency cache mirrors InMemoryLedgerStore
GraphLedgerStore keeps a Mutex<HashMap<String, TransactionId>>
seeded lazily on write. It's not authoritative — the graph is —
but it makes find_by_external_id O(1) instead of an
all-vertices scan. Production stores plugging in a persistent
datastore would replace this with an indexed property query
(IndraDB supports with_property_equal_to plus property
indexing); the in-memory facade doesn't bother because the cache
is fast enough.
4. JSON property codecs are explicit
We don't serde_json::to_value(&account)? and stash the whole
struct as a blob. Every field is encoded explicitly with a known
property name and known type (currency_code as string,
amount_minor as i64, effective_at_unix_secs as u64). Reasons:
- Future schema evolution: when we add a property, we don't have to deserialize old blobs.
- Query-friendly:
VertexWithPropertyValueQueryworks against individual properties; a blob property is opaque. - Audit-friendly: an operator browsing the graph (via the indradb-server CLI) sees readable property names.
The cost is more code in the codec helpers, but they're trivial and test-covered.
5. Local base64 + filter-CSV codecs, not extra deps
The webhook payload and endpoint secret are Vec<u8>. JSON
can't carry raw bytes, so we encode. We wrote a ~60-line
base64 codec rather than adding the base64 crate — same
dependency hygiene as the local hex codec in op-webhook. RFC 4648
test vectors verified in base64_known_vectors.
The event-filter list is encoded as a CSV inside one property
(escaping , as \,). It could've been a JSON array, but
keeping it textual lets IndraDB indexing work on the full string
(future optimization) and avoids the JSON-property-of-arrays
edge cases.
6. Sealed Error::Invariant
When the graph is in an unexpected state (e.g. a tx vertex with
no debit/credit edges, or a property of the wrong JSON shape),
we raise Error::Invariant(String). Callers shouldn't normally
see these — they'd indicate either schema drift (someone wrote
through the raw IndraDB API bypassing our facade) or a bug in
our codec. The variant exists so we can be explicit instead of
panicking.
7. License-friendly module boundary
op-graph depends on indradb-lib, but op-ledger and
op-webhook do NOT — the trait surfaces they expose are
storage-agnostic. Operators who want to ship under a strict
Apache-2.0 hygiene policy can use op-ledger and op-webhook
with their own (Apache-2.0 or MIT) LedgerStore /
WebhookStore implementation, simply not depending on
op-graph.
What this crate does NOT do
- No graph query language. No Cypher / GQL / Gremlin.
IndraDB itself doesn't expose those; we expose typed Rust
helpers (the four functions in
queries.rs). - No persistence by default.
MemoryDatastoreonly. IndraDB supports persistent backends via itsrocksdb-datastorefeature (we don't enable it; operators choosing persistence opt in by depending onindradb-libwith that feature and passing their ownDatabase<RocksdbDatastore>to a custom store). - No cross-store transactionality. Writing a ledger transaction and a webhook event is two store calls. They're both backed by IndraDB so they share the underlying mutex, but there's no rollback if the second one fails.
- No streaming subscriptions. Operators poll or run periodic queries.
- No fraud-graph queries yet. The schema supports them
(everything is in one graph), but the typed helpers in
queries.rscover only the four ledger + webhook traversals. Fraud queries land in Phase 16 or later.
Bugs caught during construction
-
MPL-2.0 vs Apache-2.0 compatibility surprise. I initially assumed IndraDB was Apache-2.0 or MIT. Searching turned up MPL-2.0. License analysis added to the architectural doc to set operator expectations correctly.
-
IndraDB API drift between major versions. Earlier docs showed
outbound(limit: u32) -> PipeEdgeQuery. The 5.0.0 surface isoutbound() -> ValidationResult<PipeQuery>with.limit()and.t()as builder chains. Pinned to=5.0.0to avoid this surprise upstream. -
Orphan vertex leak on put_attempt. First draft of
GraphWebhookStore::put_attemptcreated the attempt vertex before verifying the event and endpoint vertices existed. If validation failed, an orphan vertex was left behind. Fixed to validate first, create after. -
Status property is not the tx's lifecycle state. When
mark_postedis called, we have to update the status property on the tx vertex, not just callTransaction::poston the in-memory copy. First draft updated the local struct but didn't write back; balance() then read the old status and miscounted. Fixed by writing the new status property after the state-machine method succeeds. -
Currencyconstants vs arbitrary codes. Mycurrency_from_props(code, exponent)first tries the seven curated constants (USD/EUR/BRL/INR/GBP/JPY/CNY) for the fast-path, then falls through toCurrency::try_newfor arbitrary codes. Without the fast-path the constants reconstructed viatry_newwould be!=to the original despite encoding the same currency, because the comparison sees them as semantically equivalent but structurally distinct values. (Actually they would be equal —CurrencyderivesPartialEqon its raw byte fields. But the curated path is faster and avoids the validation work, so we keep it.) -
Direction-as-property vs direction-as-edge-type. I initially considered storing
directionas a property on a singleledger_entryedge type. Decided against: making debit and credit separate edge types lets us filter by direction in a single query (out_edges(tx, "ledger_debit")) rather than out-edges + property filter. Trade-off: two edge types instead of one. Worth it. -
SpecificVertexQuery::single(uuid).outbound()?.t(ident)composition.outbound()returnsValidationResult<PipeQuery>;t()consumesPipeQueryand returnsPipeQuery. Took two tries to get the ordering right — the chain looks naturally?.t(...)but I initially wrote it as.t(ident)?(callington a Result, which doesn't compile).
Honest concerns going into Phase 15+
-
balance()is O(edges-on-account), not O(1). For high-throughput accounts (e.g. a corporate cash account with millions of transactions) this is slow. A real production deployment would cache the running balance and update it on every entry. Out of scope for the reference impl. -
list_due_retriesdoes a full-vertex scan. IndraDB doesn't (yet) give us a typed-vertex iterator at the embedded API level — we have to walkAllVertexQueryand filter by type and property. For a webhook system with tens of thousands of attempts this is fine; for millions it isn't. Fix: when IndraDB exposes typed scans, or when we plug in RocksDB and use property indexing. -
No cycle protection in
reversal_chainbeyond aHashSet. If an operator manually wirest1 → reverses → t2andt2 → reverses → t1(which shouldn't happen but the graph doesn't forbid), we detect the cycle and return what we've found so far. We don't error. Documented in the function comment. -
The license note is operator-facing. Anyone considering redistributing OpenPay needs to read it. We surface it in three places (lib.rs doc comment, Cargo.toml description, this progress doc) so no one is surprised.
Cumulative state
| Phase | Tests | LOC |
|---|---|---|
| 1 op-core | 19 | ~600 |
| 2 op-iso20022 | 43 | ~1,400 |
| 3 op-emv | 50 | ~1,800 |
| 4 op-rails-card | 46 | ~2,100 |
| 5 op-rails-a2a | 73 | ~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 | 71 | ~2,200 |
| 11 op-orchestrator + kiosk + e2e | 90 | ~4,150 |
| 12 op-ledger | 69 | ~2,540 |
| 13 op-webhook | 106 | ~3,304 |
| 14 op-graph | 74 | ~3,696 |
| Total | ~870 | ~35,640 |
What's next (Phase 15+ candidates)
op-reconciliation— diff ledger vs PSP / bank statements; produces per-discrepancy resolution tasks. Natural follow-on now that we have a graph-backed ledger to query.- Persistent IndraDB backend — wire up
rocksdb-datastorevia aGraphHandle::new_persistent(path)constructor. Mostly one file's worth of work. - Fraud-graph queries —
accounts_linked_via_chargeback,endpoints_sharing_secret_prefix,attempts_with_shared_ip. Two-hop traversals on the existing schema; no new edge types needed. op-routerintegration — when the orchestrator decides which rail to use, it can consult the graph for recently-failed endpoints and avoid them.- OpenTelemetry trace propagation — one trace id flowing intent → ledger → webhook → consumer.
- GraphQL adapter — expose the typed
queries.rshelpers as a GraphQL schema so operator dashboards can poll without building Rust.
The thesis stands: pure-Rust, Apache-2.0 reference stack with a license-compatible MPL-2.0 graph backend chosen because OpenPay's data is graph-shaped.