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.
Service: axis-service · POST /api/cpm/v1/partners/{partnerExtId}/cards/multi/actions/{prepare|execute|findOne|cancel}
Selecting the Group
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
{ "action": "activate", "clientRef": "batch-7741-2026", "operations": [{ "selector": { "cardIds": [firstId, lastId], "seqLow": 101, "seqHigh": 105 }, "cardCount": 5, "params": {} }] }Selection Steps
| Step | Tag | Description |
|---|---|---|
| 1. Boundary shape | validate() | Selector must carry exactly 2 cardIds or 2 cardExtRefs -- the first and last card. Anything else is rejected before any DB work. |
| 2. No pending conflicts | database | recognizeConflict() on each boundary card -- a card already inside an open action can't be re-grouped. |
| 3. Endpoints match range | database | validateSeqNoMismatch() -- the first card's real sequence number must equal seqLow, the last card's must equal seqHigh. |
| 4. Expand the range | database | 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. 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.
Validator Layer — Request Shape & Provenance
| Gate | Check | On Failure |
|---|---|---|
| 1. Selector present | Selector carries cardIds / cardExtRefs. A group selector must name its boundary cards. | 400 / 2020 -- missing selector params |
| 2. Boundary array length | Exactly first + last. Not 1, not 3. | 412 / 2229 -- array size must be 2 |
| 3. Same program & design | First and last card must be the same program and the same design -- the design is the batch's identity here. | 417 / 2309 -- same program / designId |
| 4. Boundary seqs match | Each boundary card's stored sequence number must equal the range it claims. | 417 / 2309 -- seqNumber mismatch |
| 5. No overlap across ops | Multi-op requests: no two ranges may intersect. | 400 -- ranges overlap |
| 6. Unique clientRef | Idempotency guard -- a ref can be prepared once. | 417 / 2230 -- clientRef not unique |
Task Layer — Resolved Group vs Request
| Gate | Check | On Failure |
|---|---|---|
| 7. Count matches | validateCardCountMatchesRequest -- activatable cards found in range == the caller's cardCount. | 417 / 2309 -- card count mismatch |
| 8. Range is dense | validateCrossBatchActivation -- (seqHigh - seqLow + 1) == cardsFound. The window must be a solid run -- no reaching across a gap into another batch. | 417 / 2309 -- range != valid card count |
| 9. Currency matches | validateProgramCurrencyMatchesRequest -- for load actions, the requested currency must be the program's home currency. | 417 / 2309 -- invalid currency |
| 10. All cards resolve | validateIfAllCardsHaveSameProgram -- each expanded id loads and belongs to the program; a missing id is a hard not-found. | 404 -- card not found |
Once all gates pass, 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
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.
How It Works
The check is: (seqHigh − seqLow + 1) == cardsFound. The window must be a solid run — no reaching across a gap into another batch.
| Scenario | Range | Width | Found | Result |
|---|---|---|---|---|
| Activate batch one (solid island) | seqLow=101, seqHigh=105 | 5 | 5 | PASS -- 5 == 5 |
| Reach across batches (gap at 106-109) | seqLow=101, seqHigh=114 | 14 | 10 | REJECT -- 14 != 10 |
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
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. What follows is what GroupCardActionHandler does once it picks the group up.
Handler Steps
| Step | Tag | Description |
|---|---|---|
| 1. Fetch staged cards | database | 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. |
| 2. Activate on Tribe | 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. |
| 3. Record new status | database | 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). |
| 4. Settle card & action | database | 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. A batch with some failures is a normal Partial Complete; only an all-fail batch is Failed. |
Card Status Per Action
| 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
What Prepare Writes
| Entity | Table | Key Details |
|---|---|---|
| action_request | cpm | One row per operation. Status New -> Processing -> Complete (or Canceled / Failed). access_scope_json holds the resolved cardIds + count. Carries the action type, program, clientRef. |
| group_card_action | cpm | One row per card in the group. Seeded from access_scope_json.cardIds. Per-card status 0 -> 100 as the handler claims them. Deleted on cancel / expiry. |
| entity_ext_ref | cpm | ClientRef linkage. ClientRef ref stored against the action. Backs the uniqueness gate (validator step 6). |
| payment_card_sequence_number | cpm (read-only here) | (card_id, design_id, seq_number). The source of truth the range guard reads. Written at provisioning, never by activation. |
Supported Group Action Types
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 | ACTIVATED, RISK, LOST, FRAUD, SUSPEND |
| unsuspend | SUSPENDED |
| markLost | ACTIVATED, SUSPENDED, RISK, FRAUD, LOST |
| markStolen | ACTIVATED, SUSPENDED, RISK, NOT_ACTIVATED, LOST, STOLEN |
| unmarkLost | LOST |