channel_audit and payments_audit — with structural guarantees that make them legitimate as a source of truth for credit underwriting.
This page is what those tables actually contain, how data gets into them, and how the ML layer and the Partner API read out.
The substrate
The amber tiles are the substrate — the only places we accept “this happened.” The green tiles are the products that read from the substrate.The append-only contract
Two tables, two triggers, one promise.UPDATEd or DELETEd. Not by application code. Not by an engineer in a psql session. Not by a Postgres superuser short of dropping the trigger entirely (an action that itself is auditable and reviewed).
This is the structural promise behind every claim Guild makes about the ledger. “We can’t edit your history” is provable from the schema definition.
channel_audit — every interaction
Every interaction across every channel writes a row. Schema (abbreviated):
event_kind taxonomy is intentionally rich. Examples:
Every row carries the
request_id from the originating request. Tracing across services for a single user action is straightforward.
payments_audit — every kobo motion
Every motion of money writes a row. Schema (abbreviated):
verified_at timestamp from the reconcile guard. If it’s NULL on a row that should have it, something is wrong — and we’d never insert that row in the first place because the reconcile guard runs before the insert.
Examples of motion_kind:
A user’s full transaction history is a query over
payments_audit filtered by user_id, sorted by occurred_at. The dashboard pages all read from this view.
How a single event ripples through
Walking through one event end to end. Adekunle posts a job for ₦60,000. What’s notable in the sequence:- The
payments_auditrow is written before the OPay response is confirmed. Status is “held” via the absence ofverified_at. When the webhook confirms, we don’tUPDATE— we insert a fresh row with theverified_atpopulated, linked by the original ULID’s id. Append-only. - The
channel_auditrow is written immediately. The user did the action. We record it regardless of whether OPay later confirms. - The derived state in
jobsis updated withUPDATE— but derived state isn’t audit. Derived state can change. The audit can’t.
Schema ownership
Six application-owned schemas in one Postgres database.
Plus the audit tables (
channel_audit, payments_audit) which sit in a shared audit schema and are writable by every service.
Cross-schema queries are cheap inside one cluster. Each service has its own Prisma schema file declaring the schemas it owns.
How the standing model reads the ledger
The standing model’s feature pipeline is a SQL query that joins across the schemas. Roughly:users.standing_tier and users.standing_score column, with standing_recomputed_at timestamp.
A subsequent dashboard read or Partner API snapshot fetch reads from the derived column unless an invalidating event fired (a new completed job, a new review, a repayment). Lazy recomputation; nightly batch recomputation backstop.
How the matching model reads the ledger
Similar query, different shape. When a job is posted, the matching service:- Loads the job row from
jobs.job_postings. - Queries
identity.usersfiltered byactor_role = 'JOB_SEEKER'and within geographic range of the job’s location. - Joins each candidate with their derived standing tier, their last activity timestamp, their language preferences, their skill tags.
- Builds a feature vector per candidate.
- Runs the lambdarank model. Returns the top N ranked.
How the Partner API reads the ledger
The Partner API surfaces aggregates, never raw rows.Data retention
A user who deletes their account triggers a “right to erasure” workflow under NDPR. Personal identifiers in
identity.users are anonymised; the user’s channel_audit and payments_audit rows persist with the anonymised user ID. This preserves the audit integrity (the rows are still there, still immutable) while removing the personal link.
Cross-schema reads at the application layer
Most queries cross at least two schemas (identity + payments, jobs + identity, etc.). We do not maintain foreign keys across schemas — application-layer validation handles referential integrity. The reason: foreign keys add operational coupling (a DDL migration in one schema can lock another). Service ownership of schemas means each service migrates its schema independently. FKs would break that contract. The trade-off is that orphaned data is possible in theory (anaudit row referencing a deleted user). In practice, the right-to-erasure flow anonymises rather than deletes, so orphans don’t occur for real-world reasons.
A note on the partition path
channel_audit and payments_audit are partitioned by month using PostgreSQL declarative partitioning. As the platform scales, individual partition tables stay manageable in size; queries scoped to a recent time window (90 days) touch only a few partitions.
The partition path scales us comfortably through the first million users without restructuring. Beyond that, sharding by user-ID range is the next move — a known operation that doesn’t require an architecture change.
What to read next
Resilience & scale
How the ledger stays correct under load.
ML layer
The models that read this substrate.
Standing & the Ledger
The user-facing surface that the ledger powers.
Security & trust
The compliance framing of the append-only contract.