open-standards/openpay/docs/26-graph-persistence-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

9 KiB

Phase 26 — One file, all stores: Minigraf-backed persistence everywhere

Status: Draft v0.26 Date: 2026-05-22

Why

A wrong turn corrected. The original plan for Phase 26 was "persistent backends — PostgreSQL *Store impls." That was the wrong instinct: OpenPay's architectural position is "no separate services," and the ledger / webhook / reconciliation / rail- telemetry stores were already running on Minigraf (single embedded .graph file, pure Rust, bi-temporal, no server). The only stores still defaulting to InMemory* were the four most recent ones — refund, dispute, settlement, idempotency.

The right Phase 26 is therefore extend the Minigraf-backed pattern to those four, so the deployable binary persists every operator-state to one file. No new dependencies, no networked DB, no schema migrations. Drop the .graph file into a backup, restart, you're whole.

What shipped

# Item Where
1 New vertex types refund, dispute, settlement_batch, idempotency_record op-graph/src/graph.rs::vtypes
2 New edge types refund_refunds, dispute_disputes, batch_includes (each pointing at the underlying ledger_tx when present) op-graph/src/graph.rs::etypes
3 GraphRefundStore — full RefundStore impl, idempotent on external_id, atomic update<F>(closure), JSON state + indexed properties op-graph/src/refund_store.rs
4 GraphDisputeStore — mirror shape for disputes; dispute --disputes--> ledger_tx edge op-graph/src/dispute_store.rs
5 GraphSettlementStore — batch JSON + per-entry batch_includes edges; list_open filters via the indexed status_code op-graph/src/settlement_store.rs
6 GraphIdempotencyStore — IdempotencyStore over Minigraf; UUIDv5 vertex ids keyed on the idempotency string for O(1)-equivalent lookup; CAS via internal Mutex lock op-graph/src/idempotency_store.rs
7 OrchestrationOutcome + nested types now derive Serialize / Deserialize (so commit can persist them) op-orchestrator/src/outcome.rs, idempotency.rs
8 AppState::from_handle(handle) constructor — every store wired off ONE shared GraphHandle op-server/src/state.rs
9 AppState::with_graph_path(path) — open or create a single .graph file; reopening recovers every fact op-server/src/state.rs
10 Integration test: write refund + dispute + batch + idempotency through one handle, drop, reopen at the same path, verify all four recover op-graph/tests/persistence.rs

Workspace at the end of Phase 26:

Check Result
cargo build --workspace --all-targets 0 errors, 0 warnings
cargo test --workspace 977 passing, 0 failing (+20 vs Phase 25)
cargo clippy --workspace --all-targets 0 warnings

Test-count delta: GraphRefundStore +7, GraphDisputeStore +4, GraphSettlementStore +3, GraphIdempotencyStore +5, persistence integration +1.

Architecture

                         one .graph file
                              │
   ┌──────────────────────────┼──────────────────────────┐
   │                          │                          │
   ▼                          ▼                          ▼
 GraphLedgerStore        GraphRefundStore         GraphIdempotencyStore
 GraphWebhookStore       GraphDisputeStore        GraphRailTelemetry
 GraphReconciliationStore GraphSettlementStore    (audit report queries them all)

Every store consumes the same GraphHandle. The audit report walks the unified graph in a single pass; no cross-store JOINs because everything is already in one substrate.

Vertex / edge schema additions:

  refund  ─refunds──►  ledger_tx
  dispute ─disputes─►  ledger_tx
  settlement_batch ─includes─►  ledger_tx (per entry)
  idempotency_record  (UUIDv5-keyed; no edges)

Why Postgres is not needed

Honest accounting of what a SQL/server DB would add over Minigraf for this workspace:

