> ## 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.

# Troubleshooting: wallet pass generation counter and void conflict

> Resolve generation-counter drift, the 409 CONFLICT on update-after-void vs duplicate-issue idempotency, non-monotonic generation reads from two issuers, and stale deep-links that still open an old pass — across the active/voided/expired state edges.

# Troubleshooting: wallet pass generation counter and void conflict

Three symptoms send operators here. A holder's phone shows stale pass content
after your update succeeded. An update or void returns `409 CONFLICT`. Or a
deep-link that "top up points" flow minted yesterday opens the wrong pass. Each
maps to one of the lifecycle edges — `active`, `voided`, `expired` — the
[wallet pass lifecycle](/concepts/wallet-pass-lifecycle) defines; this page
names the check for each edge and the fix at the level you own.

For the issuance workflow itself and the field reference, start at the
[Wallet Passes channel guide](/channels/wallet-passes).

<Note>
  The wallet-pass operations you run — issue, update, void — are **tenant-owned**
  controls that move through your own ledger. A 409 or a drifted generation is
  the platform honoring the transition you already made, not a platform fault.
  The fixes below are yours to run; escalations go to the ledger read-path, not
  the wallet.
</Note>

***

## Cause table

Read the pass first with `GET /wallet-passes/:id`, or list with the filter the
channel guide documents (`?type=…&status=…&limit=…`). The returned `status`
and `generation` decide which row applies.

| Cause                                           | How to confirm                                                                                                                                                                                       | Fix                                                                                                                                                                                                                                         |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Client re-issued while holding a stale read     | `generation` on your read is higher than what the holder's phone reports — a second client issued the same pass while the first client's stale copy never fetched the newer event.                   | Re-issue once with the same `idempotency_key`, then have the holder refresh the pass link. The replay folds to the **current** state, so the refresh resolves.                                                                              |
| Update or void on a `voided` pass               | `GET` returns `status: "voided"`; the update/void you sent landed after the pass's terminal void event.                                                                                              | Treat void as permanent — issue a replacement pass. The error is correct: a voided pass can never move again.                                                                                                                               |
| Update or void raced another transition         | Two clients issued `update`/`void` concurrently; the loser's follow-up read shows the winner's transition applied.                                                                                   | The transition resolves server-side under a per-pass lock. Re-GET and match your intended effect against the current `status`.                                                                                                              |
| Two clients read `generation` non-monotonically | Client A issues, client B reads, client A issues again — B's later read returns a **higher** `generation` than its own previous read, and A's newest `issued` event carries the older `received_at`. | Treat the **highest observed `generation`** as canonical — it is the count of accepted update events, not a sequence position. Fold from reads, never diff.                                                                                 |
| Deep-link still routes to the old pass          | The link you minted before an update carries a cache-busted-serial or an old object id that the holder's wallet resolves to a previous rendering.                                                    | Re-derive the save link from the current `GET` and cache-bust it (`?gen=…` on the shortened URL or your own redirect). The platform re-derives the platform payloads fresh on every read, so the current GET always returns a current link. |

The edges are the whole diagnosis:

* **`active`** — accepts update and void. Each accepted update bumps
  `generation` by exactly one.
* **`voided`** — terminal. Update and re-void return `409 CONFLICT`; issue a
  replacement.
* **`expired`** — the wallet platform renders the pass expired when its
  `expires_at` lapses, but the ledger `status` still reads `active` until you
  void. An expired-but-active pass accepts update, so restoring validity means
  patching a later `expires_at`; the void decision is separate.

***

## Why the generation counter drifts

`generation` counts accepted `update` events, starting at `0` on issue. Drift
is always a stale-read artifact, never a counter bug. When two issuers work on
the same contact — one issuing, one polling — a poller that holds a read from
before the latest update reports a lower number than the issuer's sum. Because
reads fold the pass's full event history at request time, a fresh `GET` always
returns the counter's exact current value; a cached copy does not.

Recovery is the same either way: re-issue once under the same
`idempotency_key` (the replay is folded to current state — never stale) and
regenerate the holder's save link from the fresh `GET`. The holder refreshes
their copy on the next open of the new link.

***

## `409 CONFLICT` on update-after-void vs duplicate-issue idempotency

Two distinct edges produce a 409, and they must not be conflated.

