Group Activation

liveGroup activation by sequence range: a single request activates a whole run of cards at once via a two-phase prepare/execute flow.· 2026-05-30
0

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}

card-state writes land in cpm · the per-card Tribe leg runs on a 4-thread pool, within the platform's 6-concurrent ceiling
axis-service (sync)deferred taskTribe (external)cpm database
1

Selecting the Group

CardSelector — two boundary cards bracket a sequence range

a group is addressed by its endpoints, not a list

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.

2

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.

a request runs the gates top to bottom — first failure wins

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.

validator layer · request shape & provenance
1

selector carries cardIds / cardExtRefs

A group selector must name its boundary cards.

400 · 2020
missing selector params
2

boundary array length == 2

Exactly first + last. Not 1, not 3.

412 · 2229
array size must be 2
3

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.

417 · 2309
same program / designId
4

boundary seqs == seqLow / seqHigh

Each boundary card’s stored sequence number must equal the range it claims.

417 · 2309
seqNumber mismatch
5

no overlap across operations

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 · the resolved group vs the request
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. Detailed in section 3.

417 · 2309
range ≠ valid card count
9

currency matches program validateProgramCurrencyMatchesRequest

For load actions, the requested currency must be the program’s home currency.

417 · 2309
invalid currency
10

every card resolves & shares the program validateIfAllCardsHaveSameProgram

Each expanded id loads and belongs to the program; a missing id is a hard not-found.

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

3

The Sequence-Range Guard

What "cross-batch" means, and how a count check enforces it — GroupCardAccountActionTask.validateCrossBatchActivation

a batch = a contiguous run of sequence numbers under one design

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.

design A sequence space — two batches, one design
▣ = activatable card of design A  ·  · = no card at this sequence number (gap between batches)
pass activate batch one — seqLow=101, seqHigh=105 → width 5, found 55 == 5. A solid island.
reject reach across — seqLow=101, seqHigh=114 → width 14, found 1014 ≠ 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.

4

Execute — Deferred Activation

multi/actions/execute → GroupCardAccountActionTask.execute() → GroupCardActionHandler (background)

execute defers — the handler does the work

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.

card status — what the handler writes per card
actionbeforeafter (no KYC lock)after (KYC reg-lock)tribe calls
activatenot activatedactivatedsuspended (T) + kyc_lockedactivateCard [+ changeCardStatus T]
activateWithLoadnot activatedactivated + funds loadedsuspended (T) · lock holds the loadactivate + load [or KycLock]
5

Where Everything Ends Up

what prepare writes
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 gate 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
the group-action mechanism isn't only for activate

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 typeaccepted starting status
activatenot activated
activateWithLoadnot activated
loadactivated
suspendactivatedrisklostfraudsuspend
unsuspendsuspended
markLostactivatedsuspendedriskfraudlost
markStolenactivatedsuspendedrisknot activatedloststolen
unmarkLostlost