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
0

State of Play -- Start Here

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 is verified against axis / platform-core / core / .NET source, and the live prod schema.

  • The DTO looks like the hard seam — but it's soft with an ordered rollout. The PaymentCard fields requiresKyc / kycLocked / deferredLoadAmount pass through the .NET tier unbranched and are branched on by two Angular UIs. But every branching consumer is our code, so the names/meanings are reshapeable via expand/contract.
  • 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.
  • 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.
  • The big lever is unreleased. core's entire KycLock machinery + initial_funded_date are branch-only (PR #273, open) — prod-confirmed absent. This is the co-design window.
  • One framing correction: platform-core owns no database schema — no migrations, no entities. The lock tables are axis-owned.
  • 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.
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

FieldTypeAnnotationBucket
requiresKyc Boolean @Expose(deserialize=false) FIXED name
kycLocked Boolean @Expose(deserialize=false) FIXED name
deferredLoadAmount DeferredLoadAmount{BigDecimal amount, String currency} @Expose(deserialize=false) FIXED name

Where the fields are produced — all axis, all flexible

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

Consumers — the .NET tier is pipes; the Angular UIs are the sinks

ConsumerUses the fields to...Kind
card-balance-ui (cardholder) if (requiresKyc && kycLocked) -> prompt "registration required" and route to /cards/kyc The live registration funnel
setldhub (admin) Render lock status (kycLocked), "Registration Required" (requiresKyc), the deferred amount; mask available balance while requiresKyc && kycLocked Admin display + gating
.NET services (setldhub-api, card-balance-api, etc.) Deserialize -> re-serialize; no branch Pass-through pipe

The card-status enum — no "pending" value

Constants.CardStatus (platform-core) is a processor mirror: Activated, Blocked, Suspended, Risk, Stolen, Lost, Expired, NotActivated, Fraud. There is no "pending verification" value. The design's choice holds: keep verification off the status enum (it's a separate concern, modelled as the pending-activation record).

3

Seam 2 -- Shared Database Objects

Three tables across two schemas. Columns confirmed against setldpay_prod.

cpm.kyc_lock — axis-owned (prod: 7 cols, 39,931 rows)

Prod columnTypeHolds
id bigint PK = the card id (one row per card's parked load)
spicardid bigint Tribe's card id
referencenumber / description varchar Load reference + label
currencyamount json The deferred amount
load_channel json Added by axis V3 (the one snake_case col)
createdat timestamp Insert-only

cpm.payment_card — axis-owned (prod: 1,857 kyc_locked)

Prod columnTypeNotes
kyc_locked boolean The hold flag -- set on activate, cleared on release
requires_kyc boolean Per-card override; else derived from the kyc flag (getRequiresKyc)

core.program_configuration — core-owned, axis reads cross-schema

Prod columnRoleState
is_kyc_required / is_registration_required The live triggers -- feed the predicate Released, core-owned
is_fund_loaded_during_initial_activation Intended fund-timing flag Dead -- no reader in axis or core
is_fund_loaded_after_registration Intended fund-timing flag Dead -- no reader in axis or core
cdd2_source_of_funds_required (adjacent KYC flag, not lock-related) Released
card_expiry_scenario_id Card-expiry FK (V49) Branch-only -- absent in prod

What actually runs in prod — the config space is tiny

kycregfund_initfund_postregLock?Prod designs / programs
t t f t LOCKED 65 / 23
f f f f open 9 / 4
4

Seam 3 -- The HTTP Contracts

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

Release — core → axis & card-balance-api → axis

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

java
 // 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, main) VerifyIndividualRequest{Long cardId, UUID personUuid} Always release
card-balance-api (KycScreeningsController, main) KycInfoForward{cardId, inquiryType, personUuid?, personInfo?} Release if the UI supplied personUuid, else inquiry

Verdict — axis → core

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.

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

The Branch-Soft Window

Everything core-side is unreleased. PR #273 open. Prod-confirmed absent.

core-service on feat/246-card-expiry — 12 ahead, 0 behind, PR #273 OPEN

ArtifactWhereState
core KycLockEntity, KycLockRepository, KycLockService core via PR #265 -> branch only Branch-soft
CardsFacade.resolveRegistrationLock + clone-on-transfer core CardsFacade:91,174 Branch-soft
initial_funded_date on cpm.kyc_lock (axis V6 + core branch entity) axis + core branches Branch-soft, absent in prod
core.program_configuration.card_expiry_scenario_id (V49) core branch Branch-soft, absent in prod
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 flowDTODBHTTPNet change bucket
Activation (no funds) Emit kycLocked from record-presence Create pending record (no amount) None (local design read) Axis-only
Activate-and-load (+ amount) Emit deferredLoadAmount from record Record carries the deferred amount Balance check; verdict read if person linked Axis-only
Release (verification event) Fields flip to usable on clear Atomic claim of the record (fund-once) Scope + split the endpoint; verdict re-check Axis + core co-design
Replacement (carry-over) Unchanged Record moves with the card None if axis owns it Axis (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.

7

Phasing Against the Seams

PhaseSeams touchedCoordination
Security fix (standalone or Phase 1) HTTP -- scope the release lookup, split/role the endpoint Axis-only -- no external coordination
Phase 1 -- axis DTO (derive-and-keep-emit), DB (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 unchanged => consumers 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