Registration & KYC -- The Design

draftCode solution for the registration & KYC redesign. Check-on-demand model + the three flows.· 2026-05-30
0

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 .NET CardRegistrationLockRequired table — gated by one conflated boolean, released through an overloaded verifyIndividual endpoint carrying a live cross-tenant IDOR, entangled with the unmerged feat/246-card-expiry branch, 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/requiresKyc signal. 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_date are unreleased branch/staging code — still soft; shape them before feat/246 reaches 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).
1

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.
2

What Axis Persists

The processor suspend (enforcement) + one optional record. That's it.

ThingHoldsReplaces
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:

sql
 -- 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.

3

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

  1. Request (axis):activate(card) from a partner.
  2. 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.
  3. 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.
  4. 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

  1. Request + balance check (axis):activateWithLoad(card, amount) → check the funding account can cover it.
  2. Needs verification? (same check as flow 1):
    No, or already verified → activate + load now → Active & usable.
    Needs it & not verified → continue.
  3. 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)

  1. Holder registers (cardholder site): The cardholder registers their card; the site drives core.
  2. Core runs KYC (core): Resolves the required level from the amount, runs the provider(s), records the result. core is the verification authority.
  3. Nudge axis (core / card-balance-api → axis): An orchestrator calls verifyIndividual with personUuid → the release branch.
  4. 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.
  5. Claim, load, unsuspend (axis, tribe): Atomically claim the record, apply the deferred load (idempotent ref), unsuspend → Active & usable.
4

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 / kycLocked flow platform-core → axis API → .NET → Angular. The DTO keeps emitting them derived so consumers keep working unchanged.
5

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.

GateDescriptionStatus
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
6

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.
7

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.

8

Release State & Phased Migration

What's entrenched vs still soft

SurfaceStateChanging 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/kycLocked derived (DTO compat).
  • Phase 2 (core + Stream D): Co-design with the unmerged feat/246-card-expiry branch. Settle the model before that branch merges (fold initial_funded_date into 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.
9

Open Decisions

The model supports either answer to each of these.

IDQuestionLean
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