Skip to content

fix(realunit): close the PersonalData KYC step a completed registration satisfies - #4559

Open
Danswar wants to merge 6 commits into
developfrom
fix/realunit-close-personal-data-step
Open

fix(realunit): close the PersonalData KYC step a completed registration satisfies#4559
Danswar wants to merge 6 commits into
developfrom
fix/realunit-close-personal-data-step

Conversation

@Danswar

@Danswar Danswar commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Closes item 1 of #4556.

Problem

A completed RealUnit registration lifts the account to LEVEL_20 — which the KycLevel enum defines as personal data — but never closes the PERSONAL_DATA step that level represents.

For an account carrying an open step from an earlier, abandoned KYC attempt the step stays IN_PROGRESS permanently: the auto-completion in initiateStep is gated on !preventDirectEvaluation, which the presence of any prior step row sets, so such an account can never satisfy the step again. KycInfoMapper then keeps handing that stale step back as currentStep, the RealUnit client cannot render it, and onboarding dead-ends with no path forward.

Change

Reconcile the step with the level at every point that concludes the registration is durably in place. KycService.completeSatisfiedPersonalDataStep mirrors what updatePersonalData already does (completecreateStepLogupdateProgress) and reloads the account with kycSteps so the lookup never reads an undefined relation.

Deliberate constraints

  • Never closes an unremedied re-opened step. isDataComplete is a non-null check, not a validity check. When Sumsub reports PROBLEMATIC_APPLICANT_DATA, restartStep fails the completed step and opens a fresh one so the user can correct data that is present but wrong — and the rejection marker lands on the failed row, so a pending-only lookup is no protection. The verdict comes from the most recent step carrying a settled verdict (KycStep.hasSettledVerdict), judged over the account's own chain only. Remediation is read from the step's result and the durable RESTARTED_STEP marker (KycStep.isRejected), not from its status alone: initiateStep cancels a merely pending step as well as a completed one, so CANCELED alone cannot tell an untouched retry from a remediation, whereas result survives cancel() and a step cancelled while pending never had one. A completion later revoked by restartStep keeps its stale result, so the marker — not the status — is what keeps it blocking. Merged-in rows are excluded — a merge seeds them below the floor and orders them chronologically only within a batch, so ranking across batches could let a stale cancellation outrank a newer rejection. Without this guard the retry would be re-completed with the same rejected data, skipping the correction step and stranding the user on Ident.
  • Kept separate from ensureRegistrationKycLevel. That method returns early at LEVEL_20, which is exactly the state a stuck account is already in, so folding the reconciliation into it would skip precisely the population that needs it.
  • Best-effort, like the level lift: a failure is logged rather than turning a durable registration into a 500. It does not self-heal — the log says so explicitly and asks for manual reconciliation, because an already-wedged account does not come back through this path (see Scope below).

Scope: prevents the wedge, does not cure it

This runs when a registration request arrives. An account that is already wedged does not come back through here on its own — for a wallet with a Completed registration getRegistrationInfo answers AlreadyRegistered, and the client then goes straight to the KYC step flow without re-posting register/complete. Such an account is only reached if it registers a further wallet.

The pre-existing backlog is small enough to handle out of band rather than with a data migration, and one of the affected accounts is the rejection shape above, which must not be auto-completed.

Not a behaviour change

The API already asserts the personal data is satisfied by granting LEVEL_20; the open step contradicts a decision the API itself made. No level is granted, no verification requirement changes — Ident and everything above it are untouched. The code path is reachable only through POST /v1/realunit/register/complete.

Verification

  • 16 new tests. Each guard mutation-checked — neutralised body, accepting non-pending steps, dropping the isDataComplete guard, dropping the failed-chain guard, widening it back to “any failed step”, dropping the result discrimination, picking the pending step by first match instead of highest sequence, dropping the own-chain scoping, reading the verdict as isFailed instead of isRejected, and reverting each of the three call sites individually — every mutation turns the suite red.
  • kyc + user + realunit subdomains green (785 passed), tsc --noEmit clean, eslint and prettier clean.

Danswar added 6 commits July 31, 2026 22:01
…on satisfies

A completed RealUnit registration lifts the account to LEVEL_20, which the
KycLevel enum defines as personal data, but never closes the PERSONAL_DATA
step that level represents. For an account carrying an open step from an
earlier abandoned attempt the step stays IN_PROGRESS forever: createStep's
auto-completion is gated on !preventDirectEvaluation, which any prior step
row sets, and KycInfoMapper then keeps returning that stale step as
currentStep. The RealUnit client cannot render it and dead-ends onboarding.

Reconcile the step with the level at every point that concludes the
registration is durably in place, including the idempotent retry paths, so
an account stuck before this fix repairs itself on its next registration.

Scoped to a PENDING step only: preventDirectEvaluation exists so a retry
does not paper over a prior rejection, so a FAILED step keeps going through
the normal flow. Kept separate from ensureRegistrationKycLevel because that
returns early at LEVEL_20 - exactly the state a stuck account is in.
Review follow-ups on the registration reconciliation.

