Registration & KYC — The Seams

liveMap of every contract the redesign must live within — the shared DTO, the shared database objects, and the cross-service HTTP calls.· 2026-05-30

The design says what to build; this says where it can move. A map of every contract the redesign must live within — the shared DTO, the shared database objects, and the cross-service HTTP calls — each labelled fixed (changing it is a cross-repo or prod migration), flexible (reshapeable inside axis), or branch-soft (unreleased on feat/246 — shape it before it merges). Every claim here is verified against axis / platform-core / core / .NET source, and the live prod schema.

VERIFIED 2026-05-30. Source-read across 5 repos + setldpay_prod read-only schema & counts.
0

State of Play — Start Here

the whole seam picture in a minute (verified 2026-05-30)
  • The DTO looks like the hard seam — but it's soft with an ordered rollout. The PaymentCard fields requiresKyc / kycLocked / deferredLoadAmount (platform-core) pass through the .NET tier unbranched and are branched on by two Angular UIs (the cardholder funnel gates on requiresKyc && kycLocked). But every branching consumer is our code, so the names/meanings are reshapeable via expand/contract — teach the UIs old-or-new, ship them first, then flip axis. "Fixed" only if you refuse to touch consumers. soft w/ rollout §02 · pending the external-consumer check (the open Investigate card).
  • The prod schema is small and confirmed. cpm.payment_card.{kyc_locked, requires_kyc}, cpm.kyc_lock (7 columns, no initial_funded_date), core.program_configuration flags. Reshapeable as long as axis keeps populating the DTO. released §03
  • The HTTP calls the design needs already exist — the release endpoint (overloaded), the verdict read, the cardholder forward. One carries a live cross-tenant IDOR. flexible §04
  • The big lever is unreleased. core's entire KycLock machinery + initial_funded_date + the card-expiry scenario surface are branch-only (PR #273, open) — prod-confirmed absent. This is the co-design window. branch-soft §05
  • One framing correction: platform-core owns no database schema — no migrations, no entities. The lock tables are axis-owned, and their base DDL isn't in any repo (manually applied). The earlier "shared base tables in platform-core" was wrong. §02
  • The seam to watch: feat/246 makes core read & write axis's cpm.kyc_lock directly — the opposite of the design's "axis is sole owner." Resolve before #273 merges. §05
1

The Three Buckets

Every seam below carries one of these. The whole point of the map is which bucket each object is in.

bucketwhat it meanscost to change
fixed A published cross-repo contract — a wire DTO field name, or a column that exists in prod. Coordinated multi-repo change, or a Flyway migration against live data. Breaking if done unilaterally.
flexible Axis-internal: entities, mappers, the predicate, handlers, table shapes axis owns. An ordinary axis PR. No external coordination, provided the fixed surface keeps its shape.
branch-soft Code/schema that exists only on the unmerged feat/246-card-expiry branch — not yet released anywhere. Free to reshape now. Becomes fixed the moment PR #273 reaches prod.
2

Seam 1 — The DTO / Wire Contract

platform-core PaymentCard · looks like the hardest seam — but soft via expand/contract, because every branching consumer is ours

the three lock fields — response-only, released

platform-core/.../shared/cpm/rest/PaymentCard.java — a Gson POJO, @Expose annotations only (field name = JSON key). All three lock fields are @Expose(deserialize = false) — they ship on responses but are ignored on inbound parse, so request-side consumers are untouched by any change to them.

fieldtypeannotationbucket
requiresKycBoolean@Expose(deserialize=false) PaymentCard:86fixed name
kycLockedBoolean@Expose(deserialize=false) PaymentCard:89fixed name
deferredLoadAmountDeferredLoadAmount{BigDecimal amount, String currency}@Expose(deserialize=false) PaymentCard:92fixed name

deferredLoadAmount is already shipped — present in the platform-core DTO and on setldhub-api origin/main. It is a contract to keep emitting, not a field to add.

