Skip to main content

D110 — Onboarding subject-type contract (client-declared individual|business)

Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. The mechanics live in app-modules/onboarding/src/Http/Controllers/Api/OnboardingController.php, app-modules/onboarding/src/Commands/{SubmitOnboardingApplicationCommand,InitiateVerificationCommand}.php, app-modules/onboarding/src/Support/ApplicationApprovalEvaluator.php, app-modules/rails/src/Contracts/OnboardsCustomers.php, app-modules/rails/src/Sumsub/{SumsubRailsProvider.php,Support/SumsubLevelResolver.php}, and app-modules/rails/src/Conduit/{ConduitRailsProvider.php,Mappers/VerificationMapper.php}.

Context

Both individuals and businesses onboard as tenants. The two are PEER routes — KYB (business) and KYC (individual) — but the pre-existing code had no way for the client to say which: GET /onboarding/requirements and POST /onboarding/applications carried no subject discriminator, and the Sumsub adapter's applicantType() silently defaulted every applicant to individual. So a business (KYB) submit was impossible — every applicant was created individual, and fetchBusinessVerification() then failed loud — and, symmetrically, an individual applicant never had a working end-to-end approval path (the local approval gate assumed a business ownership graph, so a natural person with zero UBO edges could never complete). Two adjacent gaps rode along: the Sumsub verification levelName (dashboard-configured, per applicant type) was never sent on either the applicant create or the WebSDK token mint (gaps A + B in docs/tracking/onboarding-ux-takeover.md).

Decision

The client declares the onboarding SUBJECT — individual | business — on BOTH the requirements read and the application submit. There is no silent default anywhere.

  • Client API. GET /onboarding/requirements and POST /onboarding/applications gain a REQUIRED type param (in:individual,business). Verification initiation (POST /customers/{customer}/verifications) takes NO new body field: the verification kind is DERIVED from the customer's stored subject type (deriving from a declared, stored fact is not a silent default).
  • Bus messages. GetOnboardingRequirementsQuery + SubmitOnboardingApplicationCommand carry a required Modules\Rails\Enums\CustomerType $type. InitiateVerificationCommand::$type becomes ?VerificationType (null = derive from the stored subject; an explicit value still overrides).
  • Neutral payload + mirror stamping. The submit handler threads type to rails and stamps customers.type from the declared subject at submit time (via the customerType mirror key), so the individual-vs-business fact is queryable immediately — the load-bearing input to the approval path.
  • Rails seam. OnboardsCustomers::getOnboardingRequirements(string $country, CustomerType $type). Sumsub serves both subjects: applicantType() maps individual→Individual / business→Company and FAILS LOUD on absent/unknown (no default); a new Sumsub\Support\SumsubLevelResolver (config sumsub.levels.{individual, company}, fail-loud when unconfigured — the UtilaAssetMap precedent) resolves the per-subject levelName for BOTH createEntity($applicant, $level) and the WebSDK AccessTokenCreationDto(levelName: …) (closing gaps A + B).
  • KYC approval path. ApplicationApprovalEvaluator branches on the stored subject type: an INDIVIDUAL SKIPS the ownership look-through entirely (an ownership assessment of a natural person is meaningless), gating only on own-KYC-approved + no open EDD/RFI, and emits NO BeneficialOwnerVerificationCompleted (there are no owners) — eventless beyond the mirror write. Business + legacy null-type rows keep exactly the prior path.
  • Level-per-type derivation. IndividualKyc → individual level, BusinessKyb → company level; a legacy null-type customer is KYB-era → BusinessKyb.

