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. This doc maps that machinery exactly as it stands — the trigger, the lock-on-activate path, the deferred-funding path, the single release path, the code it touches — and, honestly, the places it strands cards today.

activate → suspend & hold → verifyIndividual → release & load — one round trip, gated by program config

the hold is real at Tribe (status T) and recorded in cpm (kyc_locked + a KycLockEntity row) · the only thing that releases it is a verifyIndividual call
program config (cpm)axis-service handlerthe lock stateKYC releaseTribe
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.

// 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 §5, and the lie in §6·D4).

reg_requiredkyc_requiredlock?DTO requiresKyc
falsefalseopenfalse
truefalseLOCKEDfalse ✗
falsetrueLOCKEDtrue
truetrueLOCKEDtrue

Row 2 is Touchwood (design 1470): registration-only. It locks, yet the DTO reports requiresKyc=false — only the separate kycLocked field reveals the hold. This is the logic; for which rows actually exist, see the population table below — rows 2 & 3 are staging-only.

what actually exists — live data, prod vs staging

The 4-flag space is mostly theoretical. Prod uses only two configurations. Staging is messier — but that may be an artifact of the card-load-for-testing process rather than real program setup, so treat staging as indicative, not authoritative. Counts sampled 2026-05-30, active rows only.

kycregfund_initfund_postreglock?live prodstaging
ttftlocked65 designs · 23 programs16 designs
ttfflockednone36 designs
tffflockednone3 designs
ftftlockednone1 design — Touchwood 1470
ffffopen9 designs · 4 programs2 designs
fund_init is false everywhere. No design — prod or staging — funds during activation. "Lock + pre-fund" has zero instances.
In prod, locked ⟺ fund_postreg ⟺ (kyc ∧ reg). The "dead" flag agrees with the lock inference for 100% of prod rows; reg and kyc are always both-set or both-clear.
The reg-only / kyc-only rows are staging-only. Touchwood (reg-only + fund-after-reg) is the one genuinely new shape — imminent in prod, hence "don't break it."
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. The single-card and group handlers carry the same branch verbatim.

cpm already locked? bail

If pce.isKycLocked() is already true, throw IllicitModificationException(alreadyActivated) — "already activated and currently kyc locked". A locked card can't be re-activated.

tribe activate on Tribe

activateCard(spiCardId, ref) — the card goes live (status A). addStatusUpdate(Activated) records it in card_update_activity.

the hold 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 (§3). Hold that thought for §6·D1.

card status the handler writes
actionbeforeafter — open designafter — locked designKycLockEntity created?
activatenot activatedactivatedactivatedsuspended (T) + kyc_lockedno ✗
activateWithLoadnot activatedactivated + funds loadedsuspended (T) + kyc_locked · load deferred yes ✓ (by the load leg)

The asymmetry in the last column is the whole problem: the lock flag and the KycLockEntity — the two things the release path needs together — are written by different code paths. activate writes one; only a load writes the other.

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. The actual load is replayed at release (§4).

// 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)

gate balance check — skipped when locked

checkFundingBalance() early-returns the moment isRegistrationLockRequired is true: "requires registration lock. Skipping balance check." A locked load is never checked for sufficient funds up front (§6·D3).

cpm park the load in a KycLockEntity

persistKycLock() writes one row keyed by cardId: currencyAmount + loadChannel (both JSON), referenceNumber, description="card load", createdAt. This row is the deferred instruction.

tribe open program → load immediately

No lock → executeLoadAndRecordTransfer moves funds and loads the card on Tribe in-thread, recording a tran_reference.

4

The One Way Out — verifyIndividual

POST /api/cpm/v1/partners/{partnerExtId}/kyc/inquiries/verifyIndividual → KycInquiryHandler.onVerifyIndividual → activateAndLoadCard

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

There is exactly one code path that clears a lock. A KYC verification result for the person posts to verifyIndividual; the handler looks the card up and runs activateAndLoadCard. It is guarded by two preconditions, and if either is unmet the card does not come back.

tribe 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 — "skipping any other registration steps". A card that was never suspended is silently a no-op (§6·D2).

