Hash-chained ledger
Every financial event PAID records is cryptographically linked to the event before it. Rewriting history breaks the chain, and verification catches it. This page explains exactly what is chained, how each hash is computed, what that buys you — and, just as importantly, what it does not.
What is chained
The chain is not a single table trick. Several record families carry their own hash chain, each append-only by design:
| Record | Table | Chain shape |
|---|---|---|
| Financial events (the money ledger) | financial_events | Global append-only sequence — every event commits to the hash of the event before it. |
| Audit events | audit_events | Append-only sequence — previous_hash links each event to its predecessor. |
| Revenue-recognition events | revenue_recognition_events | Parallel chain over ASC 606 / IFRS 15 recognition moments. |
| Checkout sessions | checkout_sessions | Per-session chain across status transitions (open → complete / expired). |
| Customer portal sessions | customer_portal_sessions | Same per-object pattern as checkout sessions. |
How a hash is computed
A ledger event's event_hash is a SHA-256 over a pipe-delimited canonical string in
fixed field order: sequence ID, event ID, idempotency key, event type, amount (int64 minor
units), currency, direction, merchant ID, entity ID, previous_hash, and the event
metadata serialized with sorted keys. The genesis event is sequence 1 with a previous_hash of 64 zeros.
Because previous_hash is inside the hashed pre-image, editing any
historical row changes its hash and invalidates every event after it. Appends are guarded, not
best-effort: the ledger core verifies previous_hash and sequence continuity before
every write and refuses on mismatch — no auto-repair, no silent continuation, no operator
override. Events timestamped inside a closed accounting period are refused as well.
The per-object chains (checkout sessions, portal sessions, revenue recognition) use the same
construction at row granularity: hash_curr = SHA-256(hash_prev || canonical
pre-image). Every status transition sets hash_prev to the prior hash_curr, so a session's history is a verifiable sequence of its own states. The
audit chain hashes a canonical newline-separated key=value form binding every
semantically meaningful field — actor, IP address, user agent, request ID, status, and the
details/metadata maps as deterministic JSON — plus previous_hash. A silent edit to
any of those fields invalidates the chain at verification time.
What backs the chain
A hash chain in a mutable table is a promise, not a guarantee. Two more layers back it:
- Database-level immutability. Append-only triggers on
financial_eventsandaudit_eventsrejectUPDATE,DELETE, andTRUNCATEat the Postgres layer — with one documented exception: a session-level escape hatch (a Postgres GUC intended for test-fixture teardown) that any session holding the application's database credential can set before mutating. In production the API refuses to boot if any of these triggers is missing. - Signed checkpoint anchoring. On a 15-minute interval, the ledger tip (sequence + hash) is signed into an append-only checkpoint chain using an Ed25519 key held outside the database. Checkpoints chain onto each other, the anchor refuses to run if the tip sequence ever regresses (evidence of truncation), and verification checks the most recently anchored checkpoint: its Ed25519 signature, and the ledger's hash at that anchored sequence. Because event hashes chain, rewriting any row at or before that sequence changes the anchored hash and is caught. A signature failure means the latest checkpoint was edited; a tip mismatch means a ledger row was rewritten after it was anchored.
LEDGER_ANCHOR_SIGNING_KEY (base64 Ed25519) — unset, it is disabled with a boot
warning. And the checkpoint witness writes to the application log, which is not an external anchor: a witness abstraction exists in the code, but no off-box
transparency-log witness is implemented or configurable today — wiring one in is a code change,
not a deployment setting — so checkpoint history lives with the application. PAID does not
anchor to a public blockchain.Tamper-evident, not tamper-proof
Be precise about the threat model. The layers stack like this:
- The chain alone catches accidental corruption, application bugs, and naive single-row edits — any change makes recomputation fail. It does not stop an attacker with full database write access, who can rewrite a row and recompute every downstream hash, leaving a self-consistent chain.
- The append-only triggers stop accidental corruption and naive SQL edits, but not that attacker: the trigger function honors the session-level escape hatch above, and setting that GUC requires no special database privilege — any session that can execute SQL as the application role can set it and mutate. The SQL layer is a guardrail, not a wall.
- The signed checkpoints are the layer that catches that attacker: the Ed25519 signing key never lives in the database, so a rewritten ledger no longer matches checkpoints the attacker cannot forge.
The residual gap is an attacker who controls the database and the checkpoint history and the signing key — which is why the witness interface exists, and why "tamper-proof" would be an overclaim. What the system guarantees is that tampering does not stay silent: verification tells you the chain is broken, and where.
Verifying the chain
| Endpoint | Auth | What it checks |
|---|---|---|
GET /v1/audit/verify | Admin API key | Full audit chain: every recomputed event hash against the stored hash, every previous_hash link against its predecessor. |
GET /v1/audit/verify-range?from_seq=N&to_seq=M | Admin API key | A window of the chain. The verifier also pulls the event just before the window, so a tamper straddling the edge cannot hide. |
GET /v1/tax/reports/:id/verify | Merchant secret key | Recomputes a tax report's SHA-256 and compares it to the hash stored at generation time. |
GET /v1/compliance/reports/:id/verify | Merchant secret key | Same recompute-and-compare for compliance reports. |
The full-chain endpoints are admin-only — they exist for operators and auditors with platform
access, and return status ("valid" or "broken"), events_scanned, and on failure the first bad row's broken_at_seq, broken_at_id, and a machine-readable reason. As a merchant, the
report-verify endpoints are yours to call any time:
curl https://api.trustfabric.ai/v1/tax/reports/$REPORT_ID/verify \
-H "Authorization: Bearer $PAID_SECRET_KEY"
# 200 OK
# {
# "report_id": "<your report id>",
# "stored_hash": "<sha-256 recorded when the report was generated>",
# "computed_hash": "<sha-256 recomputed from the stored row just now>",
# "valid": true
# }Auditor evidence bundles
For a full independent audit, POST /v1/exports/soc2-evidence-bundle builds an
evidence bundle whose manifest is Ed25519-signed and embeds hash-chain proofs, and GET /v1/exports/soc2-evidence-bundle/:id/verification.py serves a standalone Python
verifier with the public keys embedded — your auditor verifies the bundle fully offline, no
network access and no trust in PAID's servers required. These routes are registered on every
deployment but disabled unless it sets SOC2_BUNDLE_ENABLED=true.