Group Activation
The Two-Phase Flow
A single request activates a whole run of cards at once. The caller names the first and last card of a contiguous batch plus its sequence range; axis-service expands that into every activatable card in the range and processes them together. It runs in two phases — a synchronous prepare that validates and stages the group, then a deferred execute that drives the per-card Tribe activation. Most of the interesting logic lives in prepare, which puts every request through a gauntlet of structural and semantic checks — including the cross-batch guard that stops a range from quietly reaching across batch boundaries.
POST /api/cpm/v1/partners/{partnerExtId}/cards/multi/actions/{prepare|execute|findOne|cancel}
Selecting the Group
CardSelector — two boundary cards bracket a sequence range
You don't enumerate every card. You give the first and last card of the batch (by id or external ref) and the sequence numbers that bracket them. axis-service resolves the membership: every activatable, same-design card whose sequence number falls in [seqLow, seqHigh]. cardCount declares how many you expect.
// request body — one operation { "action": "activate", "clientRef": "batch-7741-2026", "operations": [{ "selector": { "cardIds": [firstId, lastId], "seqLow": 101, "seqHigh": 105 }, "cardCount": 5, "params": {} }] }
validate() boundary shape
Selector must carry exactly 2 cardIdsor 2 cardExtRefs — the first and last card. Anything else is rejected before any DB work.
database no pending conflicts
recognizeConflict() on each boundary card — a card already inside an open action can't be re-grouped.
database endpoints match the range
validateSeqNoMismatch() — the first card's real sequence number must equal seqLow, the last card's must equal seqHigh. The caller can't claim a range its boundary cards don't actually sit on.
database expand the range
findAllGroupCardIds(seqLow, seqHigh, designId, action) — pulls every card of the first card's designId, in range, whose latest status is valid for the action. This set is the group.
Prepare — The Validation Gauntlet
Each gate is a check; failing one exits with a specific HTTP status + errorCode. Codes confirmed against the live release-test suite.
Validation is layered: the validator layer (CardAccountActionValidator) checks the request's shape and provenance before any group is built; the task layer (GroupCardAccountActionTask.prepare) checks the resolved group against what the caller asked for. Several semantic failures share the mismatched code (417 · 2309) — the response message is what tells them apart.
selector carries cardIds / cardExtRefs
A group selector must name its boundary cards.
missing selector params
boundary array length == 2
Exactly first + last. Not 1, not 3.
array size must be 2
boundaries share program & designId
First and last card must be the same program and the same design — the design is the batch’s identity here.
same program / designId
boundary seqs == seqLow / seqHigh
Each boundary card’s stored sequence number must equal the range it claims.
seqNumber mismatch
no overlap across operations
Multi-op requests: no two ranges may intersect.
ranges overlap
unique clientRef
Idempotency guard — a ref can be prepared once.
clientRef not unique
count matches — validateCardCountMatchesRequest
Activatable cards found in range == the caller’s cardCount.
card count mismatch
range is dense — validateCrossBatchActivation
(seqHigh − seqLow + 1) == cardsFound. The window must be a solid run — no reaching across a gap into another batch. Detailed in section 3.
range ≠ valid card count
currency matches program — validateProgramCurrencyMatchesRequest
For load actions, the requested currency must be the program’s home currency.
invalid currency
every card resolves & shares the program — validateIfAllCardsHaveSameProgram
Each expanded id loads and belongs to the program; a missing id is a hard not-found.
card not found
staged
An action_request (status New) is created, the group is written to group_card_action, and a New DTO with actionGroupId returns. A 120s timer is armed to cancel if execute never comes.
The Sequence-Range Guard
What "cross-batch" means, and how a count check enforces it — GroupCardAccountActionTask.validateCrossBatchActivation
There is no batch identifier in the data. A batch is simply a block of cards minted together, which lands as a contiguous run of sequence numbers sharing one design_id. The same design can be reused for later mint runs, so its sequence space looks like dense islands separated by gaps — each island a batch. Sequence numbers are scoped per design, so the design_id filter in findAllGroupCardIds is what isolates the right design in the first place; this guard then enforces that the chosen range stays inside a single island.
seqLow=101, seqHigh=105 → width 5, found 5 → 5 == 5. A solid island.seqLow=101, seqHigh=114 → width 14, found 10 → 14 ≠ 10. The gap exposes the cross-batch span. The two checks work together. cardCount == found on its own would let a caller set cardCount to the 10 cards actually found and pass — silently grouping two batches and skipping whatever sits in the gap. Requiring width == found as well rules that out: the only way to satisfy both is a single gapless run.
Execute — Deferred Activation
multi/actions/execute → GroupCardAccountActionTask.execute() → GroupCardActionHandler (background)
execute schedules a deferred task and returns Processing at once; the per-card work runs later on a background worker. The deferral itself — the persisted queue, the scanner, the 4-thread pool, clientRef idempotency and the 60s/120s expiry timers — is the standard action mechanism, documented in Actions & deferred tasks. What follows is what GroupCardActionHandler does once it picks the group up.
database fetch the staged cards
Pull the group from group_card_action (status New) in pages of up to 100 and queue them. For load actions the whole batch amount is moved to a staging account first (see the money path below).
tribe activate each card on Tribe
Per card: Cards.activateCard(spiCardId, ref) sets the card live on Tribe — each card in its own transaction, so one card failing can't roll back the rest.
database record the new card status
addStatusUpdate() writes a card_update_activity row of Activated. If the program requires a KYC registration lock, the card is also flagged kyc_locked and pushed to Tribe status T (Suspended) — activated, then held for KYC.
database settle the card & the action
Each group_card_action row goes Complete, or Failed with the error attached. When the pages drain, the action settles by completed-vs-total count — its cardCount rewritten to the number that succeeded — and a callback fires. A batch with some failures is a normal Partial Complete; only an all-fail batch is Failed.
| action | before | after (no KYC lock) | after (KYC reg-lock) | tribe calls |
|---|---|---|---|---|
activate | not activated | activated | suspended (T) + kyc_locked | activateCard [+ changeCardStatus T] |
activateWithLoad | not activated | activated + funds loaded | suspended (T) · lock holds the load | activate + load [or KycLock] |
Where Everything Ends Up
- status New → Processing → Complete (or Canceled / Failed)
- access_scope_json holds the resolved cardIds + count
- carries the action type, program, clientRef
- seeded from
access_scope_json.cardIds - per-card status 0 → 100 as the handler claims them
- deleted on cancel / expiry
- ClientRef ref stored against the action
- backs the uniqueness gate (validator gate 6)
- (card_id, design_id, seq_number)
- the source of truth the range guard reads
- written at provisioning, never by activation
The same prepare/execute + sequence-range machinery serves a family of bulk actions. Each declares the card statuses it will accept (group-action-types.properties); the range guard applies to all of them.
| action type | accepted starting status |
|---|---|
activate | not activated |
activateWithLoad | not activated |
load | activated |
suspend | activatedrisklostfraudsuspend |
unsuspend | suspended |
markLost | activatedsuspendedriskfraudlost |
markStolen | activatedsuspendedrisknot activatedloststolen |
unmarkLost | lost |