Capability Minigraf Postgres
ACID per-transaction yes yes
Multi-process access file-locked; needs a thin daemon for HA networked server
Bi-temporal time-travel native (substrate-level) DIY (audit tables + triggers)
Single-file deploy yes no (server + config + tablespace)
Schema-less storage of domain JSON yes partial (JSONB columns)
Backup is "copy a file" yes pg_dump + WAL replay
Pure Rust, no other runtimes yes requires libpq / a connection pool

The capability gap that would push toward a server DB is multi-host concurrent writes. Even there, the right answer for a reference stack is "ship a thin graph daemon if you need it" not "adopt a 30-year-old SQL server with all the operational baggage that brings." Phase 16 already made this call when picking Minigraf; Phase 26 extends that decision to the remaining stores.

Persistence model

// Single file, all stores:
let state = AppState::with_graph_path("/var/lib/openpay/data.graph")?;
let app = op_server::router(state);
axum::serve(listener, app).await?;

Reopen at the same path on the next start: every refund, dispute, batch, idempotency record, ledger tx, webhook delivery, rail attempt, reconciliation task is exactly as it was.

For tests / demos:

let state = AppState::new_in_memory();

Same trait surface, in-memory Minigraf; the only difference is where the bytes land.

Idempotency semantics

GraphIdempotencyStore mirrors the in-memory store's CAS contract with a small twist: the trait's release() method marks a slot as __released__ rather than deleting it. Minigraf is bi-temporal — retracts move the "current" view but the history is preserved. A subsequent reserve() for the same key sees a record whose body_signature is the sentinel string, treats it as "in-flight expired," and proceeds. This matches the production- guidance language already in the trait docs: "mark as expired-in- flight so analytics can spot leaked reservations."

Atomicity of update<F>(closure)

The closure-driven update contract is "the store commits only if the closure returns Ok(())." The graph stores honor this by load → stage → run closure → persist-on-Ok. Until the closure returns successfully, nothing has been written. Minigraf is sequential at the transaction layer, so the persist step is itself atomic.

The contract is identical to the InMemory* ref impls; only the substrate changed.

Honest concerns (carry-forward)

  • Linear external_id lookup. Body-equivalence checks scan all refund / dispute / settlement_batch vertices. Minigraf doesn't expose a generic secondary-index API yet; once it does, drop in a :find by external_id and the operator-visible API doesn't change. Acceptable in the meantime — even 100k records scan in milliseconds on the in-memory backend.
  • Idempotency vertices stay forever. No TTL eviction. A high-throughput operator running for years accumulates them. Plumb in a periodic release_expired_before(unix_secs) sweep in a follow-up; the trait already has the right shape.
  • Edge re-emission on update. sync_entry_edges checks for existing edges before creating; idempotent but O(existing edges) per update. For batches with thousands of entries operators may want to short-circuit by tracking which entries are new — current code re-checks the whole set every time.
  • OrchestrationOutcome serde is now in the type's public contract. We control the producer (the orchestrator) and consumer (the idempotency store), so the format is internal; but operators reading the JSON directly via the graph should treat it as serde_json::Value, not a stable schema.

Multi-process / multi-host

Single file is single writer by Minigraf's design. Operators running multiple op-server instances against the same data have two options:

  1. Sticky-route by tenant — each instance owns its own .graph file. Reasonable for SaaS deployments where tenants shard naturally.
  2. Front the graph with a daemon — a small server that owns the Minigraf handle and exposes its API over IPC. Out of scope for this phase; the trait surfaces already admit it cleanly (each Graph*Store takes a GraphHandle; replace it with a RemoteGraphHandle and the rest of the stack is unchanged).

Neither approach requires Postgres. The architecture deliberately keeps that door open.

Test totals

op-graph         +20 tests
                   GraphRefundStore         7
                   GraphDisputeStore        4
                   GraphSettlementStore     3
                   GraphIdempotencyStore    5
                   persistence integration  1
                                              ----
                                              +20 net

cargo test --workspace: 977 passing, 0 failing. cargo build --workspace --all-targets: 0 warnings. cargo clippy --workspace --all-targets: 0 warnings.