open-standards/openpay/docs/21-sota-sweep-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

267 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase 21 — SOTA sweep: refunds, disputes, observability, audit report
**Status**: Draft v0.21
**Date**: 2026-05-22
## Why
OpenPay's mission is to bring margin back to vendors by removing the
3–5% tax stack. To do that, the reference stack has to cover the
full payment-acceptance surface — not just the happy path. After
Phase 20 the books, the rails, the reconciler, and the routing
brain were all SOTA, but three operator-facing surfaces were still
missing: **refund** workflows, **dispute / chargeback** workflows,
and the **trace + audit-report** plumbing that auditors and SREs
ask for. Phase 21 closes those gaps.
No new external dependencies. Every new crate sits on the same
typed-store / graph-substrate / pluggable-backend pattern the rest
of the workspace already establishes.
## What shipped
| # | Item | Where |
|--:|---|---|
| 1 | `op-refund` crate — full/partial refunds, state machine, idempotency on `external_id`, pluggable `RefundStore` + in-memory ref impl | `crates/op-refund/` |
| 2 | `op-dispute` crate — chargeback / inquiry / representment workflow, normalized cross-network reason taxonomy, evidence refs, pluggable `DisputeStore` | `crates/op-dispute/` |
| 3 | OpenTelemetry-compatible trace propagation — `tracing` workspace dep, `#[tracing::instrument]` on the hot paths in orchestrator / ledger / webhook / reconciliation | `Cargo.toml`, `op-{orchestrator,ledger,webhook,reconciliation}` |
| 4 | History compaction — `GraphHandle::compact()` surfacing Minigraf's checkpoint API | `op-graph/src/graph.rs` |
| 5 | Audit report builder — `AuditReport::for_window(handle, start, end, now)` joining `ledger_tx` × `rail_attempt` × `reconciliation_task` in one graph pass | `op-graph/src/audit.rs`, `tests/audit_report.rs` |
Workspace at the end of Phase 21:
| Check | Result |
|---|---|
| `cargo build --workspace --all-targets` | **0 errors, 0 warnings** |
| `cargo test --workspace` | **861 passing, 0 failing** (+37 vs Phase 20) |
| `cargo clippy --workspace --all-targets` | **0 warnings** |
Test-count delta breakdown: op-refund +17, op-dispute +16, op-graph
audit_report integration suite +4.
## op-refund
A refund is its own lifecycle, not just a "negative transaction."
Operators need to track *intent* (the customer or merchant asked
for the money back), submit to the PSP, wait for approval, and
then post the ledger reversal when settlement is confirmed —
sometimes days later. Folding all of that into the ledger as raw
debits/credits loses the workflow.
### Domain model
`Refund` carries: a deterministic UUIDv7 `RefundId`, the
`original_tx_id` it reverses, the `Money` amount, a
`RefundReason`, optional operator `external_id` for idempotency,
optional `psp_refund_id` once submitted, an ordered `metadata`
list, request/decision timestamps, and a `Status`.
```rust
pub enum Status {
Requested,
Submitted { psp_refund_id: String },
Approved,
Settled { settled_at_unix_secs: u64 },
Declined { code: String, message: String },
Failed { code: String, message: String },
}
```
State transitions live as methods on `Refund` that enforce the
graph:
- `submit(psp_refund_id)` — `Requested → Submitted`
- `approve()` — `Submitted → Approved`
- `settle(at)` — `Approved → Settled`
- `decline(code, msg)` — `Requested|Submitted → Declined`
- `fail_after_approval(code, msg)` — `Approved → Failed`
Any other transition returns `Error::InvalidTransition`. Amounts
larger than the original tx (operators are supposed to enforce
this with `list_for_tx` + sum) return `Error::AmountExceeded`
when the caller opts in to validation.
### Idempotency
`RefundStore::create_refund` is idempotent on `external_id`. The
in-memory ref impl uses a body-equivalence helper
(`bodies_equivalent`) that does an order-insensitive comparison
of the `metadata` bag — operators retrying with the same logical
payload but a re-ordered metadata HashMap iteration get the
existing id back, not a mismatch. A genuine body change (amount,
reason, tx id, external id, metadata content) returns
`Error::IdempotencyMismatch(external_id)`.
### Storage trait
`RefundStore` mirrors `LedgerStore` / `WebhookStore`: a small
sync trait with explicit atomicity contract on the closure-driven
`update`. The closure receives `&mut Refund`, runs the state
transition, and the store commits *only* if the closure returns
`Ok(())`. A partial mutation must never be persisted. The
in-memory impl clones-stage-commit; a future Postgres impl would
wrap the closure in a transaction.
## op-dispute
Disputes are a different beast from refunds. A refund is operator-
or customer-initiated; a dispute is *issuer-* or *bank-*initiated
and carries evidence, deadlines, and a possible representment
loop. The cross-network taxonomy varies wildly — Visa Reason Codes
vs Mastercard vs Faster Payments "Confirmation of Payee" — so
the crate normalizes to a curated class while letting operators
keep the raw `network_reason_code` on the `Dispute`.
### Domain model
`Dispute` carries id, `tx_id`, `DisputeReason`, optional
`network_reason_code`, a `Vec<EvidenceRef>` (each carrying
`kind`, `url`, optional `note`, and `attached_at_unix_secs`),
optional `external_id`, deadlines, timestamps, and a `Status`:
```rust
pub enum Status {
Inquiry, // pre-chargeback; issuer asking for info
Chargeback, // money already pulled
Representment, // we're contesting the chargeback
Won, // representment succeeded
Lost, // representment failed
Accepted, // we accepted the chargeback (didn't represent)
}
```
Transitions:
- `attach_evidence(EvidenceRef)` — at any pre-resolution status
- `escalate_to_chargeback()` — `Inquiry → Chargeback`
- `represent()` — `Chargeback → Representment`
- `resolve_won()`/`resolve_lost()` — from `Representment`
- `accept()` — `Inquiry|Chargeback → Accepted`
### Storage trait
`DisputeStore` is the same shape as `RefundStore` (deliberate
parallelism — operators implementing the Postgres backend wire
both at once). Idempotency on `external_id`, closure-driven
`update` with atomicity contract, `list_for_tx` for fan-out.
## OpenTelemetry trace propagation
`tracing` is now a workspace dep, and the four crates an operator
typically wants to see in a flamegraph carry
`#[tracing::instrument]` on their hot path:
| Span | Where |
|---|---|
| `orchestrator.run` | `Orchestrator::run` — outer-most boundary; carries `intent_key`, `amount_minor`, `currency` |
| `ledger.post_transaction` | `InMemoryLedgerStore::post_transaction` — `tx_id`, `entry_count` |
| `webhook.dispatch` | `Dispatcher::dispatch` — `event_id`, `endpoint_count` |
| `reconciliation.reconcile` | `Reconciler::reconcile` — `window_start`, `window_end`, `tx_count` |
These are just span boundaries — no opinionated exporter, no
forced OTLP wiring. Operators install whatever
`tracing-subscriber` layer they prefer (Jaeger, Honeycomb,
stdout, `tracing-opentelemetry`). The crates themselves stay
exporter-agnostic.
## History compaction
Minigraf's `Database::checkpoint` collapses the bi-temporal log
once retracted assertions stop mattering. Phase 17 wired the
retract-then-assert pattern in but never surfaced compaction.
`GraphHandle::compact()` is the operator-facing thin wrapper:
```rust
handle.compact()?; // calls minigraf::Database::checkpoint internally
```
For the in-memory backend it returns `Ok(())` (no-op). For a
file-backed Minigraf store it does what you'd expect — fold the
journal, free the dead records. Test
`compact_returns_ok_on_in_memory_handle` in
`tests/audit_report.rs` exercises the no-op path.
## Audit report
Auditors and finance teams ask the same question every quarter:
*"for every ledger transaction in this window, show me the rail
that processed it, the reconciliation tasks that touched it, and
the status."* That join was already implicit across the typed
stores — Phase 21 surfaces it as a structured report on the
shared graph substrate.
```rust
let report = AuditReport::for_window(
&graph_handle,
start_tx_count,
end_tx_count,
generated_at_unix_secs, // operator-supplied for determinism
)?;
for entry in &report.entries {
println!(
"{} status={} rail={:?} driver={:?} tasks={:?}",
entry.tx_id, entry.status,
entry.rail, entry.driver,
entry.reconciliation_task_ids,
);
}
```
Each `AuditEntry` carries:
- `tx_id` and `external_id` (the idempotency-key join axis)
- `status` (lifecycle: pending / posted / archived)
- `posted_at_tx_count` and `effective_at_unix_secs`
- `settled_amount_minor` + `currency_code` (debit sum, single-
currency only; multi-currency txs surface as `None`)
- `rail` + `driver` (joined via `rail_attempt.external_id_hint`)
- `reconciliation_task_ids` (inbound `task_about` edges)
The report sorts ascending by `posted_at_tx_count` so it reads
like an audit log. `generated_at_unix_secs` is operator-supplied
so replays / test runs are byte-identical.
### Implementation note
`for_window` is a single-pass walk: build the
`external_id → (rail, driver)` index up front from all
`rail_attempt` vertices, then iterate `ledger_tx` vertices,
filter by `posted_at_tx_count` window, join. One graph, one
report, no cross-store mutex coordination.
## Honest concerns (carry-forward)
- **Refund → ledger reversal posting is the operator's job.** The
refund crate models the workflow but doesn't auto-post a
reversal entry when `Status::Settled` lands. That coupling
belongs in operator glue (which ledger? which reversal
account?). A future phase could ship a `RefundLedgerLink`
helper for the common single-ledger case.
- **Dispute → ledger reservation is also operator-side.** Many
networks debit the merchant the moment the chargeback opens.
We track the workflow; we don't auto-post the hold.
- **Audit report is read-only.** No "rebuild from journal"
capability yet — operators with a Minigraf file store get
bi-temporal time-travel via the existing `LedgerHistory`
APIs, but the audit report itself is a snapshot.
- **Trace context propagation between crates is not enforced.**
The instrumented spans nest correctly when callers come from
the orchestrator, but an operator wiring a standalone
reconciler against a wholly different orchestrator will see
detached span trees unless they set up an external context
propagator.
## Test totals
```
op-refund 17 tests (state machine 11, idempotency 3, store CRUD 3)
op-dispute 16 tests (state machine 10, evidence 2, store CRUD 4)
op-graph 4 tests (audit_report.rs — end-to-end join, empty,
inverted window, compact no-op)
----
+37 net
```
`cargo test --workspace`: **861 passing, 0 failing**.
`cargo build --workspace --all-targets`: **0 warnings**.
`cargo clippy --workspace --all-targets`: **0 warnings**.