Registration & KYC — The Design
State of Play — Start Here
A card's design says whether it needs verification — axis knows that locally. Whether verification is satisfied is core's live answer, asked only at the moment money would move — never cached. The sole extra state axis keeps is one optional pending-activation record: its existence means "awaiting verification," and it carries the deferred load (if any) to apply when the card goes live. No held-flags, no reasons cache, no side-records.
working draft · check-on-demand model + the three flows · OPEN items still need a decision
- The mess (today): the registration/KYC "hold" is spread across three stores — axis
KycLockEntity, core's branch-only copy, and a vestigial .NETCardRegistrationLockRequiredtable — gated by one conflated boolean, released through an overloadedverifyIndividualendpoint carrying a live cross-tenant IDOR, entangled with the unmergedfeat/246-card-expirybranch, across 5+ repos with branch/staging/prod skew. - The target (simple): a card carries one optional pending-activation record (existence = awaiting verification; holds the deferred load) + a derived
kycLocked/requiresKycsignal. Verification is an event that releases the card and applies the deferred load once.usable ⇔ active AND no record. (§00–§02.) - Hard to change (entrenched): only the DTO contract
requiresKyc/kycLocked/deferredLoadAmount(derive-and-keep-emit) + the prod schema. The core-side machinery +initial_funded_dateare unreleased branch/staging code — still soft; shape them beforefeat/246reaches prod. (§07.) - Needs a human (Grif/product): OPEN-1…5 (§08) + the held-card expiry-anchor question — card-expiry counts from the activation date, so a pending card burns its expiry window before it's ever usable.
- One mechanical safety fix, already carded: the release IDOR → partner-scope it + split the overloaded endpoint (release = orchestrator-only; inquiry/screening = cardholder-facing). Release is reachable by core and card-balance-api.
- Next build step: Phase 1 in axis (the record + idempotent record-free release + the security fix + truth-table tests) — §07. The rest is cross-service coordination, chiefly with
feat/246.
The Model
- Requirement — "does this design need verification" — read from the design, locally, by axis.
- Verdict — "is it satisfied for this holder & amount" — core's live answer, asked at money-movement. Never cached.
- Pending-activation record — axis's only extra state: optional
{deferred_amount?, channel?, ref?, since}. Existence = awaiting verification; amount = the deferred load.
- usable ⇔ processor-active AND no pending-activation record.
- money moves only on a fresh core "verified" — every time. The check is amount-aware, so a load needing deeper KYC than the holder has simply doesn't pass.
- funded exactly once — the record is claimed-and-cleared in one conditional update.
What Axis Persists
The processor suspend (enforcement) + one optional record. That's it.
| thing | holds | replaces |
|---|---|---|
| processor suspend | the card is held at Tribe (status T) while pending — the actual enforcement | the same suspend used today, but no longer paired with a boolean |
pending-activation record (nullable) | existence = awaiting verification; optional {deferred_amount, channel, ref} + since | the kyc_locked boolean, the KycLockEntity side-record (incl. core's copy), and any pending-reasons cache |
No verdict, no reasons stored. "Usable vs pending" is local (record absent vs present). "Why pending / which step" is core's, fetched on demand for the consumer signal. Fund-once is one conditional update:
-- claims the record so two nudges can't double-fund UPDATE payment_card SET pending_activation = NULL, funded_ref = :ref WHERE id = :id AND pending_activation IS NOT NULL RETURNING deferred_amount, deferred_channel;
Row + amount returned ⇒ load it at the processor using :ref (idempotent there too). No row ⇒ already cleared ⇒ no-op. A record with no amount ⇒ just unsuspend.
The Three Flows
Activation · activate-and-load · registration. The verdict check is amount-aware and always against core's live state.
axis request
activate(card) from a partner.
axis · local does the design need verification?
Read the design's verification requirement — no core call needed for this.
no → activate at processor → Active & usable. Done.
yes → continue.
axis → core is a person already linked?
Only then is a verdict even possible. Fresh activation has no person yet (registration comes later) → skip the call, treat as not-verified.
linked & core says verified → activate → Active & usable (the replacement / re-issue case).
not linked, or not verified → continue.
axis · tribe go pending
Create a pending-activation record (no amount) and suspend at the processor. Not usable. No money involved.
axis request + balance check
activateWithLoad(card, amount) → check the funding account can cover it.
axis → core needs verification? (same check as ①)
no, or already verified → activate + load now → Active & usable.
needs it & not verified → continue.
axis · tribe defer the load
Create a pending-activation record carrying the amount, suspend at the processor. No money moves — it's applied on release (flow ③).
cardholder site holder registers
The cardholder registers their card; the site drives core.
core core runs KYC
Resolves the required level from the amount, runs the provider(s), records the result. core is the verification authority.
core / card-balance-api → axis nudge axis
An orchestrator — core-service (CardLinkingService) or card-balance-api (forwarding the cardholder's personUuid + cardId) — calls verifyIndividual with personUuid → the release branch: "person P verified, re-evaluate card X." (The same overloaded endpoint serves no-personUuid KYC screening too — see OPEN-0 on splitting it.)
axis → core axis checks core, before moving any money
There's a pending-activation record → ask core to confirm the holder is verified for this design & the deferred amount.
not verified → no-op; stays pending. (A stale or wrong nudge can't fund anything.)
verified → continue.
axis · tribe claim, load, unsuspend
Atomically claim the record, apply the deferred load (idempotent ref), unsuspend → Active & usable.
Ownership — And the Calls Already Exist
- core = compliance brain. Defines what verification is required, resolves the amount-banded KYC level, runs providers, and holds the authoritative record + history (
kyc_result+ Envers). Verification attaches to the person. - axis = card & funds engine + sole processor gateway. Owns card state, the pending-activation record, and all Tribe ops. Reads the design requirement locally (the static booleans, keyed by
(program, design)— fixing today'sdesignId-only lookup); asks core for the verdict. - The calls already exist both ways. core→axis (release); and axis→core (
CoreApi/Personsviacore.api.baseUrl, already used inCardAccountMultiActionValidator). The verdict check reuses this established direction — not a new dependency. - Two authorization surfaces. Partner-facing (
/api/cpm/*, partner role + program scope) for activate/load; orchestrator/internal (the/internal-api/*pattern, a role only core holds) forrelease/hold/replace. - The consumer signal is a live contract.
requiresKyc/kycLockedflow platform-core → axis API → setldhub-api (.NET) → setldhub → card-balance-ui. The DTO keeps emitting them derived —kycLocked= pending-activation record exists,requiresKyc= design needs kyc — so consumers keep working unchanged; renaming is a coordinated multi-repo migration.
How Axis Knows It's Safe to Release
A service boundary is a trust boundary. Three gates, all primary — plus the money-movement check that backstops them.
Authentication — who is calling?
Caller identity from its credential. Have it.
Action authorization — may this caller release?
An orchestrator-only capability only core holds — not the broad partner role. This is what makes trusting core's verdict safe: only core can ask.
Resource scope — which card?
Partner-scoped lookup, so a caller can only touch its own cards. Skipped on the release fast-path today — the live IDOR.
Money-movement check — backstop
Even an authorized release moves no money unless the amount-aware core verdict passes (flow ③). A stale/replayed/spoofed nudge funds nothing. Plus the atomic claim → funded once.
Full Card Replacement
- The new card takes over; the source is retired.
- Usable source → balance moves to the replacement, usable. The holder is already verified (verification is on the person), so no re-verification.
- Pending source → the pending-activation record carries over to the replacement (deferred amount and all); it releases on verification, gated by the same core check. Done within axis (single owner, one atomic claim) — no cross-service clone, no race.
Why Verification Is a Record, Not a Card Status
The card-status enum is a processor mirror in a shared library, consumed by exhaustive switches that throw on unknown values — and the processor has no "pending verification" code. A new status would mean a shared-library change, a fake mapping, and risk to every switch — and it would be wrong: "suspended at the processor" and "awaiting verification" are different concerns. So a pending card sits at the processor's normal suspended code, and the pending-activation record carries the verification meaning. usable = active and no record.
Release State & Phased Migration
The heavy core-side machinery turns out to be unreleased — only on the feat/246-card-expiry branch, absent from prod. The genuinely fixed surface is narrow.
| surface | state | changing it |
|---|---|---|
DTO contract requiresKyc / kycLockedplatform-core → axis API → setldhub-api (.NET) → setldhub → card-balance-ui | released | derive-and-keep-emit (compat), else coordinated multi-repo rename |
Prod schema — payment_card.kyc_locked/requires_kyc, kyc_lock (7-col), program_configuration flags | released | Flyway migration |
core KycLock entity/service + clone-on-transfer; initial_funded_date (axis V6) | unreleased — branch + staging | free to reshape now, before feat/246 reaches prod |
"On main" ≠ "in prod" — all six repos deploy manually, so the prod schema is the only definitive released signal. initial_funded_date has no fee/expiry reader anywhere — axis, core, .NET, and the Node platform services all checked, none found — and is the same "claimed/funded" marker as the pending-activation record, so fold it in, don't preserve it. The V6 migration is deployed to staging (feat/246 integration) but absent from dev and prod. Branch-verified: the card-expiry feature anchors expiry on the activation date (CardUpdateActivity.createdTs) — explicitly even for held cards — not on initial_funded_date.
release a nudge that checks core + claims + loads (idempotent, no stranding); gate every fund-movement on the amount-aware core verdict; partner-scope + action-authz the release endpoint (the security fix); fix the requirement keying; pin the flows with behavioural tests. Keep emitting requiresKyc/kycLocked derived (DTO compat). feat/246-card-expiry branch — not a removal of released code. core's KycLock entity/service + clone-on-transfer and initial_funded_date live only there; settle the model before that branch merges (fold initial_funded_date into the pending-activation record), after which it hardens into a released cross-repo schema contract. Expose the verdict check cleanly; route replacement through axis's replace. Open Decisions
◆ = likely needs product / Grif. The model supports either answer to each.
verifyIndividual overloaded; release reachable by core + card-balance-api
One endpoint, branched on personUuid: with it → release (activateAndLoadCard — activate + load); without → a KYC inquiry / screening (no money). card-balance-api is a thin forwarder — its KycInfoForward carries personUuid + cardId, so it can hit the release branch too (when the cardholder UI supplies them). So release is NOT core-only: both core-service (CardLinkingService) and card-balance-api can trigger it.
Resolution: split the endpoint (release vs inquiry); the release op's orchestrator role must cover both core and card-balance-api (excluding partner users) — or consolidate the cardholder release path through core. The IDOR (unscoped findById on the release branch) → partner-scope it. Open sub-q: is the live cardholder release done via card-balance-api or via core? (a card-balance-ui trace settles it).
Bare activate on a fund-after-verification design
Card becomes usable with nothing to load. Require activateWithLoad, or allow it (funds via a later load)?
- Require
activateWithLoadup front. - Allow bare
activate; funds arrive via a later load.
Need: the real partner flow. → Grif
Loading a card that's still pending
Reject ("not yet verified"), or extend the deferred amount on the record?
- Reject the load until verification clears.
- Top up the record's deferred amount in place.
Lean: reject, unless top-up-before-verification is a real need.
Does a later load re-gate a usable card?
The amount-aware check means a load needing a deeper level than the holder has will fail. Policy: reject that load, or re-suspend the card pending the higher level?
- Reject the over-level load; card stays usable.
- Re-suspend the card pending the higher level.
Need: compliance intent. The mechanism supports either. → Grif
Verification fails or never arrives
A card sits pending. Expire/cancel after a window (the record's since makes this a cheap sweep)? Partner-driven resolution? Cross-feature interaction: card-expiry anchors on the activation date, so a held card's expiry clock runs while it's still pending — a never-verified card can age toward (or past) expiry without ever being usable. The branch chose this deliberately; confirm it's intended.
Need: product + compliance. → Grif
Retire the dead fund-timing flags?
Funding follows the requirement; the flags are unused. Columns are core-schema.
Lean: ignore + document now; schedule removal with core.