tribe activate on Tribe

changeCardStatus(spiCardId, "A", 3) brings the card back to Activated, and setKycLocked(false) clears the flag in cpm.

cpm precondition — a KycLockEntity must exist

getKycLockEntity(cardId) calls KycLockEntity.findFirstByExternalRef; if it returns null it throws KycLockException. This is the parked-load instruction — no row, no load, hard failure (§6·D1).

cpm fund transfer, then load — unless already funded

If isAlreadyInitiallyFunded(cardId) is false: run the A→B processFundTransfer, then loadCard with the stored currencyAmount + loadChannel from the lock row. The deferred load from §3 finally lands.

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 — the rest strand.

how the card was lockedcard at TribeKycLockEntity?verifyIndividual outcome
activate on a locked designTnone ✗KycLockException → stranded (D1 · S1)
activateWithLoad on a locked designTcreated ✓releases + loads ✓ — the only sound path
load on an already-active locked cardA (never suspended)created ✓no-op — load never applies (D2 · S2)
5

Code Paths & Where State Lives

every place the lock predicate is consulted

The lock reaches into six call sites across three handlers, the DTO mapper, and a repository. This is the full blast radius of isRegistrationLockRequired / isKycRequired.

entry pointclass · methodwhat the lock changes
single activateCardActionHandler.activateCardlocked → kyc_locked=true + Tribe "T"
single load / activateWithLoadCardActionHandler.loadFundsisKycRequiredAndNotYetVerifiedpersistKycLocklocked → park load in KycLockEntity, skip Tribe load
single balance checkCardActionHandler.checkFundingBalancelocked → early return, no balance check
group activateGroupCardActionHandler.activateCardlocked → kyc_locked=true + Tribe "T" (per card)
group load / stagingGroupCardActionHandler.execute (samples card 0) · loadFundslocked → skip A→B transfer + B→A reversal; park each load
releaseKycInquiryHandler.activateAndLoadCard requires "T" + KycLockEntity → Tribe "A", clear flag, replay load
DTO mappingPaymentCardEntity.getRequiresKyc returns the kyc flag only (or per-card override) — never reg
config lookupProgramConfigurationRepository.getByDesignIdno deleted_at / ordering — feeds every call above
where the lock state is persisted
KycLockEntity
cpm · table KYC_LOCK · one row per parked load
  • id = the card id (PK)
  • spiCardId — Tribe's card id
  • currencyAmount / loadChannel — JSON, the parked load
  • referenceNumber · description · createdAt
  • created by loadFunds; read & consumed at release
payment_card
cpm · the card itself
  • kyc_locked — the hold flag (set on activate, cleared on release)
  • requires_kyc — per-card override; else derived from the kyc flag
  • Tribe status ("A" / "T") lives at Tribe, mirrored via status updates
program_configuration
core schema · keyed by designId
  • is_registration_required · is_kyc_required — the live triggers
  • is_fund_loaded_during_initial_activationdead
  • is_fund_loaded_after_registrationdead
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.

D1 · S1

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. Pre-existing for kyc-required programs; the §1 split newly exposed it for registration-only programs too. Live evidence: ~20 prod cards stranded today (KYC programs 563 / 561 / 459).

D2 · S2

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 — the deferred load is parked and never applied.

D3 · S2

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. An "activated but unfunded" condition surfaces later, at release, instead of being caught at request time.

D4 · S3

the DTO requiresKyc lies

getRequiresKyc reflects the kyc flag only, so a registration-only card that is locked reports requiresKyc=false. Consumers (setldhub-ui, core-service) can't tell it's locked from that field — only the separate kycLocked field carries it.

D5 · S3

the is_fund_loaded_* flags are dead

The two flags meant to express fund timing have getters but no callers. The system can't express "registration-required but fund during activation" — fund timing is welded to the lock decision. Touchwood's defer matches its config only by coincidence.

D6 · S3

getByDesignId ignores deleted_at + ordering

The config lookup that feeds every branch above returns list.get(0) from an unordered, unfiltered query. A soft-deleted or duplicate config row could be read non-deterministically.