> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# The event-ledger projection model

> The shared architecture behind wallet passes, incentives, and loyalty: lifecycle state kept as append-only events in the tenant CDP event store, with the current state folded from that stream on every read — no per-surface tables, nothing to sync.

# The event-ledger projection model

Three customer-facing surfaces — [Wallet Passes](/channels/wallet-passes),
[Incentives](/concepts/incentives), and [Loyalty](/concepts/loyalty-program-model)
— keep their lifecycle state the same way: as **append-only events in the
[CDP event stream](/concepts/cdp-event-model)**, 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:

| Step           | Wallet passes                                                                                                  | Incentives                                                 | Loyalty                                                                  |
| -------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------ |
| Issue / earn   | `wallet_pass.issued` — carries the **full content snapshot** (type, title, fields, barcode, colors, recipient) | `incentive.issued` — carries the full fulfillment snapshot | an earn event per the program's earn rules, or `loyalty.points_adjusted` |
| Update / spend | `wallet_pass.updated` — carries **only the changed content fields**                                            | `incentive.redeemed` — consumes the reward                 | `loyalty.points_redeemed` — burns points                                 |
| End            | `wallet_pass.voided` — terminal                                                                                | `incentive.voided` — terminal                              | program reset appends a tombstone revision                               |

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:

| Shared (same vocabulary on every surface)                    | Surface-specific (lives beside the fold)                                                                                                                           |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| The tenant `cdp_events` store is the only persistence        | The event-name family and the payload each event carries                                                                                                           |
| Issued/earned event seeds the record; terminal event ends it | The projection's patch rules — for a pass, *presence* of a field in the update patch overrides (an explicit `null` clears the field; missing fields carry forward) |
| The fold is computed on read and is order-insensitive        | Loyalty's FIFO expiry lots: each earn opens a lot that expires under the program's expiry window, and redemptions consume oldest-first                             |
| The per-object advisory lock serializes mutating transitions | What a "terminal" event forbids next (a voided pass accepts no further updates; loyalty reset tombstones the *config*, not member history)                         |

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.

## Related reading

* [The CDP event model](/concepts/cdp-event-model) — the store every ledger here projects over.
* [Wallet Passes](/channels/wallet-passes) — the pass lifecycle, the issuance API, and the event-backed storage model.
* [Loyalty program model](/concepts/loyalty-program-model) — balances, tiers, and redemption as projections over the stream.
* [Incentives: catalog, ledger, and fulfillment](/concepts/incentives) — the issuance/redeem/void ledger behind rewards.
* [Idempotency and safe retries](/concepts/idempotency-and-safe-retries) — why replayed issuance is safe on an event-backed surface.
