D117 — A Stables-owned activity declaration gates the KYB submit
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/Enums/{BusinessActivity,ActivitySupport,DeclarationVerdict, DeclarationVerdictSource}.php,app-modules/onboarding/config/activity-policy.php,app-modules/onboarding/src/Support/{ActivityPolicyMatrix,ActivityDeclarationEvaluator, DeclarationFingerprinter}.php,app-modules/onboarding/src/Models/{ActivityDeclaration, DeclarationFingerprint}.php,app-modules/onboarding/src/Handlers/Commands/ SubmitActivityDeclarationHandler.php, the four gate checks inSubmitOnboardingApplicationHandler::handle(), andapp-modules/onboarding/src/Http/Controllers/Api/ActivityDeclarationController.php.
Context
Bridge's high-risk-activity disclosure obligation (https://apidocs.bridge.xyz/platform/customers/compliance/businesses/highrisk, fetched 2026-09-02) requires a business customer to disclose certain activities and forbids others outright under Bridge's Terms of Service — a KYB submit that skips this has no way to declare (or be refused for) either. Separately, Conduit is no longer a target vendor (decided 2026-09-02) — this feature does not add a Conduit mapping — and product wants to start capturing demand for activities/industries no current provider serves, so the business case for a market Stables cannot yet serve is visible rather than silently lost at the door.
There was no place in the onboarding flow to capture what a business or individual actually does before the KYB/KYC submit itself, and no policy engine to evaluate that against what the finance rails we use can support.
Decision
A new Stables-owned "activity declaration" step is inserted into the onboarding module, between
registration and the KYB submit. SubmitOnboardingApplicationCommand (the KYB submit) now
refuses with a 409 unless the tenant holds a declaration whose verdict unlocks KYB (see
SubmitOnboardingApplicationHandler::handle(), checked before any document capture / vault write
/ provider call).
- The vocabulary is Stables-owned, not Bridge's.
BusinessActivityis a deliberate SUPERSET of Bridge'shigh_risk_activitiesenum: the first 15 cases are Bridge's values verbatim (pinned byBridgeEnumPinningTestagainstpackages/stables/bridge-sdk/spec/openapi.json→UpdateBusinessCustomerPayload.properties.high_risk_activities.items.enum), and the remaining cases are additions drawn from Bridge's ToS prohibited list on the high-risk page (plus a neutralother). Owning the vocabulary lets the declaration capture — and refuse — activities Bridge prohibits outright, and keeps the client contract provider-neutral (a client never sees a Bridge-shaped enum).LegalStructure,EmploymentStatus, andIndividualAccountPurposeare pinned 1:1 to the corresponding Bridge payload enums (business_type,employment_status,account_purpose) so a future Bridge payload mapping (deferred, see below) needs no vocabulary translation. The onboarding subject type reuses the existingModules\Rails\Enums\ CustomerType(individual|business, D110) — no new subject enum. - A per-provider support matrix, config-listed.
app-modules/onboarding/config/ activity-policy.phpscores everyBusinessActivityagainst every provider inevaluation_providers(today['bridge', 'sovera']) assupported | disclose | prohibited | unknown(ActivitySupport). This is a stopgap: providers are a flat config list, not read from the routing substrate'sjurisdiction_provider_grants(D94) — Bridge is not yet represented there at all (noRailsProvideradapter exists for it).ActivityPolicyMatrixvalidates at construction that every listed provider has a row for every activity, so a newBusinessActivitycase that nobody scored fails loudly instead of silently reading Unknown. - The Bridge high-risk page is authoritative (confirmed by Stables' Bridge relationship
manager, 2026-09-02): activities the page names as high-risk are modelled
disclose; activities on its ToS prohibited list are modelledprohibited. Overlap 1 — the check-cashing interpretation: the page lists "check cashing" under both its Money Services (high-risk) heading and its prohibited list. We modelcheck_cashingas its OWNprohibitedBusinessActivitycase, distinct frommoney_services(gift cards, ATMs, remittances), which staysdisclose— so a business that only does check cashing is refused, while one doing other money-services activity is disclosed, not silently conflated with the check-cashing refusal. Sovera's business end-user onboarding is undocumented (packages/stables/sovera-sdk/README.md), so every activity isunknownfor Sovera — a Sovera-routed declaration always lands on manual review, never an automatic allow or deny. Overlap 2 — the "via Bridge" carve-outs: the high-risk page's ToS list also names "Investment or credit services", "Digital asset exchange services", and "Money services", each qualified "provided by Users to third parties via Bridge", while the SAME page names Money Services and FX/VC brokerage/OTC as high-risk (disclose). Director ruling (user + Bridge RM, the page is authoritative): the ACTIVITY-LEVEL status formoney_servicesandoperate_foreign_exchange_virtual_currencies_brokerage_otcSTAYSdisclose(explicitly named high-risk), andinvestment_servicesSTAYSdisclosePROVISIONALLY. The "via Bridge" condition is NOT an activity-level status — it is a KYB follow-up captured by Bridge's ownconducts_money_services_using_bridge/ flow-of-funds questions (theflow_of_fundsandconducts_money_services_using_bridgefields onUpdateBusinessCustomerPayloadinpackages/stables/bridge-sdk/spec/openapi.json), so it belongs to the deferred Bridge-payload mapping, not the declaration matrix.investment_servicesremains an OPEN vendor question to confirm with Bridge's relationship manager (see Deferred). No matrix value changes for either overlap. - A pure evaluator, config-driven, deterministic.
ActivityDeclarationEvaluatortakes the worstActivitySupportper provider across the declared activities (otheralways scores Unknown), derives a baseDeclarationVerdictfrom the most permissive provider outcome (any Supported → Allowed; else any Disclose → AllowedWithDisclosure; else any Unknown → NeedsReview; else Unsupported), then applies three downgrade-only rules — all three can only push toward NeedsReview, never lift Unsupported: a NAICS-code hint that contradicts the declared activity set; a cross-tenant fingerprint match to a priorunsupporteddeclaration; and "denied once ⇒ never auto-allowed" — if the tenant's declaration lineage contains ANY version, from ANY source, ever verdictedunsupportedorneeds_review($lineageHasDenial, computed bySubmitActivityDeclarationHandlerasActivityDeclaration:: query()->whereIn('verdict', [...])->exists()over the tenant's full RLS-scoped history — not just the immediately-previous version), a subsequent POLICY-sourced evaluation whose base verdict would unlock KYB is downgraded toneeds_review. This closes a gap where an interveningneeds_reviewversion (itself a downgrade, not the tenant's original denial) would reset the "previous verdict" check and let a later clean redeclaration auto-unlock KYB with no operator ever reviewing anything. Once a lineage is held, only an operator review (Support\ActivityDeclarationReviewWriter,verdict_source = operator) can produce an unlocking verdict — and it clears only that specific declaration: a further client resubmit after an operatorallowstill finds the lineage denial and lands back inneeds_review. The daily re-evaluation sweep (ReevaluateActivityDeclarationsCommand) is the one caller that passeslineageHasDenial: falsedeliberately: it is the platform re-scoring an existing held declaration under a NEW policy/matrix version, not the tenant changing its answers, so the lineage rule must not fire (it would otherwise self-referentially re-downgrade the very row being re-evaluated). - Declarations are append-only versions, per tenant (RLS). Every submit, operator review
(wave 2), or policy re-evaluation (wave 2) APPENDS a new version to
onboarding_activity_declarationsrather than mutating one; the highestversionfor the tenant is the effective declaration. This keeps a full audit trail of what was declared, under what policy, with what outcome, at every point in time. - A central, cross-tenant fingerprint ledger prevents gaming.
onboarding_declaration_ fingerprintsis modelled exactly likeprovider_customers(D53/D54): a plain biginttenant_idwith no FK, soTableRLSManagernever RLS-policies it — a per-tenant policy would make a cross-tenant match impossible.DeclarationFingerprintercomputes HMAC-SHA256 digests over normalisedregistration_number,legal_name, andemail_domainvalues (never the plaintext) and a row is written for EVERY declaration version, carrying that version's verdict. The evaluator queries this ledger for a matching digest belonging to a DIFFERENT tenant whose verdict wasunsupported, closing two gaming paths in one mechanism: "denied → re-declare differently in the same tenant" (caught by the tenant's own declaration-LINEAGE downgrade — see item 4 — which holds across any number of intervening versions, not just the immediately-previous one) and "denied → re-register as a new tenant" (caught by the cross-tenant fingerprint match). The identity fields are TRANSLITERATED to ASCII before normalisation (Str::ascii), so accented spellings ("Müller GmbH" vs "Muller GmbH") collide deliberately rather than being byte-dropped. Residual risk: the ledger is a best-effort exact-match signal (registration_number/legal_name/email_domainafter normalisation), NOT exhaustive — changing all three defeats it; it raises the cost of gaming, it does not make it impossible. APP_KEY caveat: withONBOARDING_FINGERPRINT_KEYunset the HMAC key derives fromapp.key, so rotatingAPP_KEYinvalidates every prior digest — set the dedicated key before any planned rotation. - The KYB gate is four distinct 409s, checked in this order in
SubmitOnboardingApplicationHandler::handle(): no declaration on file (ACTIVITY_DECLARATION_REQUIRED), latest verdictneeds_review(ACTIVITY_DECLARATION_UNDER_REVIEW), latest verdictunsupported(ACTIVITY_DECLARATION_UNSUPPORTED), and the declaration's subject type not matching the KYB command's subject (ACTIVITY_DECLARATION_SUBJECT_MISMATCH) — the last catches a business declaring activities and then submitting an individual KYB (or vice versa) against a declaration that was never evaluated for that subject. - The catalog never reveals support status.
GetActivityDeclarationCatalogQueryreturns only{value, label, neutral}per activity plus the vocabulary lists and the bridge-sdk NAICS/ occupation reference lists — never which provider allows, discloses, or prohibits which activity. Support status is a server-only evaluation input. - Notify-me + re-evaluation (wave 2). A tenant whose declaration is
unsupportedorneeds_reviewcan opt in (UpdateActivityDeclarationNotificationPreferenceCommand) to be told if a future policy change (a matrix update, a new provider) flips their declaration to an allowed verdict. The re-evaluation sweep and its notification are wave 2 (see “The operator surface (wave 2)” below).
The operator surface (wave 2)
- A compliance
CaseType::ActivityDeclarationReviewcase, opened by a queued listener (OpenCaseForActivityDeclarationReview) onModules\Onboarding\Events\ ActivityDeclarationNeedsReview, through theCaseIntake::openOrAdvance()seam keyed on(tenant_id, ActivityDeclarationReview, declaration uuid)— the same shape as the existingOpenCaseForOnboardingReviewprecedent. This lane is wired. ReviewActivityDeclarationCommand(backoffice,webguard, step-up, rationale required): an operator decision ofalloworunsupported, appending a NEW declaration version (verdict_source = operator) rather than mutating the reviewed one, written undertenancy()->run($tenant, …)per the D105 invariant (operator writes to a tenant-RLS row enter tenant context and dispatch the domain's own tenant-scoped writer —Support\ ActivityDeclarationReviewWriter).allowresolves toallowed_with_disclosurewhen the stored evaluation shows any provider atdisclose, elseallowed; only a declaration whose latest version isneeds_review/unsupportedis reviewable, otherwise the command throwsACTIVITY_DECLARATION_REVIEW_STATE_INVALID(409). Both operator routes reuseOnboardingOperatorPermission::ApplicationsReview— no new permission was minted. This command does not advance or close the compliance case (the D105 confirm/reopen precedent): case disposition stays a separate, manual compliance operator action via the generic case endpoints.GetActivityDeclarationForOperatorQuery(cross-tenant, BYPASSRLS, declaresReadsClassifiedData) andGetUnservedDemandQuery— an aggregate of activities/NAICS/ jurisdiction demand among tenants currentlyunsupportedorneeds_review, with no PII in its output.- Console
onboarding:reevaluate-activity-declarations: a plain per-tenant sweep (not a task-runner fleet sweep) that re-runs the evaluator for every tenant whose latest declaration predates the currentpolicy_versionand is not operator-decided; a verdict flip to an unlocking verdict for awants_notificationtenant firesActivityDeclarationSupportedNotification. Scheduled daily (->daily()->onOneServer()->withoutOverlapping()) inOnboardingServiceProvider::boot(). - Backoffice routes:
GET /api/v1/backoffice/onboarding/activity-declarations/{uuid},POST .../{uuid}/review(step-up),GET /api/v1/backoffice/onboarding/unserved-demand.
Rejected alternatives
- Gate at registration time, before any workspace exists. Rejected: registration must stay a fast, low-friction self-serve step (see tenant-lifecycle.md — a tenant exists, with a working login, before any verification). Forcing an activity declaration before the workspace even exists would import a compliance decision into the one step product deliberately keeps frictionless.
- Fold the declaration into the existing KYB form fields only, with no separate step or evaluator. Rejected: the KYB form is requirements-driven per provider/country (see the onboarding README's requirements flow) — it has no concept of a policy verdict, no append-only version history, and no cross-tenant fingerprint check. Bolting an evaluation step onto a provider-shaped form would either duplicate the vocabulary per provider or leak provider-shaped fields into the client contract; a dedicated Stables-owned step keeps the KYB form provider- neutral and the declaration reusable across whichever provider ultimately serves the tenant.
Deferred
- Mapping the declaration into Bridge's customer payload. No
RailsProvideradapter exists for Bridge yet (seebridge-development's "app-integration half"); the pinned enums (LegalStructure,EmploymentStatus,IndividualAccountPurpose, and the Bridge-verbatimBusinessActivitycases) exist so this mapping is a straight value-for-value translation when that adapter lands, not a redesign. - Switching
evaluation_providersfrom a flat config list tojurisdiction_provider_grants(D94's routing substrate) once Bridge is represented there. Today's config-listed set is a stopgap specifically because no such grant exists for Bridge. - Sovera policy rows beyond blanket
unknown. Sovera's business end-user onboarding is undocumented; every activity routes to manual review until that changes. - The
investment_services"via Bridge" status (Overlap 2, above). It is modelleddisclosePROVISIONALLY; whether Bridge treats third-party investment/credit services provided "via Bridge" as disclosable or prohibited is an OPEN question to confirm with Bridge's relationship manager. The "via Bridge" condition itself is a KYB follow-up (Bridge'sconducts_money_services_using_bridge/flow_of_fundspayload fields), captured when the deferred Bridge-payload mapping lands. - The
device_signalfingerprint kind. The module dependency graph does not currently permit onboarding to import the fraud module's\Models; adding that edge was out of scope for this change, so onlyregistration_number,legal_name, andemail_domainfingerprints are computed today. See the onboarding README's fingerprints section.
FE contract summary
GET /api/v1/onboarding/activity-declaration/catalog— the form-building catalog (no support status).GET /api/v1/onboarding/activity-declaration— the tenant's latest declaration version, or a 404ACTIVITY_DECLARATION_NOT_FOUND. The response is provider-neutral: it carriesverdict,verdict_source,policy_version, and the declared fields, but NOT the rawevaluationsnapshot orverdict_reasons(which name providers / prior-denial matches / NAICS hints — operator-only).POST /api/v1/onboarding/activity-declaration— submit a new version; 201 with the resulting declaration (verdict included). A lost(tenant_id, version)race is a retryable 409ACTIVITY_DECLARATION_CONFLICT.PATCH /api/v1/onboarding/activity-declaration/notification— toggle the notify-me opt-in.- The KYB submit (
POST /api/v1/onboarding/applications) now additionally 409s with one ofACTIVITY_DECLARATION_REQUIRED/ACTIVITY_DECLARATION_UNDER_REVIEW/ACTIVITY_DECLARATION_UNSUPPORTED/ACTIVITY_DECLARATION_SUBJECT_MISMATCH.
Full request/response shapes: onboarding module README.
Consequences
- Every KYB test now needs a seeded allowed activity declaration; the shared root fixture
Tests\Support\SeedsAllowedActivityDeclaration::seedAllowedActivityDeclaration()does this (theSeedsApprovedOnboardingprecedent). Enums\ErrorCodegains the public activity-declaration codes (ACTIVITY_DECLARATION_NOT_FOUND,ACTIVITY_DECLARATION_REQUIRED,ACTIVITY_DECLARATION_UNDER_REVIEW,ACTIVITY_DECLARATION_UNSUPPORTED,ACTIVITY_DECLARATION_SUBJECT_MISMATCH, the wave-2ACTIVITY_DECLARATION_REVIEW_STATE_INVALID, andACTIVITY_DECLARATION_CONFLICTfor the version-race on submit).- The bridge-sdk NAICS + occupation reference-list snapshots (a parallel wave of this same PR;
see the bridge-sdk README's "Reference lists" section) become a load-bearing input to the
client catalog and to the NAICS-hint downgrade rule — a snapshot refresh
(
make bridge-lists-diff) can change catalog contents but never changes an already-persisted declaration's verdict retroactively. - The Bridge overlap question that was previously open in
provider-landscape.md("is agambling-flagged business eligible to onboard at all?") is resolved by this ruling:gamblingis modelledprohibited, so the declaration step refuses it outright rather than forwarding it to Bridge — see that page's updated note.