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.
200 lines
9 KiB
Markdown
200 lines
9 KiB
Markdown
# 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
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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
|
|
|
|
```rust
|
|
// 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:
|
|
|
|
```rust
|
|
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.**
|