Registration & KYC Lock Mechanism

liveAs-built: one mechanism, two triggers. How axis-service locks a freshly activated card until verification releases it.· 2026-05-30
0

The Shape of a Lock

A lock holds a freshly activated card back from use until a verification event releases it: axis-service activates the card, then immediately suspends it at Tribe (status T) and flags it kyc_locked. Two program-configuration flags can trigger it — registration_required and kyc_required — but today they feed a single predicate and produce a physically identical hold; there is no separate "registration lock" state.

Fund timing is folded into the same decision: a locked card defers its initial load, parking the amount in a KycLockEntity row to be applied at release. The only thing that releases it is a verifyIndividual call.

1

What Turns the Lock On

KycLockService.isRegistrationLockRequired() / KycLockService.isKycRequired() / ProgramConfigurationRepository.getByDesignId().

The predicate — registration OR kyc

Every lock-related branch in the service asks one method. It loads the card's ProgramConfigurationEntity by designId and returns true if either flag is set. There is no third "lock" flag and no notion of which trigger fired — the result is one boolean.

java
 // KycLockService.isRegistrationLockRequired(card) boolean requiresKyc = cfg.isKycRequired(); boolean requiresRegistration = cfg.isRegistrationRequired(); return requiresRegistration || requiresKyc; // config missing for designId -> false (no lock)

A separate isKycRequired() returns the kyc flag alone. It does not gate any lock — it only feeds the DTO field requiresKyc (see the D4 lie in section 6).

Truth table

reg_requiredkyc_requiredLock?DTO requiresKyc
false false open false
true false LOCKED false (the D4 lie)
false true LOCKED true
true true LOCKED true

What actually exists — live data, prod vs staging

kycregfund_initfund_postregLock?ProdStaging
t t f t LOCKED 65 designs, 23 programs 16 designs
t t f f LOCKED none 36 designs
t f f f LOCKED none 3 designs
f t f t LOCKED none 1 design -- Touchwood 1470
f f f f open 9 designs, 4 programs 2 designs
2

Applying the Lock -- On Activate

CardActionHandler.activateCard() (single, inline) / GroupCardActionHandler.activateCard() (group, deferred) — identical lock logic.

Activate first, then hold

Activation always runs to completion on Tribe before the lock is considered. The card is genuinely activated, its Activated status recorded — and only then, if the predicate says so, is it pulled back to Suspended.

  1. Already locked? Bail. If pce.isKycLocked() is already true, throw IllicitModificationException(alreadyActivated). A locked card can't be re-activated.
  2. Activate on Tribe.activateCard(spiCardId, ref) — the card goes live (status A). addStatusUpdate(Activated) records it.
  3. If isRegistrationLockRequired → suspend & flag. Set pce.setKycLocked(true), call Tribe changeCardStatus(spiCardId, "T", 3) to Suspended, and record a second status update of Suspended. The card is now activated-then-held. No KycLockEntity is created here — that only happens on a funding path.

Card status the handler writes

ActionBeforeAfter -- open designAfter -- locked designKycLockEntity created?
activate not activated activated activated -> suspended (T) + kyc_locked no
activateWithLoad not activated activated + funds loaded suspended (T) + kyc_locked, load deferred yes (by the load leg)
3

Fund Timing -- Inferred from the Lock

CardActionHandler.loadFunds() / isKycRequiredAndNotYetVerified() / persistKycLock() / checkFundingBalance().

A locked load defers instead of loading

For load and activateWithLoad, the handler asks the lock predicate again — this time to decide whether to load now or park the load for later. If the card is locked and has no existing lock row, the amount, channel and reference are serialized into a KycLockEntity and the method returns without touching Tribe.

java
 // loadFunds(amount, channel) if (isKycRequiredAndNotYetVerified(card)) { persistKycLock(cardId, spiCardId, ref, amount, channel); // park it return; // no Tribe load now } executeLoadAndRecordTransfer(...); // load now // isKycRequiredAndNotYetVerified = // no existing KycLockEntity // AND isRegistrationLockRequired(card)
  1. Balance check — skipped when locked.checkFundingBalance() early-returns the moment isRegistrationLockRequired is true. A locked load is never checked for sufficient funds up front (D3).
  2. Park the load in a KycLockEntity.persistKycLock() writes one row keyed by cardId: currencyAmount + loadChannel (both JSON), referenceNumber, description="card load", createdAt.
  3. Open program → load immediately. No lock → executeLoadAndRecordTransfer moves funds and loads the card on Tribe in-thread.