isDataComplete is a non-null check, not a validity check, so the pending
lookup alone could not tell an abandoned step from one restartStep
deliberately re-opened after Sumsub reported PROBLEMATIC_APPLICANT_DATA.
The rejection marker lives on the failed row, not the pending one, so the
retry was matched and would have been re-completed with the same rejected
data - skipping the correction step and stranding the user on Ident. Bail
whenever the PersonalData chain carries a failed step.

The RealUnit spec provided KycService as a bare object, so the new call was
an undefined-function TypeError swallowed by the best-effort catch: the
three call sites were unpinned and an existing lift-failure assertion had
stopped discriminating. Provide the method, pin the call sites, and tighten
that assertion to the lift-specific message.

Move the requiredKycFields projection onto UserData as kycFieldData and use
it at both call sites, correct the stale createStep reference to
initiateStep, drop production identifiers from the fixtures, and remove the
self-heal claim: an already-wedged account answers AlreadyRegistered and
never re-posts register/complete, so this prevents the wedge rather than
curing it.
Second review pass.

The failed-chain guard judged the whole history, so a rejection the user had
already remedied would disable the reconciliation for that account forever.
Take the verdict from the most recent settled step instead: an unremedied
re-open is still blocked, a remedied one stays eligible.

Pin the concurrency-collision call site. The existing spy targets
ensureRegistrationKycLevel, which ensureRegistrationKycState calls
transitively, so reverting that site kept the suite green - the same trap
the other two pinning tests were written to close. All three sites now fail
a test individually when reverted.

Drop the remaining self-heal wording from the log message and the two
comments this PR added. It is contradicted by the scope note eight lines
below: when the reconciliation throws, the account is left wedged and the
log would have told the on-call it recovers by itself, suppressing the
manual follow-up that is actually needed.

Re-pair the new forwardRegistration test with its own comment so the
pre-existing REGRESSION GUARD rationale sits with the test it documents.
…atus

Third review pass. Both reviews independently found that the previous pass
reopened the regression the guard exists to prevent.

CANCELED cannot stand for remediation: initiateStep cancels the previous
COMPLETED step, but it also cancels a merely PENDING one, so an untouched
rejection retry and a genuine remediation end in the same status. Taking
CANCELED as proof let a Sumsub-rejected retry be auto-completed with the
same data - and the test added last pass asserted that wrong outcome,
because its fixture was the unremedied shape.

result separates them durably: complete() writes it, cancel() leaves it
alone, and a step cancelled while still pending never had one. A result-less
cancellation is skipped so the failed step behind it still decides. The
fixture now carries a result and gains its negative twin.

Pick the pending step by highest sequence rather than by first match:
legacy merged-in accounts carry a second IN_PROGRESS step at a negative
sequence, and find() would close that dead step while leaving the live one
open - reporting success on an account that stayed wedged.
Fourth review pass.

Merged-in rows were still in scope for both the verdict and the pick. A
merge seeds the slave's steps 100 below the floor and orders them
chronologically only within a batch, so a later merge sits below an earlier
one: ranking across batches could let a stale cancellation outrank a newer
rejection, and an account whose only pending step was merged-in would have
had that dead row closed while its live chain stayed untouched. Own rows
always start at 0 and merged-in rows are always negative, so the split is
exact.

Move the settled-verdict predicate onto KycStep as hasSettledVerdict, where
the rest of the status getters live, and drop the hardcoded line references
from the docstring - this PR's own insertions had already shifted them onto
unrelated code.
Fourth review pass, second finding.

restartStep revokes an outcome without erasing it: it calls fail(undefined,
...) and setResult(undefined) keeps the existing value, so a
completed-then-restarted step still carries a stale result. Cancelling that
row afterwards - a plausible support cleanup - made it read as a clean
completion, and the retry was auto-completed with the rejected data.

Excluding such a row from the settled set does not fix it: with no settled
row left nothing blocks either. The verdict itself has to carry the signal,
so KycStep.isRejected reads the durable RESTARTED_STEP marker alongside the
status and stays authoritative after the status has moved past FAILED.
@Danswar

Danswar commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Four review passes were needed to reach zero findings.

Pass 1 — the guard matched a PersonalData step that restartStep had deliberately re-opened after a Sumsub PROBLEMATIC_APPLICANT_DATA rejection, and would have re-completed it with the same rejected data, skipping the correction step. Also: the RealUnit spec provided KycService as a bare object, so the new call was an undefined-function TypeError swallowed by the best-effort catch — the call sites were unpinned and an existing assertion had stopped discriminating.

Pass 2 — the "self-heal on retry" claim was wrong and was removed from the log message, the comments and the PR body: an already-wedged account answers AlreadyRegistered and never re-posts register/complete, so this prevents the wedge rather than curing it.

Pass 3CANCELED was being read as proof a rejection had been remedied. It is not: initiateStep cancels a merely pending step as readily as a completed one. Remediation is now read from result, which cancel() preserves and a never-completed step never has.

Pass 4 — merged-in rows (negative sequence) were still voting on the verdict and eligible for closing; the chain is now scoped to the account's own steps. And a completion revoked by restartStep keeps its stale result, so the durable RESTARTED_STEP marker — not the status — is what keeps it blocking.

Every guard is mutation-checked in both directions: too broad and too narrow each turn the suite red.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant