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’scdp_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
generationby 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.
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:
- Take the advisory lock for that one pass / incentive / contact.
- Re-read the object’s event stream and fold the current state.
- Validate the transition against the fold (is the pass still active? is the balance sufficient?).
- Append the new event — or roll back, releasing the lock with the transaction.
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
GETreturns 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. generationis the change signal. On a wallet pass, every accepted update incrementsgeneration. 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.
Related reading
- The CDP event model — the store every ledger here projects over.
- Wallet Passes — the pass lifecycle, the issuance API, and the event-backed storage model.
- Loyalty program model — balances, tiers, and redemption as projections over the stream.
- Incentives: catalog, ledger, and fulfillment — the issuance/redeem/void ledger behind rewards.
- Idempotency and safe retries — why replayed issuance is safe on an event-backed surface.