Registration & KYC Lock Mechanism
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
What Turns the Lock On
KycLockService.isRegistrationLockRequired() · KycLockService.isKycRequired() · ProgramConfigurationRepository.getByDesignId()
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_required | kyc_required | lock? | DTO requiresKyc |
|---|---|---|---|
| false | false | open | false |
| true | false | LOCKED | false ✗ |
| false | true | LOCKED | true |
| true | true | LOCKED | true |
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.
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.
| kyc | reg | fund_init | fund_postreg | lock? | live prod | staging |
|---|---|---|---|---|---|---|
| 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 |
Applying the Lock — On Activate
CardActionHandler.activateCard() (single, inline) · GroupCardActionHandler.activateCard() (group, deferred) — identical lock logic
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.
| action | before | after — open design | after — locked design | KycLockEntity 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) |
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.
Fund Timing — Inferred from the Lock
CardActionHandler.loadFunds() · isKycRequiredAndNotYetVerified() · persistKycLock() · checkFundingBalance()
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.
The One Way Out — verifyIndividual
POST /api/cpm/v1/partners/{partnerExtId}/kyc/inquiries/verifyIndividual → KycInquiryHandler.onVerifyIndividual → activateAndLoadCard
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.
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 locked | card at Tribe | KycLockEntity? | verifyIndividual outcome |
|---|---|---|---|
activate on a locked design | T | none ✗ | KycLockException → stranded (D1 · S1) |
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 · S2) |
Code Paths & Where State Lives
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 point | class · method | what 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 |
- 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
- 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
- is_registration_required · is_kyc_required — the live triggers
- is_fund_loaded_during_initial_activation — dead
- is_fund_loaded_after_registration — dead
- Activated, then Suspended on a locked activate
- the audit trail of the hold
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.
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).
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.
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.
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.
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.
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.