Registration & KYC — The Design

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

working draft · check-on-demand model + the three flows · OPEN items still need a decision

reload the whole picture in a minute (as of 2026-05-30)
  • 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. (§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_date are unreleased branch/staging code — still soft; shape them before feat/246 reaches 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.
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.
card lifecycle — not activated → active & usable, direct or via pending
activate · open design activate · needs verification core nudge → axis checks core → verified claim record · apply deferred load verification fails / never arrives Not activated Active & usable no record Pending pending-activation record · suspended · not usable Active & usable record cleared · funds present fate undecided OPEN-4
2

What Axis Persists

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

thingholdsreplaces
processor suspendthe card is held at Tribe (status T) while pending — the actual enforcementthe 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.

3

The Three Flows

Activation · activate-and-load · registration. The verdict check is amount-aware and always against core's live state.

① activation — activate, no funds

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.

② activate-and-load — activateWithLoad, with an amount

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 nowActive & 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 ③).

③ registration — the deferred-load completion (cross-service)

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.

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 (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's designId-only lookup); asks core for the verdict.
  • The calls already exist both ways. core→axis (release); and axis→core (CoreApi/Persons via core.api.baseUrl, already used in CardAccountMultiActionValidator). 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) for release / hold / replace.
  • The consumer signal is a live contract. requiresKyc / kycLocked flow platform-core → axis API → setldhub-api (.NET) → setldhub → card-balance-ui. The DTO keeps emitting them derivedkycLocked = pending-activation record exists, requiresKyc = design needs kyc — so consumers keep working unchanged; renaming is a coordinated multi-repo migration.
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.

1

Authentication — who is calling?

Caller identity from its credential. Have it.

have
2

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.

missing today
3

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.

gap today

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.

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 — release-state sweep, 2026-05-30

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.

surfacestatechanging it
DTO contract requiresKyc / kycLocked
platform-core → axis API → setldhub-api (.NET) → setldhub → card-balance-ui
releasedderive-and-keep-emit (compat), else coordinated multi-repo rename
Prod schema — payment_card.kyc_locked/requires_kyc, kyc_lock (7-col), program_configuration flags releasedFlyway migration
core KycLock entity/service + clone-on-transfer; initial_funded_date (axis V6) unreleased — branch + stagingfree 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.

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 — 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.
Cross-cut
Retire the dead fund-timing flags (core-schema, coordinate with core). Confirm deployed prod versions — manual deploy means on-main ≠ live.
9

Open Decisions

◆ = likely needs product / Grif. The model supports either answer to each.

OPEN-0 · resolved

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

OPEN-1 ◆

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 activateWithLoad up front.
  • Allow bare activate; funds arrive via a later load.

Need: the real partner flow. → Grif

OPEN-2

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.

OPEN-3 ◆

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

OPEN-4 ◆

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

OPEN-5

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.