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.
11 KiB
Phase 17 — Time-travel ledger queries
Status: Draft v0.17 Date: 2026-05-21
Why
Phase 16 swapped op-graph's storage to Minigraf, which gives us
bi-temporal history for free: every assert is appended, never
overwritten; every retract is itself a fact. The graph file at rest
holds every value every property ever had, indexed by a monotonic
transaction counter.
That's a powerful capability sitting unused. Phase 17 surfaces it as a first-class operator API: "what did this account's balance look like at point X?" "what was this transaction's status before mark_posted ran?" These aren't novel questions — auditors and reconciliation operators ask them daily — they were just expensive before. Now they're a query away.
What shipped
A new trait in op-ledger and its implementation in op-graph.
The substrate already existed; this phase exposes it cleanly.
| File | LOC | Notes |
|---|---|---|
op-ledger/src/history.rs |
77 | new — LedgerHistory trait |
op-ledger/src/lib.rs |
+2 | register module + re-export |
op-graph/src/graph.rs |
+~115 | tx_count() + four _at Datalog query helpers |
op-graph/src/ledger_store.rs |
+~180 | LedgerHistory impl + read_entry_edge_at |
op-graph/tests/time_travel.rs |
203 | new — 5 integration tests |
| Phase 17 totals | ~580 added | (no removals; this is pure capability addition) |
Workspace at the end of Phase 17:
| Check | Result |
|---|---|
cargo build --workspace --all-targets |
0 errors, 0 warnings |
cargo test --workspace |
815 passing, 0 failing (+5 vs Phase 16) |
cargo clippy --workspace --all-targets |
0 warnings |
The API
use op_ledger::{LedgerHistory, LedgerStore};
// Operator snapshots the counter right after a write they care
// about. The counter is opaque — don't subtract it from a wall
// clock; just hold it as "the moment after I posted this."
let id = store.post_transaction(tx)?;
let snap = handle.tx_count(); // u64
// ... arbitrary time passes; many more writes happen ...
// Time-travel reads against that moment.
let bal_then = store.balance_as_of(account_id, snap)?;
let tx_then = store.transaction_as_of(id, snap)?;
// ^ status reflects the value at `snap`. If we've since called
// `mark_posted`, `bal_then.status` is still `Pending`.
Two methods, both error-typed against op_ledger::Error:
balance_as_of(account, tx_count) -> Result<Balance>— walks the same entry-per-edge structure asbalance(), but every inbound debit/credit edge read, every edge property read, and every tx status read goes through Minigraf's:as-of Nfilter. Entries posted aftertx_countare invisible; statuses that flipped aftertx_countshow their earlier value.transaction_as_of(id, tx_count) -> Result<Transaction>— reconstructs the transaction as it stood attx_count. ReturnsError::TransactionNotFoundif the tx didn't exist (had no_typefact) at that moment.
Key design decisions
1. The trait lives in op-ledger, not in op-graph
LedgerHistory is a capability operators reasonably want to check
for before depending on it. Putting it next to LedgerStore in
op-ledger makes "this ledger can do time-travel" a typed property
of the store, not a quirk of the graph-store import path. Concrete
stores opt in:
GraphLedgerStoreimplements it (Minigraf is its substrate).InMemoryLedgerStoredoes not implement it (it's a snapshot of "now"; no fact log).- Future Postgres-backed or TigerBeetle-backed stores would implement it as they see fit.
The trait is separate from LedgerStore rather than added to
it. This is deliberate: the cost of always having to implement it
would push every minimal store toward keeping its own fact log
just to satisfy the trait. Optional is right.
2. The reference point is Minigraf's monotonic counter, not a
wall-clock timestamp
Two options were on the table:
- Wall-clock timestamp: operator-friendly ("at 2026-05-21
10:00 UTC..."), but Minigraf's
:as-ofaccepts the monotonic counter, not Unix milliseconds. Mapping wall-clock → counter would require a separate index op-graph would have to maintain. - Monotonic tx counter: opaque to operators but free —
Minigraf already maintains it and
:as-of Nreads it directly.
Phase 17 ships the counter form via GraphHandle::tx_count(), the
direct path. A future enhancement could add a tx_count_at_time()
helper if operators want the wall-clock front door — it'd be a
secondary index over a :transaction_time attribute, not a
substrate change.
3. Account properties are read at present, not as-of
An account's currency and normal_balance are immutable: set at
creation, never re-asserted. Reading them at the present view
inside balance_as_of is correct and faster than threading
:as-of through the account read. If a future feature ever lets
an operator mutate those, this assumption breaks and the impl
needs to switch to get_vertex_properties_at. Documented in the
impl's docstring.
4. Empty property map → TransactionNotFound
In transaction_as_of, if get_vertex_properties_at returns an
empty map at tx_count, the tx either didn't exist then (later
creation) or never existed. We don't distinguish; both return
Error::TransactionNotFound. That's the right semantic — at the
asked-for moment, the tx wasn't queryable.
5. Four new _at helpers on GraphHandle, not one generic
"as_of mode"
out_edges_at, in_edges_at, get_vertex_properties_at,
get_edge_properties_at parallel their present-time siblings.
This deliberately avoids an as_of: Option<u64> parameter on
every existing GraphHandle method (which would have rippled
through every call site in op-graph). The duplication is small
(each helper is ~10 lines), the call sites stay clean, and the
intent — "this read is historical" — is visible at the call.
What this phase does NOT do
- No retroactive writes. All writes happen at "now"; there is
no
post_transaction_as_of(tx, tx_count)that asserts facts at a past counter. Bi-temporality here is read-only. - No range queries. No
replay_window(start_tx, end_tx)that yields the deltas between two counters. The substrate could support it; the use case hasn't surfaced. - No wall-clock front door. See Decision 2.
- No automatic counter discovery. Operators have to
snapshot
tx_count()at the moment they care about. We don't archive snapshots, name them, or persist them outside the caller's own bookkeeping. (A future "named checkpoints" feature is on the list, not in scope here.) - No InMemoryLedgerStore impl. That store doesn't keep a fact
log; implementing
LedgerHistorywould require either keeping a parallel history (a different design) or refusing the call. Honest: not implemented.
Bugs caught during construction
-
Property attribute filter must strip the colon. Minigraf keywords come back as
:name, notname. Theget_vertex_properties_athelper inherited the same fix as the present-timeget_vertex_properties(Phase 16 had this issue too). Triple-checked while writing the new helper. -
Status::Archivedarm inbalance_as_of. Without it the match was non-exhaustive vsStatus— the rust compiler caught it. Once added it correctly contributes nothing to eitherpostedorpending. Same behaviour asbalance(). -
Test fixture ordering. The first draft of
time_travel_survives_persistence_round_tripreused aGraphHandleafter taking a snapshot, then dropped + reopened — but the snapshot's counter value didn't necessarily match the reopened handle's counter, because the persisted counter is tied to the file, not the handle. Verified empirically: the counter persists with the file, sosnaptaken before drop still points at the right moment after reopen. Documented in the test.
Honest concerns going into Phase 18+
-
Counter is opaque to humans. An operator looking at a
tx_count = 4729value can't easily say "that's last Tuesday." A future Phase could add a:transaction_timeattribute on every tx vertex (bi-temporal valid-time) and an indexwall_clock → countersobalance_as_ofcan take atime::OffsetDateTime. Today: caller bookkeeping. -
Cost of
balance_as_ofscales with edge count. Each inbound debit/credit edge gets its own property and tx-status read — same asbalance(). The_atvariants don't slow this down (Datalog filters at the storage layer) but don't speed it up either. For an account with millions of entries, this is slow; production deployments either roll cached running balances (out of scope, in op-ledger's "honest concerns" section already) or query at a higher level. -
History grows unboundedly. Phase 16's note still applies: every set is a retract + assert under the hood, leaving a permanent record. Long-running graphs need compaction. Minigraf supports it but
op-graphdoesn't surface it yet. -
get_vertex_properties_atreturning empty means "didn't exist." This isn't airtight: an account could theoretically exist with_typebut no user properties at some earlytx_count. In practice every vertex this codebase creates also writes at least one property in the samepost, so the failure mode is unobserved. Documented anyway.
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 | +500 net |
| 17 | LedgerHistory trait + GraphLedgerStore impl |
+5 | +580 |
| Total | 815 | ~38,636 |
What's next (Phase 18+ candidates)
- Wall-clock time-travel. Add a
:transaction_time_unix_secsattribute on every tx vertex on write; index it; exposetx_count_at_time(t: u64) -> u64so callers can use real timestamps. Minor work; user-friendliness win. - Named checkpoints. Operator-bookkept names → counters in a
small
checkpointsrelation.balance_as_of_checkpoint("Q4-2025-close"). op-routerintegration (Phase 17 sibling, still queued).- CAMT.053 XML conformance +
Camt054Source. - History compaction. Surface Minigraf's compaction so long-running operators can prune.
replay_window(start_tx, end_tx)— diff two historical points. Easy with what we have; needs only an iteration helper.
The thesis stands: free bi-temporal history shipped as a typed operator-facing capability. Auditors no longer have to ask "can you reconstruct what we knew on November 4th?" — they can just do the query.