4

The One Way Out -- verifyIndividual

POST /api/cpm/v1/partners/{partnerExtId}/kyc/inquiries/verifyIndividualKycInquiryHandler.onVerifyIndividualactivateAndLoadCard.

Release = un-suspend at Tribe, then replay the parked load

There is exactly one code path that clears a lock. A KYC verification result posts to verifyIndividual; the handler looks the card up and runs activateAndLoadCard. It is guarded by two preconditions:

  1. Precondition — card must be Tribe "T".isCardNotSuspended() reads the live Tribe status. If it is not "T", the handler logs a warning and returns early. A card that was never suspended is silently a no-op (D2).
  2. Activate on Tribe.changeCardStatus(spiCardId, "A", 3) brings the card back to Activated, and setKycLocked(false) clears the flag.
  3. Precondition — a KycLockEntity must exist.getKycLockEntity(cardId) calls KycLockEntity.findFirstByExternalRef; if it returns null it throws KycLockException. No row, no load, hard failure (D1).
  4. Fund transfer, then load. If isAlreadyInitiallyFunded(cardId) is false: run the A→B processFundTransfer, then loadCard with the stored currencyAmount + loadChannel from the lock row.

How each entry path lands at the release gate

The release path needs the card to be both suspended ("T") and backed by a KycLockEntity. Only one of three entry combinations satisfies both.

How the card was lockedCard at TribeKycLockEntity?verifyIndividual outcome
activate on a locked design T none KycLockException -> stranded (D1)
activateWithLoad on a locked design T created releases + loads (the only sound path)
load on an already-active locked card A (never suspended) created no-op -- load never applies (D2)
5

Code Paths & Where State Lives

Every place the lock predicate is consulted

Entry pointClass / methodWhat the lock changes
single activate CardActionHandler.activateCard locked -> kyc_locked=true + Tribe "T"
single load / activateWithLoad CardActionHandler.loadFunds -> isKycRequiredAndNotYetVerified -> persistKycLock locked -> park load in KycLockEntity, skip Tribe load
single balance check CardActionHandler.checkFundingBalance locked -> early return, no balance check
group activate GroupCardActionHandler.activateCard locked -> kyc_locked=true + Tribe "T" (per card)
group load / staging GroupCardActionHandler.execute (samples card 0) / loadFunds locked -> skip A->B transfer + B->A reversal; park each load
release KycInquiryHandler.activateAndLoadCard requires "T" + KycLockEntity -> Tribe "A", clear flag, replay load
DTO mapping PaymentCardEntity.getRequiresKyc returns the kyc flag only (or per-card override) -- never reg
config lookup ProgramConfigurationRepository.getByDesignId no deleted_at / ordering -- feeds every call above

Where the lock state is persisted

EntityTable / SchemaHolds
KycLockEntity cpm.kyc_lock -- one row per parked load id (= card id PK), spiCardId, currencyAmount/loadChannel (JSON), referenceNumber, description, createdAt
payment_card cpm.payment_card kyc_locked (the hold flag), requires_kyc (per-card override)
program_configuration core schema, keyed by designId is_registration_required, is_kyc_required (live triggers); is_fund_loaded_* (dead)
card_update_activity cpm -- status history Activated then Suspended on a locked activate; the audit trail of the hold
6

Where It Breaks Today

The as-built defects, worst first. These are current reality, not proposals — they motivate the redesign, they don't prejudge it.

SeverityIDTitleDescription
S1 D1 activate strands the card Plain activate on a locked design sets kyc_locked=true + Tribe "T" but never creates a KycLockEntity. The release path then throws KycLockException -- the card is suspended forever. ~20 prod cards stranded today.
S2 D2 pure load defers into a dead end load on an already-active locked card creates the KycLockEntity but never suspends the card. The release path only runs for "T" cards, so it no-ops.
S2 D3 locked designs skip the up-front balance check Single-card checkFundingBalance early-returns when locked; the group path skips the batch transfer that is its only balance check.
S3 D4 the DTO requiresKyc lies getRequiresKyc reflects the kyc flag only, so a registration-only card that is locked reports requiresKyc=false. Consumers can't tell it's locked from that field.
S3 D5 the is_fund_loaded_* flags are dead The two flags meant to express fund timing have getters but no callers. Fund timing is welded to the lock decision.
S3 D6 getByDesignId ignores deleted_at + ordering The config lookup returns list.get(0) from an unordered, unfiltered query. A soft-deleted or duplicate config row could be read non-deterministically.