Skip to main content

The event-ledger projection model

Three customer-facing surfaces — Wallet Passes, Incentives, and Loyalty — keep their lifecycle state the same way: as append-only events in the CDP event stream, with the current state of a pass, incentive, or points balance projected (folded) from that stream at read time. None of them adds a table; all three share the same vocabulary. This page explains the pattern itself: why these surfaces chose a ledger over a table, how concurrent writes stay ordered, and how you should read the resulting API responses.

The pattern: issue, update, void — one event family per surface

A lifecycle surface of this shape answers three questions — create the thing, change the thing, end the thing — and each answer is one event appended to the tenant’s cdp_events store, keyed by the surface’s id and carrying a payload on the event: The current state is never stored. What a GET returns for one pass is a projection folded over that pass’s event stream at the moment you ask: the issued snapshot seeds the record, each update applies on top in received order, and a void overlays the terminal lifecycle flag. Nothing is recomputed by a background job and nothing drifts — the response is the fold. One property of the fold is worth depending on: it is order-insensitive. Updates and voids are sorted by their received timestamp before folding, so a listing that reads the same events in a different row order still projects to the same state. An update or void event with no matching issued event is discarded — there is nothing to project onto.

Why a ledger instead of a table

A conventional design would give each surface its own table (wallet_passes, incentives, …) with mutable rows and a migration behind it. The ledger deliberately trades that away:
  • No migration surface. Adding, deleting, or reshaping a surface’s fields changes what the events carry, not the schema. The three surfaces exist today with zero tables owned by any of them.
  • The issued snapshot never mutates. What you issued stays exactly what you issued, with its timestamp, forever. An audit question — what did this pass say when the customer saved it? — is answered by the original event, not reconstructed from edit history.
  • Changes are deltas, with a generation counter. An update event carries only the fields that changed, and every accepted update bumps the pass’s generation by one. The issued snapshot is the base; updates fold on top of it. That counter is the pass-refresh signal: a push-registration step that needs to tell Apple or Google Wallet “this pass changed” keys off the generation moving.
  • Reads never lie about staleness. Because every response is computed from the full event history at request time, there is no cache layer and no projection job that can fall behind. An attribution backfill or a replayed webhook feeds the projection the moment its events land.
The trade is that state is recomputed on read rather than read from a row. For these surfaces — per-pass and per-incentive reads bounded by an id filter, balance folds bounded by one contact’s event history — that is cheap, and the ledger reads are idempotent request-path reads.

Serialization: one advisory lock per object

Append-only does not mean unordered. Two concurrent updates to the same pass, or a redeem and a void racing on the same incentive, still need a winner. Each surface takes a per-object PostgreSQL advisory lock (pg_advisory_xact_lock) scoped to the one object being mutated — the pass id, the incentive id, or the contact id — inside the same transaction that re-reads the fold, validates the transition, and appends the event:
  1. Take the advisory lock for that one pass / incentive / contact.
  2. Re-read the object’s event stream and fold the current state.
  3. Validate the transition against the fold (is the pass still active? is the balance sufficient?).
  4. Append the new event — or roll back, releasing the lock with the transaction.
A concurrent request for the same object blocks on the lock and re-folds after, so it sees the winner’s event and validates against the post-transition state. Concurrent requests for different objects never touch each other’s locks. Two consequences for integrators: an update that races a void and loses comes back as a 409 CONFLICT naming the pass’s current status, and a spend that exceeds the available balance is rejected with the available and requested amounts. The lock and the fold live on different sides of a deliberate seam. The controller owns the transaction, the advisory lock, and the event-store reads/writes; the ledger module — the event-name constants, the payload builders, and the fold itself — is pure. It takes event rows and returns projected records, importing no database or HTTP machinery. The projection rules (order-insensitivity, patch semantics, terminality) are therefore plain unit-testable functions, and the locking code contains no business logic to shadow them.

Where else the pattern runs — shared vocabulary, surface-specific rules

Wallet passes, incentives, and loyalty are three instances of one design, not three similar designs: What stays shared is the architecture; what stays surface-specific is the state machine folded on top of it. When a fourth surface of this shape appears (e.g. coupon-code issuance, which follows the same ledger discipline), you already know its storage story.

What you branch on as an integrator

  • Current state vs event history. Every GET returns the fold — a pass’s current content with updates applied, an incentive’s current status, a member’s current balance and tier. The underlying events stay inspectable in the contact’s event timeline (and are addressed directly through the CDP query and export surfaces if you need history for audit or a warehouse). Use the fold for “what is true now”; use events for “how did it get here”.
  • Void is terminal — treat it as such. A void event ends the pass or incentive; a second void, or an update after a void, returns 409 CONFLICT. To re-issue, create a new object. The same holds one level up: a replayed issuance request (same idempotency key) returns the original object, folded to its current lifecycle state — so a delayed retry after an update or void replies with the pass as it now stands, not with stale issued-time data.
  • generation is the change signal. On a wallet pass, every accepted update increments generation. Compare it across polling to detect a content change, and treat each bump as the trigger a pass-refresh push to Apple or Google Wallet would key off — you do not need to diff content fields to learn that something moved.