where the fields are produced — all axis, all flexible
// axis PaymentCardEntity.toPaymentCardDTO(accessLevel) :359
dto.setRequiresKyc(getRequiresKyc());   // :392
dto.setKycLocked(isKycLocked());      // :393
dto.setDeferredLoadAmount(
  getDeferredAmount(getId()));         // :397, from KYC_LOCK

// getRequiresKyc() :520 — the D4 "lie"
if (this.requiresKyc == null)
  return KycLockService.isKycRequired(this);
// ↑ kyc flag ALONE — reg-only card reads false
  • The mapper is axis-internal. toPaymentCardDTO, getRequiresKyc, isKycLocked, getDeferredAmount all live in PaymentCardEntity. flexible
  • So the meaning is free to change. Under the design, kycLocked ⇒ "pending-activation record exists", requiresKyc ⇒ "design needs KYC", deferredLoadAmount ⇒ the record's amount. Derive-and-keep-emit — no consumer notices.
  • This is where the D4 defect is fixed — make kycLocked/a reason reflect registration locks too, without a new field.
consumers — the .NET tier is pipes; the Angular UIs are the sinks

Correction. An earlier pass stopped at "pure pass-through, empty blast radius." That holds for the .NET middle tier only — and it was the wrong place to stop. The fields cross setldhub-api / card-balance-api / IncommListenerApi unbranched, then are actively branched on in two Angular front-ends. A semantic change is observable; the contract is the field names and their truth-conditions.

consumeruses the fields to…kind
card-balance-ui cardholder · balance.component.ts:128if (requiresKyc && kycLocked) → prompt "registration required" and route the holder to /cards/kycthe live registration funnel
setldhub admin · card-details + kycStatus pipe render lock status (kycLocked), "Registration Required" (requiresKyc), the deferred amount; mask available balance while requiresKyc && kycLocked; derive isRegistered = kycLocked===falseadmin display + gating
setldhub-api · card-balance-api · IncommListenerApi · sms-notifier · fx-notify .NET, main deserialize → re-serialize; no branch (sms-notifier & fx-notify carry the field on their model but never read it) pass-through pipe

Both UIs read requiresKyc && kycLocked as "pending verification" — which matches the design's intended meaning, so derive-and-keep-emit still works as long as the truth-conditions hold: kycLocked ⇔ held-pending, requiresKyc ⇔ needs registration, deferredLoadAmount ⇔ the parked amount.

the card-status enum — no "pending" value, and that's deliberate

Constants.CardStatus (platform-core) is a processor mirror: Activated, Blocked, Suspended, Risk, Stolen, Lost, Expired, NotActivated, Fraud. There is no "pending verification" value — confirmed absent. Constants.KycStatus is just {Verified, Unverified}.

  • Adding an enum value is serialization-safe but the brittle part is axis-side: CardStatusMapper.fromTribeStatus*() switches over raw Tribe codes (A/B/T/R/S/L/E/N/F) and throws on unknown. flexible (axis)
  • The design's choice holds: keep verification off the status enum (it's a separate concern, modelled as the pending-activation record). A pending card sits at the processor's normal Suspended code; the record carries the verification meaning.
3

Seam 2 — Shared Database Objects

three tables across two schemas · columns confirmed against setldpay_prod

cpm.kyc_lock — axis-owned · the parked-load store prod: 7 cols · 39,931 rows

Mapped by axis/.../kyc/lock/KycLockEntity.java (@Table(name="KYC_LOCK")). The JPA @Column names are unquoted camelCase, so Postgres folds them to lowercase — the actual prod columns are spicardid, referencenumber, etc. No initial_funded_date — and a full-DB search for %funded_date% returns nothing in prod.

