open-standards/openpay/docs/14-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

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:

  1. 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.

  2. Reversal chains are subgraphs. Tracing tx → reverses → tx → reverses → tx in 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 MemoryDatastore available 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_tx or ledger_account → ledger_ledger
  • ledger_reverses: ledger_tx → ledger_tx (the original)
  • webhook_delivers: webhook_event → webhook_attempt
  • webhook_to: webhook_attempt → webhook_endpoint

Why edge-per-entry instead of entries-as-properties

Two options for storing transaction entries:

  1. Property blob: serialize the entire Vec<Entry> as JSON and stash on the tx vertex.
  2. 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: VertexWithPropertyValueQuery works 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. MemoryDatastore only. IndraDB supports persistent backends via its rocksdb-datastore feature (we don't enable it; operators choosing persistence opt in by depending on indradb-lib with that feature and passing their own Database<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.rs cover only the four ledger + webhook traversals. Fraud queries land in Phase 16 or later.

Bugs caught during construction

  1. 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.

  2. IndraDB API drift between major versions. Earlier docs showed outbound(limit: u32) -> PipeEdgeQuery. The 5.0.0 surface is outbound() -> ValidationResult<PipeQuery> with .limit() and .t() as builder chains. Pinned to =5.0.0 to avoid this surprise upstream.

  3. Orphan vertex leak on put_attempt. First draft of GraphWebhookStore::put_attempt created 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.

  4. Status property is not the tx's lifecycle state. When mark_posted is called, we have to update the status property on the tx vertex, not just call Transaction::post on 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.

  5. Currency constants vs arbitrary codes. My currency_from_props(code, exponent) first tries the seven curated constants (USD/EUR/BRL/INR/GBP/JPY/CNY) for the fast-path, then falls through to Currency::try_new for arbitrary codes. Without the fast-path the constants reconstructed via try_new would 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 — Currency derives PartialEq on its raw byte fields. But the curated path is faster and avoids the validation work, so we keep it.)

  6. Direction-as-property vs direction-as-edge-type. I initially considered storing direction as a property on a single ledger_entry edge 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.

  7. SpecificVertexQuery::single(uuid).outbound()?.t(ident) composition. outbound() returns ValidationResult<PipeQuery>; t() consumes PipeQuery and returns PipeQuery. Took two tries to get the ordering right — the chain looks naturally ?.t(...) but I initially wrote it as .t(ident)? (calling t on 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_retries does a full-vertex scan. IndraDB doesn't (yet) give us a typed-vertex iterator at the embedded API level — we have to walk AllVertexQuery and 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_chain beyond a HashSet. If an operator manually wires t1 → reverses → t2 and t2 → 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-datastore via a GraphHandle::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-router integration — 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.rs helpers 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.