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

7.8 KiB

Phase 23 — HTTP API server: vendors can actually deploy this

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

Why

Until Phase 22 OpenPay was a workspace of well-tested Rust crates with no HTTP surface. To bring margin back to vendors, operators have to be able to run the stack — and that means a deployable binary that speaks REST. Phase 23 adds op-server: an [axum]-based HTTP server that exposes the workspace's surfaces (intents, refunds, disputes, settlement, audit) as a clean REST API.

Second of three sequenced phases (22 → 23 → 24). Next phase wires concrete driver SDKs and reference rail integrations.

What shipped

# Item Where
1 op-server library + binary crate (axum 0.8, tokio 1.x, tower-http) crates/op-server/
2 AppState — Clone-able bundle of Arc<store>s + Arc<Orchestrator> + shared GraphHandle state.rs
3 ApiError — HTTP error envelope with stable JSON shape + per-crate From conversions mapping each domain error to the right status code error.rs
4 Intent endpoint — POST /v1/intents runs the orchestrator, returns per-attempt outcome list handlers/intent.rs
5 Refund endpoints — create / get / submit / approve / settle handlers/refund.rs
6 Dispute endpoints — create / get / attach evidence handlers/dispute.rs
7 Settlement endpoints — open / get / add entry / close handlers/settlement.rs
8 Audit endpoint — GET /v1/audit/report?start_tx=..&end_tx=..&generated_at_unix_secs=.. handlers/audit.rs
9 Health + readiness — GET /health always-200, GET /readiness reports store counts handlers/health.rs
10 Binary entry point — TCP bind + graceful Ctrl-C / SIGTERM shutdown + tracing-subscriber setup main.rs
11 Integration tests via tower::ServiceExt::oneshot — no socket, no listener, 9 e2e tests tests/api.rs

Workspace at the end of Phase 23:

Check Result
cargo build --workspace --all-targets 0 errors, 0 warnings
cargo test --workspace 906 passing, 0 failing (+9 vs Phase 22)
cargo clippy --workspace --all-targets 0 warnings

Endpoint surface

GET   /health                                  → 200 always
GET   /readiness                               → store counts
POST  /v1/intents                              → run orchestrator
POST  /v1/refunds                              → create refund
GET   /v1/refunds/{id}                         → fetch refund
POST  /v1/refunds/{id}/submit                  → Requested→Submitted
POST  /v1/refunds/{id}/approve                 → Submitted→Approved
POST  /v1/refunds/{id}/settle                  → Approved→Settled
POST  /v1/disputes                             → create dispute
GET   /v1/disputes/{id}                        → fetch dispute
POST  /v1/disputes/{id}/evidence               → attach evidence
POST  /v1/settlement/batches                   → open batch
GET   /v1/settlement/batches/{id}              → fetch batch
POST  /v1/settlement/batches/{id}/entries      → add tx to batch
POST  /v1/settlement/batches/{id}/close        → apply holdback + close
GET   /v1/audit/report                         → multi-store audit join

All POST endpoints take JSON. All responses are JSON; errors carry the stable shape:

{ "code": "not_found", "message": "refund not found: 0123...", "details": null }

State shape

pub struct AppState {
    pub orchestrator: Arc<Orchestrator>,
    pub refunds: Arc<InMemoryRefundStore>,
    pub disputes: Arc<InMemoryDisputeStore>,
    pub settlement: Arc<InMemorySettlementStore>,
    pub ledger: Arc<GraphLedgerStore>,
    pub reconciliation: Arc<GraphReconciliationStore>,
    pub telemetry: Arc<GraphRailTelemetry>,
    pub graph: GraphHandle,
}

Clone is cheap (everything is Arc or the graph handle's own Arc). axum extracts State<AppState> per request. Operators replace InMemory* with their own *Store impls (Postgres, TigerBeetle, etc.) by constructing a non-default state and calling router(state).

Error mapping

Each domain crate's Error enum has a From<Error> for ApiError implementation that picks the right HTTP class:

Domain variant HTTP
*::NotFound(_), op_ledger::*NotFound(_) 404
*::InvalidTransition { .. }, op_ledger::TerminalState 409
*::IdempotencyMismatch(_) 409
*::Invalid, *::AmountExceeded, currency / parse failures 400
op_orchestrator::*, op_graph::* (catch-all) 500

The body always uses the stable {code, message, details} envelope, regardless of which crate raised the error.

Why &impl SettlementStore matters here

The settlement engine's update<F>(... f: F) trait-method makes SettlementStore non-dyn-compatible. The HTTP handler resolves this by holding Arc<InMemorySettlementStore> directly (concrete type) — exactly the integration shape the engine expects. Same pattern for the refund and dispute stores.

Testing approach

tower::ServiceExt::oneshot invokes the router with a synthetic Request<Body> and returns the Response<Body> directly — no TCP socket, no listener, no port assignment. Each test in tests/api.rs builds an AppState::new_in_memory(), calls oneshot, asserts on status + JSON body. Fast (≈10ms per test), deterministic, no port conflicts.

Honest concerns (carry-forward)

  • No auth. Operators apply a tower::Layer (API-key middleware, JWT validator, mTLS). The reference binary ships unauthenticated — running it on a public IP is the operator's problem.
  • No TLS. Terminate upstream (Caddy / nginx / load balancer). The binary listens plain HTTP.
  • No request body size limits. Default axum limits apply. Operators wanting harder bounds add RequestBodyLimitLayer.
  • Reference handlers only. Refund-approve doesn't take a body; dispute lifecycle (escalate/represent/resolve) is currently represented only via the in-memory store API, not HTTP. Adding those endpoints is straightforward — followed the same pattern as the shipped ones.
  • No pagination. Endpoints that would benefit from pagination (audit window, list-open batches) currently return everything in the window. Real deployments add ?limit=&cursor= once the query layer in op-graph supports it.
  • No webhook delivery from the server. op-webhook exists in the workspace but its dispatcher isn't wired through the HTTP layer — operators run the dispatcher in a separate worker. The server publishes events to the store; the dispatcher polls / consumes them.
  • Synchronous orchestrator on an async handler. Orchestrator::run is sync; the axum handler awaits a tokio::task::spawn_blocking in production, but the reference path runs it inline on the async runtime. Fine for the in-memory ref adapter, painful for a real PSP-calling adapter.

Operator boot sequence

OP_BIND_ADDR=0.0.0.0:8080 \
RUST_LOG=info,op_server=debug \
cargo run --release -p op-server

The binary uses tracing-subscriber to emit structured logs; operators install their own subscriber (OpenTelemetry exporter, JSON-to-Datadog, etc.) by replacing the init block in main.rs or by linking against the library and calling op_server::router from their own composition root.

Test totals

op-server      9 integration tests
                  health 1, readiness 1, refund create/get/idem 3,
                  refund 404 1, dispute 1, settlement 1, audit 1,
                  bad-currency 1
                                                              ----
                                                              +9 net

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