Registration & KYC — The Seams
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.
State of Play — Start Here
- The DTO looks like the hard seam — but it's soft with an ordered rollout. The
PaymentCardfieldsrequiresKyc/kycLocked/deferredLoadAmount(platform-core) pass through the .NET tier unbranched and are branched on by two Angular UIs (the cardholder funnel gates onrequiresKyc && 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, noinitial_funded_date),core.program_configurationflags. 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/246makes core read & write axis'scpm.kyc_lockdirectly — the opposite of the design's "axis is sole owner." Resolve before #273 merges. §05
The Three Buckets
Every seam below carries one of these. The whole point of the map is which bucket each object is in.
| bucket | what it means | cost 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. |
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
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.
| field | type | annotation | bucket |
|---|---|---|---|
requiresKyc | Boolean | @Expose(deserialize=false) PaymentCard:86 | fixed name |
kycLocked | Boolean | @Expose(deserialize=false) PaymentCard:89 | fixed name |
deferredLoadAmount | DeferredLoadAmount{BigDecimal amount, String currency} | @Expose(deserialize=false) PaymentCard:92 | fixed 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.
// 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,getDeferredAmountall live inPaymentCardEntity. 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.
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.
| consumer | uses the fields to… | kind |
|---|---|---|
| card-balance-ui cardholder · balance.component.ts:128 | if (requiresKyc && kycLocked) → prompt "registration required" and route the holder to /cards/kyc | the 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===false | admin 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.
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
Suspendedcode; the record carries the verification meaning.
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 column | type | holds |
|---|---|---|
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 |
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 column | type | notes |
|---|---|---|
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) |
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 PaymentCardEntity → REQUIRES_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 column | role | state |
|---|---|---|
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 |
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.
The 4-flag space is mostly theoretical. Active (deleted_at IS NULL) configs in prod collapse to two shapes:
| kyc | reg | fund_init | fund_postreg | lock? | prod designs · programs |
|---|---|---|---|---|---|
| t | t | f | t | LOCKED | 65 · 23 |
| f | f | f | f | open | 9 · 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.
Seam 3 — The HTTP Contracts
the cross-service calls the design needs — all already exist
One endpoint, overloaded on personUuid. POST /api/cpm/v1/partners/{partnerExtId}/kyc/inquiries/verifyIndividual → KycInquiryHandler.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) }
| caller | body sent | which branch |
|---|---|---|
| core CardLinkingService → AxisHttpClient, main | VerifyIndividualRequest{Long cardId, UUID personUuid} | always release |
| card-balance-api KycScreeningsController, main | KycInfoForward{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.
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.
The Branch-Soft Window
everything core-side is unreleased · PR #273 open · prod-confirmed absent
feat/246-card-expiry — 12 ahead, 0 behind, PR #273 OPEN | artifact | where | state |
|---|---|---|
core KycLockEntity (@Table kyc_lock, schema=cpm), KycLockRepository, KycLockService (create / clone / getRegistrationLockByCardId) | 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's branch entity) | axis + core branches | branch-soft · absent in prod |
core.program_configuration.card_expiry_scenario_id (V49) + scenario table | core branch | branch-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.
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 flow | DTO ① | 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 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 before money | axis + core co-design |
| replacement carry-over | unchanged | record moves with the card (single owner, one claim) | 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 (where feat/246 currently has core writing cpm.kyc_lock). Hold the DTO field names steady and consumers never see the rebuild.
Phasing Against the Seams
| phase | seams touched | coordination |
|---|---|---|
| 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 ② (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 |