Skip to main content
If you take one architectural promise away from these docs, take this one. The history Guild writes cannot be altered after it’s written. Not by application code. Not by an engineer in a database session. Not by a Postgres superuser without dropping the trigger entirely — and dropping the trigger is itself audited. This is what makes Guild’s ledger different from a transaction log on any other platform. The receipts are receipts.

The two tables

Both live in a shared audit schema. Both have the same immutability trigger.

The trigger

BEFORE means the trigger fires before the operation. RAISE EXCEPTION rejects the operation entirely. The exception propagates back to the caller as an error. The row is not modified. The transaction either aborts or proceeds without the mutation. This is not “the application is careful.” This is “the database is incapable of allowing it.”

What this means in practice

INSERT works normally. UPDATE and DELETE both raise an exception. The application code doesn’t need to “remember” to avoid mutation. Even if a developer accidentally writes an UPDATE against an audit table — through Prisma, through a hand-written query, through a migration script — the trigger catches it at execution time. The bug is loud, not silent.

How we represent “the status changed”

If a row’s status changes (e.g., a payment that was PENDING becomes SUCCESS), we don’t update the original row. We insert a fresh row representing the state transition.
Two rows for two events. The relationship is captured via references_id (a self-referential column). Reading the history is a query, not a stateful update. This pattern is fundamental to event sourcing. It’s slightly more verbose at write time and significantly more reliable at read time, audit time, and dispute time.

How we derive current state

Application code doesn’t read directly from payments_audit to compute a user’s current wallet balance. That would be expensive and error-prone. Instead, derived state lives in the payments schema (payments.wallets, payments.wallet_transactions, etc.) and is updated transactionally alongside the audit insert.
The transaction either commits both or neither. The derived state is fast to read; the audit is permanent. If the derived state ever drifts from the audit (a bug, a data corruption), the audit is the source of truth. We can rebuild the derived state from the audit. The reverse is not true.

The “we cannot edit history” claim, in depth

The phrase is precise. We make it because the database guarantees it. Could a sufficiently determined operator drop the trigger? Yes — but:
  1. The trigger drop itself is an audit-loggable event at the Postgres level (the pg_audit extension is enabled).
  2. The deploy that drops the trigger would be reviewed in code.
  3. The before-and-after database fingerprints would differ in a detectable way.
  4. The lenders who rely on Guild’s audit have contractual access to spot-check the database fingerprints, and they would.
So the claim is “structurally immutable under normal operation, contractually verifiable that nobody dropped the trigger.” That’s stronger than “we promise we won’t change it.”

What this enables for partners

A bank reads a Guild snapshot for a borrower. The snapshot says: “Trusted tier. 38 completed jobs in the last 90 days. ₦458K in inflows. 12 distinct counterparties.” The bank’s underwriting model can take that signal at face value because:
  • The aggregates come from a database with structural immutability.
  • The aggregates are signed by Guild with our Ed25519 key.
  • The signature can be verified against our published public key.
  • The user explicitly granted consent for this read.
  • The snapshot includes a timestamp; the bank can detect a stale or replayed snapshot.
The trust path is end to end. No hand-wave.

What this enables for disputes

A Job Seeker disputes a payment: “I worked four days, he only paid for three.” The audit ledger shows:
  • Four attendance_marked rows in channel_audit, each with a request ID.
  • Three WALLET_CREDIT rows in payments_audit, each with a OPay transaction reference.
Either the attendance was marked four times and one payment is missing — in which case the employer owes a payment — or it was marked three times and the dispute is unfounded. The data settles the dispute, not the testimony.

Retention

The audit tables are retained indefinitely. The financial regulators we expect to interact with (CBN, SEC) have 7-10 year retention requirements; indefinite retention satisfies that. Lender contracts may impose longer requirements; indefinite still satisfies. Storage cost is linear with platform volume. At 10M monthly active users and average per-user activity, the audit tables grow at a predictable rate that fits comfortably in modern Postgres deployments. Partitioning by month gives operational flexibility.

What’s NOT in the audit ledger

Things you might expect:
  • Application logs (Pino lines). These are operational diagnostics, retained 90 days. Not authoritative.
  • Voice call audio (R2). Retained 30 days, then auto-deleted. Transcripts may be longer.
  • WhatsApp message contents. Stored separately in whatsapp.messages; subject to a 365-day retention; PII-redacted.
  • Ephemeral state (OTPs, session tokens). In Redis with short TTLs.
The audit ledger is the immutable record of “what happened to a user’s identity, jobs, and money.” Everything else is supporting data.

Data flow

How the ledger fits in the broader data model.

Lender snapshots

What lenders read from the ledger aggregates.

KYC tiers

The PII story that sits alongside the ledger.