Conduit is fail-closed as reference-only. Conduit is NOT a live onboarding integration — it is preserved reference architecture that serves only the business subject. It now fails closed for an individual subject on requirements and submit (ProviderOperationNotSupportedException, 422 PROVIDER_OPERATION_NOT_SUPPORTED — review fix; DISTINCT from the neutral PROVIDER_VALIDATION_FAILED a malformed payload gets, so the FE can render "individual onboarding is not available here yet" rather than a generic input-fix prompt), strips the neutral type from its wire payload for a business (byte-parity with the documented Conduit body; stripped before the idempotency-key derivation so a business submit's key is unchanged), and its verification mapper maps both onboarding kinds onto its own WalletCustodyConversion IDV flow. An ABSENT or unrecognised type also fails loud (ProviderValidationException, 422 — genuinely a malformed payload, mirroring the Sumsub posture) rather than silently proceeding down the business path, so a future direct caller cannot bypass the "no silent default" contract. Its tests are kept green; no further investment is made in its onboarding path.

Explicitly OUT of scope: "KYC members of a business tenant" (KYC subjects nested under a KYB tenant). The D39 identity scheme stamps externalUserId/clientReferenceId as the TENANT's uuid (one dedup handle per tenant), so a member-KYC individual under the same tenant would collide with the tenant's own applicant — member-KYC needs its own per-person identity scheme first (a deferred D39 decision), so it stays out of scope here.

Accepted risk — subject self-declaration (review finding; ACCEPTED by product, 2026-08-10)

The client SELF-DECLARES the onboarding subject (type), and nothing server-side cross-checks it: a business tenant can declare type=individual to skip the ≥25% UBO look-through entirely, since there is no tenant-level "this is a registered business entity" field to compare against, and Sumsub's individual level verifies that a natural person is who they claim to be — not that the tenant isn't a business.

ACCEPTED (product decision, 2026-08-10). Rationale: an individual onboarding runs full KYC on a real natural person, and platform use under an individual subject is that person's PERSONAL liability — the declaration doesn't dodge scrutiny, it selects whose scrutiny applies and who answers for the account. The residual exposure (business activity conducted through a personal account to keep owners undisclosed) is a CONDUCT problem, not an onboarding-gate problem: it is the territory of downstream transaction monitoring and the existing compliance-case machinery, which remain the controls of record for it.

Two candidate mitigations from the review remain available if that posture changes, neither built:

  • (a) Compensating control. Flag an individual applicant whose submitted fields carry business-shaped signals (a company-suggestive legal/display name, a registration-number-shaped field, …) into the operator review path rather than auto-approving through ApplicationApprovalEvaluator's eventless individual path.
  • (b) Bind the declared subject to registration-time data. Tie type to something collected at tenant registration (e.g. a business/individual signup flag) instead of trusting the onboarding-time declaration alone, so the two would have to be gamed together rather than one client-controlled field.

Partial mitigations already in place, stated honestly (neither closes the gap): the provider's own KYC/KYB flow still verifies a real natural person behind an individual applicant (it just doesn't verify the ABSENCE of a business), and the operator rejection-review path exists downstream for a case that does get flagged some other way. Neither substitutes for a server-side cross-check.

Consequences

  • SumsubLevelResolver requires SUMSUB_LEVEL_INDIVIDUAL / SUMSUB_LEVEL_COMPANY to be configured for a Sumsub-routed submit/initiate; unconfigured → fail-loud 503 PROVIDER_UNAVAILABLE (review fix — an unconfigured dashboard level is an OPS problem, not a client input error, so ProviderUnavailableException replaces the original ProviderValidationException; the values are per-account dashboard config). The test env pins the .env.example defaults in phpunit.xml AND tests/bootstrap.php (the same docker-compose .env-shadowing rationale as SUMSUB_BASE_URL).
  • Gaps A + B + C in docs/tracking/onboarding-ux-takeover.md are CLOSED; D (fields → applicant) + E (applicantId in the create response) were then also CLOSED (2026-08-10) against the committed spec + Sumsub public docs (ApplicantPayloadMapper for the fieldsfixedInfo map; raw-response applicantId capture with an externalUserId fallback) — the live sandbox spike is now verification, not discovery.
  • No schema change — customers.type / applications.type already exist.
  • The requirements descriptor is LEVEL-defined (Sumsub), not subject-defined: it does not vary by the declared type, so a caller should not expect per-type field lists from GET /onboarding/requirements.

← Decision log index