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}, andapp-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/requirementsandPOST /onboarding/applicationsgain a REQUIREDtypeparam (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+SubmitOnboardingApplicationCommandcarry a requiredModules\Rails\Enums\CustomerType $type.InitiateVerificationCommand::$typebecomes?VerificationType(null= derive from the stored subject; an explicit value still overrides). - Neutral payload + mirror stamping. The submit handler threads
typeto rails and stampscustomers.typefrom the declared subject at submit time (via thecustomerTypemirror 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()mapsindividual→Individual/business→Companyand FAILS LOUD on absent/unknown (no default); a newSumsub\Support\SumsubLevelResolver(configsumsub.levels.{individual, company}, fail-loud when unconfigured — theUtilaAssetMapprecedent) resolves the per-subjectlevelNamefor BOTHcreateEntity($applicant, $level)and the WebSDKAccessTokenCreationDto(levelName: …)(closing gaps A + B). - KYC approval path.
ApplicationApprovalEvaluatorbranches 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 NOBeneficialOwnerVerificationCompleted(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
typeto 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
SumsubLevelResolverrequiresSUMSUB_LEVEL_INDIVIDUAL/SUMSUB_LEVEL_COMPANYto be configured for a Sumsub-routed submit/initiate; unconfigured → fail-loud 503PROVIDER_UNAVAILABLE(review fix — an unconfigured dashboard level is an OPS problem, not a client input error, soProviderUnavailableExceptionreplaces the originalProviderValidationException; the values are per-account dashboard config). The test env pins the.env.exampledefaults inphpunit.xmlANDtests/bootstrap.php(the same docker-compose.env-shadowing rationale asSUMSUB_BASE_URL).- Gaps A + B + C in
docs/tracking/onboarding-ux-takeover.mdare CLOSED; D (fields → applicant) + E (applicantIdin the create response) were then also CLOSED (2026-08-10) against the committed spec + Sumsub public docs (ApplicantPayloadMapperfor thefields→fixedInfomap; raw-responseapplicantIdcapture with anexternalUserIdfallback) — the live sandbox spike is now verification, not discovery. - No schema change —
customers.type/applications.typealready 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 fromGET /onboarding/requirements.