* **Update or void after void.** The pass already transitioned to `voided`.
  The controller rejects the transition with `409`, names the current status,
  and returns a `reason` like `already_voided`. That edge is terminal — the
  pass can never be revived, so the recovery is to issue a new pass.
* **Duplicate issue.** `POST /wallet-passes/issue` is idempotent: send the
  same `idempotency_key` (or the `Idempotency-Key` header) and the request
  replays the **first** issued pass instead of minting a duplicate, returning
  `200` with `replayed: true` rather than a conflicting 409. The key lookup
  and the issue append run inside one transaction, so a double-submit or a
  client retry can never create two passes.

Read the two edges against the state machine (`active` accepts, `voided`
rejects) rather than against blanket 409 handling — void's 409 is the correct
answer there; the issue endpoint's success-based replay is the correct answer
for a duplicated enrollment.

***

## Generation is not sequence — the highest observed wins

When two clients issue and then independently update the same pass, a reader
that got a snapshot between the issuer's events might see `generation: 3` and
then, on a later read, `generation: 5` on the same pass. That jump is expected:
each accepted update increments by exactly one, and serial issuances across
clients mean the counter's total can only move forward. Never order events
by `generation` as though it were a sequence position — the highest value a
reader has observed is the canonical count of accepted updates, and that is
the value the wallet uses to decide that the holder needs a refresh.

***

## When the issued link still opens the old pass

The Google save link and the Apple `.pkpass` transport path encode the pass
content at mint time. An update after minting leaves the previously minted
link resolving to the pre-update snapshot, and a holder's wallet will not
re-fetch until it opens a fresh link. Recovery:

1. Re-`GET` the pass — the response rebuilds the platform payloads fresh on
   every read, so the current `platforms.google.save_url` (or the Apple
   serial) now points at the latest content.
2. Cache-bust the link hand-off: route the new save URL through your link
   shortener with a fresh slug, or attach a query marker your redirect
   resolves, so a cached redirect or an analytics page never serves the stale
   URL.
3. Re-issued passes (the idempotent-replay path) replay the **current**
   pass, so a re-issue under the same key is itself cache-busting — your
   holders pull the newest content when they open the refreshed link.

Because the ledger read-path rebuilds the platform payloads rather than
serving a stored blob, the generation counter is the only thing you need to
compare — if it moved, the link you mint already resolves to the current
rendering.

***

## What NOT to do

* **Do not use a second, new pass to fix drift.** A re-issue with a **new**
  `idempotency_key` mints a distinct pass; the old one still exists. Recovery
  is always the **same** key → same pass → newest content.
* **Do not retry a 409 void/update with backoff.** The pass is `voided` on the
  platform side; the retry can never succeed and it delays issuing the
  replacement.
* **Do not diff `generation` across clients.** Treat it only as "something
  moved" on a single pass; compare it across polls, not across issuers.
* **Do not assume a still-`active` expired pass is safe to void-auto.** The
  pass's ledger status remains `active` past `expires_at`; if you want the
  audit to read `voided`, call the void yourself.

***

## When to escalate

Escalate to [support@devotel.io](mailto:support@devotel.io) only when the
ledger-fold read itself misbehaves:

* A fresh `GET /wallet-passes/:id` returns a `generation` **lower** than the
  count of accepted updates your client has logged (that would break the
  fold, and only the platform can re-fold).
* `POST /wallet-passes/:id/update` or `void` returns `409` on a pass whose
  `GET` still returns `active` — divergence between the transition guard and
  the read projection.
* An idempotent issue returns `replayed: true` but the replayed pass shows
  issued-time content rather than the current lifecycle fold.

Include the `request_id` from `meta.request_id`, the `wps_` pass id, the
`status` and `generation` you observed, and the `idempotency_key` you used.

***

## Related

* [Wallet Passes channel guide](/channels/wallet-passes) — issuance workflow,
  the platform-payload builder, and the common error table.
* [Wallet pass lifecycle concept](/concepts/wallet-pass-lifecycle) — the
  state machine, generation counter, and append-only ledger this page
  triages.
* [Event-ledger projection model](/concepts/event-ledger-projection-model) —
  the architecture the ledger read-path folds under.
* [Idempotency and safe retries](/concepts/idempotency-and-safe-retries) —
  the retry discipline the `Idempotency-Key` header extends.
* [Error Codes reference](/reference/error-codes) — the `CONFLICT` row and
  the full retry-safety classification.
