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.
- 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. - 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. - Needs a human (Grif/product): OPEN-1…5 + the held-card expiry-anchor question.
- One mechanical safety fix, already carded: the release IDOR → partner-scope it + split the overloaded endpoint.
- Next build step: Phase 1 in axis (the record + idempotent record-free release + the security fix + truth-table tests).
The Model
Where each fact lives
- 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.
The invariants
- 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.
Flow 1: Activation — activate, no funds
- Request (axis):
activate(card)from a partner. - Does the design need verification? (axis, local): Read the design's verification requirement — no core call needed.
No → activate at processor → Active & usable. Done.
Yes → continue. - Is a person already linked? (axis → core): Only then is a verdict even possible. Fresh activation has no person yet → skip the call, treat as not-verified.
Linked & core says verified → activate → Active & usable.
Not linked, or not verified → continue. - Go pending (axis, tribe): Create a pending-activation record (no amount) and suspend at the processor. Not usable. No money involved.
Flow 2: Activate-and-load — with an amount
- Request + balance check (axis):
activateWithLoad(card, amount)→ check the funding account can cover it. - Needs verification? (same check as flow 1):
No, or already verified → activate + load now → Active & usable.
Needs it & not verified → continue. - Defer the load (axis, tribe): Create a pending-activation record carrying the amount, suspend at the processor. No money moves — it's applied on release (flow 3).
Flow 3: Registration — the deferred-load completion (cross-service)
- Holder registers (cardholder site): The cardholder registers their card; the site drives core.
- Core runs KYC (core): Resolves the required level from the amount, runs the provider(s), records the result. core is the verification authority.
- Nudge axis (core / card-balance-api → axis): An orchestrator calls
verifyIndividualwithpersonUuid→ the release branch. - Axis checks core, before moving any money (axis → core): 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.
Verified → continue. - Claim, load, unsuspend (axis, tribe): 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. 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; asks core for the verdict.
- The calls already exist both ways. core→axis (release); and axis→core via
CoreApi/Persons. 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 for release / hold / replace. - The consumer signal is a live contract.
requiresKyc/kycLockedflow platform-core → axis API → .NET → Angular. The DTO keeps emitting them derived so consumers keep working unchanged.
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.
| Gate | Description | Status |
|---|---|---|
| 1. Authentication | Who is calling? Caller identity from its credential. | Have it |
| 2. Action authorization | May this caller release? An orchestrator-only capability only core holds -- not the broad partner role. | Missing today |
| 3. Resource scope | Which card? Partner-scoped lookup, so a caller can only touch its own cards. | Gap today |
| Backstop: Money-movement check | Even an authorized release moves no money unless the amount-aware core verdict passes. A stale/replayed/spoofed nudge funds nothing. Plus the atomic claim -> funded once. | In model |
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
What's entrenched vs still soft
| Surface | State | Changing it |
|---|---|---|
| DTO contract requiresKyc / kycLocked | 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, not prod | Free to reshape now, before feat/246 reaches prod |
Phases
- Phase 1 (axis): Most of the cleanliness, one service. Introduce the pending-activation record; make 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/kycLockedderived (DTO compat). - Phase 2 (core + Stream D): Co-design with the unmerged
feat/246-card-expirybranch. Settle the model before that branch merges (foldinitial_funded_dateinto the pending-activation record). Expose the verdict check cleanly; route replacement through axis's replace. - Cross-cut: Retire the dead fund-timing flags (core-schema, coordinate with core). Confirm deployed prod versions.
Open Decisions
The model supports either answer to each of these.
| ID | Question | Lean |
|---|---|---|
| OPEN-0 (resolved) | verifyIndividual overloaded; release reachable by core + card-balance-api | Split the endpoint; the release op's orchestrator role must cover both core and card-balance-api |
| OPEN-1 | Bare activate on a fund-after-verification design | Need: the real partner flow. -> Grif |
| OPEN-2 | Loading a card that is still pending | Lean: reject, unless top-up-before-verification is a real need |
| OPEN-3 | Does a later load re-gate a usable card? | Need: compliance intent. The mechanism supports either. -> Grif |
| OPEN-4 | Verification fails or never arrives | Need: product + compliance. -> Grif |
| OPEN-5 | Retire the dead fund-timing flags? | Lean: ignore + document now; schedule removal with core |