prod columntypeholds
idbigintPK = the card id (one row per card's parked load)
spicardidbigintTribe's card id
referencenumber · descriptionvarcharload reference + label
currencyamountjsonthe deferred amount
load_channeljsonadded by axis V3 (the one snake_case col)
createdattimestampinsert-only

Bucket: the columns that exist are released (live data), but the table is axis-owned — its shape is flexible to restructure (e.g. into the design's pending-activation record + a since + a fund-once ref) as long as axis keeps populating deferredLoadAmount on the DTO. 39,931 rows means a migration here touches real history — plan it as data, not just DDL.

cpm.payment_card — axis-owned · the card prod: 1,857 kyc_locked
prod columntypenotes
kyc_lockedbooleanthe hold flag — set on activate, cleared on release
requires_kycboolean per-card override; else derived from the kyc flag (getRequiresKyc)

No deleted_at on this table. Tribe status ("A"/"T") lives at the processor, mirrored via card_update_activity. Bucket: columns released; meaning is flexible (the design replaces the boolean with record-presence, keeping the column emitting kycLocked for compat or retiring it via migration). Shared read: core also maps cpm.payment_card (its own PaymentCardEntityREQUIRES_KYC) and re-derives its own requiresKyc — so like cpm.kyc_lock, it's read by both Java services.

core.program_configuration — core-owned · axis reads it cross-schema prod-confirmed

Created by core Flyway V12 (on main). axis maps it read-only via ProgramConfigurationEntity (@Table(name="program_configuration", schema="core")) — a cross-service read over a shared table, not an API. The lock requirement is read from here.

prod columnrolestate
is_kyc_required · is_registration_requiredthe live triggers — feed the predicatereleased · core-owned
is_fund_loaded_during_initial_activationintended fund-timing flagdead — no reader in axis or core
is_fund_loaded_after_registrationintended fund-timing flagdead — no reader in axis or core
cdd2_source_of_funds_required(adjacent KYC flag, not lock-related)released
card_expiry_scenario_idcard-expiry FK (V49)branch-onlyabsent in prod

Two seam facts: (1) retiring the dead fund flags (design OPEN-5) is a core migration, coordinated, not an axis change. (2) The lookup ProgramConfigurationRepository.getByDesignId returns list.get(0) with no deleted_at filter though the column is mapped — defect D6, a one-line axis fix. The design also re-keys this read on (program, design), not designId alone.

what actually runs in prod — the config space is tiny prod

The 4-flag space is mostly theoretical. Active (deleted_at IS NULL) configs in prod collapse to two shapes:

kycregfund_initfund_postreglock?prod designs · programs
ttftLOCKED65 · 23
ffffopen9 · 4

In prod, locked ⟺ fund_postreg ⟺ (kyc ∧ reg) — the dead flag happens to agree 100%, and reg/kyc are always both-set or both-clear. fund_init is false everywhere ("lock + pre-fund" has zero instances). The reg-only Touchwood shape is not in prod (staging-only) — so the redesign has room to define its behavior before it ships.

4

Seam 3 — The HTTP Contracts

the cross-service calls the design needs — all already exist

release · core → axis & card-balance-api → axis released

One endpoint, overloaded on personUuid. POST /api/cpm/v1/partners/{partnerExtId}/kyc/inquiries/verifyIndividualKycInquiryHandler.onVerifyIndividual.

// onVerifyIndividual :38
if (req.getPersonUuid() != null && !req.getPersonUuid().isEmpty()) {  // → RELEASE branch
  PaymentCardEntity card = PaymentCardEntity.findById(cardId);  // :47 — UNSCOPED (the IDOR)
  activateAndLoadCard(cardId, card);            // un-suspend + replay parked load
}
else { // → INQUIRY branch: KycInquiryValidator + screening/comprehensive (no money) }
callerbody sentwhich branch
core CardLinkingService → AxisHttpClient, mainVerifyIndividualRequest{Long cardId, UUID personUuid}always release
card-balance-api KycScreeningsController, mainKycInfoForward{cardId, inquiryType, personUuid?, personInfo?} (camelCase, null-omitted) release if the UI supplied personUuid, else inquiry

card-balance-api is a transparent proxy — it never sets personUuid itself; the cardholder UI decides. So release is not core-only: two services reach it. The design's split (release = orchestrator-only, partner-scoped; inquiry = cardholder-facing) must cover both callers.

verdict · axis → core released

The "is this person verified, to what level" read — already wired, already used. This is the channel for the design's amount-aware, never-cached verdict check.

// axis Persons.getPerson → CoreApi
GET /core/api/v1/{partnerExternalId}/person/{uuid}
// → core PersonController.getPerson :135 → GetPersonResponse
kycResults: List<KycResultDTO> { status:KycStatus, kycLevel:KycLevelEnum, validFrom, validUntil }

axis reads kycResults and compares level to the amount band — the "verdict" the design checks at money-movement. (A write-side POST …/person/{uuid}/card/{cardId} = validateOrLinkCard also exists.) Bucket: the call exists and is flexible to consume more deeply; no new dependency direction.

5

The Branch-Soft Window

everything core-side is unreleased · PR #273 open · prod-confirmed absent

core-service is on feat/246-card-expiry — 12 ahead, 0 behind, PR #273 OPEN
artifactwherestate
core KycLockEntity (@Table kyc_lock, schema=cpm), KycLockRepository, KycLockService (create / clone / getRegistrationLockByCardId) core via PR #265 → branch onlybranch-soft
CardsFacade.resolveRegistrationLock + clone-on-transfercore CardsFacade:91,174branch-soft
initial_funded_date on cpm.kyc_lock (axis V6 + core's branch entity) axis + core branchesbranch-soft · absent in prod
core.program_configuration.card_expiry_scenario_id (V49) + scenario table core branchbranch-soft · absent in prod

getRegistrationLockByCardId = "a kyc_lock row exists with initial_funded_date IS NULL" — i.e. activated-but-never-funded. So initial_funded_date is purely the "claimed/funded" marker — the design's pending-activation record by another name — with no fee/expiry/settlement reader anywhere (axis, core, .NET, Node all checked). Fold it into the record; don't preserve it.

6

How the Design's Flows Land on the Seams

each flow, and exactly which seam it touches — and in which bucket the change sits

design flowDTO ①DB ②HTTP ③net change bucket
activation
activate, no funds
emit kycLocked from record-presence create pending record (no amount) in place of kyc_locked+KycLock none (local design read for requirement)axis-only
activate-and-load
+ amount
emit deferredLoadAmount from recordrecord carries the deferred amountbalance check; verdict read if person linkedaxis-only
release
verification event
fields flip to usable on clearatomic claim of the record (fund-once)scope + split the endpoint; verdict re-check before moneyaxis + core co-design
replacement
carry-over
unchangedrecord moves with the card (single owner, one claim)none if axis owns itaxis (vs core clone-on-transfer today)

The pattern: every flow is axis-internal except where it touches core — the verdict read (already wired) and the replacement/transfer path (where feat/246 currently has core writing cpm.kyc_lock). Hold the DTO field names steady and consumers never see the rebuild.

7

Phasing Against the Seams

phaseseams touchedcoordination
Security fix standalone or Phase 1HTTP ③ — scope the release lookup, split/role the endpointaxis-only — no external coordination
Phase 1 — axis DTO ① (derive-and-keep-emit) · DB ② (cpm.kyc_lock → pending-activation record, fund-once) · HTTP ③ (verdict re-check, record-free idempotent release) · the predicate & D6/keying fixes · truth-table tests axis-only — DTO names + truth-conditions unchanged ⇒ consumers (incl. the cardholder funnel) untouched
Phase 2 — core + Stream D DB ② (initial_funded_date fold-in; who owns cpm.kyc_lock) · replacement/transfer ownership · retire dead fund flags (core migration) co-design with feat/246 before #273 merges