Internal Docs/Group activation — end to end
group activation · two phases · one service · a validation gauntlet

group activation by sequence range

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.

00

the two-phase flow

axis-service

POST /api/cpm/v1/partners/{partnerExtId}/cards/multi/actions/{prepare|execute|findOne|cancel}

phase 1 · sync
GroupCardAccountActionTask
prepare
validate the gauntlet · expand the range · stage the group · returns actionGroupId, status New
≤ 60s
window
phase 2 · sync
GroupCardAccountActionTask
execute
schedules the deferred task · status New → Processing · returns immediately
+100ms
deferred
deferred task pool
GroupCardActionHandler
walks the staged cards · one activation per card
4-thread
pool
external
Tribe
activate / activateWithLoad per card
card-state writes land in cpm · the per-card Tribe leg runs on a 4-thread pool, within the platform's 6-concurrent ceiling (see provisioning · the Tribe limit)
axis-service (sync) deferred task Tribe (external) cpm database
This is the standard two-phase action pattern. prepare validates and stages an ActionRequestEntity (status New); execute commits it. The generic contract — the New → Processing → Complete/Partial/Failed state machine, deferred execution, clientRef idempotency and the expiry timers — lives in Actions & deferred tasks. This doc covers what's specific to group activation: the validation gauntlet (§02), the cross-batch sequence guard (§03), and the per-card activation work (§04).
01

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": {}
  }]
}

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.

no pending conflicts database

recognizeConflict() on each boundary card — a card already inside an open action can't be re-grouped.

endpoints match the range database

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.

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.

Up to 20 operations per request. A single call can carry several independent operations[], each its own range — CardAccountMultiActionValidator caps the array at 20 and rejects any two operations whose sequence ranges overlap.
02

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

selector carries cardIds / cardExtRefs

A group selector must name its boundary cards.

400 · 2020
missing selector params

boundary array length == 2

Exactly first + last. Not 1, not 3.

412 · 2229
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.

417 · 2309
same program / designId

boundary seqs == seqLow / seqHigh

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

417 · 2309
seqNumber mismatch

no overlap across operations

Multi-op requests: no two ranges may intersect.

400
ranges overlap

unique clientRef

Idempotency guard — a ref can be prepared once.

417 · 2230
clientRef not unique
task layer · the resolved group vs the request

count matches validateCardCountMatchesRequest

Activatable cards found in range == the caller's cardCount.

417 · 2309
card count mismatch

range is densevalidateCrossBatchActivation

(seqHigh − seqLow + 1) == cardsFound. The window must be a solid run — no reaching across a gap into another batch. Detailed in section 03.

417 · 2309
range ≠ valid card count

currency matches program validateProgramCurrencyMatchesRequest

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

417 · 2309
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.

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.

03

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
101
102
103
104
105
·106
····
·109
110
111
112
113
114
▣ = activatable card of design A  ·  · = no card at this sequence number (gap between batches)
passactivate batch one — seqLow=101, seqHigh=105 → width 5, found 55 == 5. A solid island.
rejectreach 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.

The assumption it rests on: because there's no real batch id, "same batch" is inferred from "gapless sequence run." That can't distinguish two same-design batches minted with immediately adjacent sequence numbers (no gap between them). In practice mint runs leave gaps, so the inference holds — but if tooling ever hands a second same-design batch a contiguous block, the guard reads the two as one. Making batch identity explicit at order-creation time would remove the inference entirely; absent that, sequence contiguity is the signal available.
Knock-on effect: the guard also rejects a partial re-activation — a range where some cards are already activated (so they drop out of the activatable count, leaving width > found). That's intended: to activate a sub-set, tighten seqLow/seqHigh to the run you mean.
04

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.

fetch the 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 (see the money path below).

activate each card 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.

record the new card 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) — activated, then held for KYC.

settle the card & the 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 — 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]
activateWithLoad / load — the money path. For funding actions the handler first moves the whole batch amount from the program's loads account into a staging account (a FundTransferEntry + tran_reference), loads each card from staging, then reverses the funds for any card that failed back to the program account. Plain activate moves no money.
05

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 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
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
suspendactivated risk lost fraud suspend
unsuspendsuspended
markLostactivated suspended risk fraud lost
markStolenactivated suspended risk not activated lost stolen
unmarkLostlost