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:

RecordTableChain shape
Financial events (the money ledger)financial_eventsGlobal append-only sequence — every event commits to the hash of the event before it.
Audit eventsaudit_eventsAppend-only sequence — previous_hash links each event to its predecessor.
Revenue-recognition eventsrevenue_recognition_eventsParallel chain over ASC 606 / IFRS 15 recognition moments.
Checkout sessionscheckout_sessionsPer-session chain across status transitions (open → complete / expired).
Customer portal sessionscustomer_portal_sessionsSame 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_events and audit_events reject UPDATE, DELETE, and TRUNCATE at 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.
Two honest caveats. Anchoring is enabled only when the deployment sets 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

EndpointAuthWhat it checks
GET /v1/audit/verifyAdmin API keyFull 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=MAdmin API keyA 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/verifyMerchant secret keyRecomputes a tax report's SHA-256 and compares it to the hash stored at generation time.
GET /v1/compliance/reports/:id/verifyMerchant secret keySame 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:

verify-report.sh
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.

The chain is one layer of the platform's integrity story. For key handling, webhook signing, and the rest of the security model, see Security.