Skip to main content

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 in SubmitOnboardingApplicationHandler::handle(), and app-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).

  1. The vocabulary is Stables-owned, not Bridge's. BusinessActivity is a deliberate SUPERSET of Bridge's high_risk_activities enum: the first 15 cases are Bridge's values verbatim (pinned by BridgeEnumPinningTest against packages/stables/bridge-sdk/spec/openapi.jsonUpdateBusinessCustomerPayload.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 neutral other). 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, and IndividualAccountPurpose are 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 existing Modules\Rails\Enums\ CustomerType (individual|business, D110) — no new subject enum.
  2. A per-provider support matrix, config-listed. app-modules/onboarding/config/ activity-policy.php scores every BusinessActivity against every provider in evaluation_providers (today ['bridge', 'sovera']) as supported | disclose | prohibited | unknown (ActivitySupport). This is a stopgap: providers are a flat config list, not read from the routing substrate's jurisdiction_provider_grants (D94) — Bridge is not yet represented there at all (no RailsProvider adapter exists for it). ActivityPolicyMatrix validates at construction that every listed provider has a row for every activity, so a new BusinessActivity case that nobody scored fails loudly instead of silently reading Unknown.
  3. 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 modelled prohibited. Overlap 1 — the check-cashing interpretation: the page lists "check cashing" under both its Money Services (high-risk) heading and its prohibited list. We model check_cashing as its OWN prohibited BusinessActivity case, distinct from money_services (gift cards, ATMs, remittances), which stays disclose — 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 is unknown for 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 for money_services and operate_foreign_exchange_virtual_currencies_brokerage_otc STAYS disclose (explicitly named high-risk), and investment_services STAYS disclose PROVISIONALLY. The "via Bridge" condition is NOT an activity-level status — it is a KYB follow-up captured by Bridge's own conducts_money_services_using_bridge / flow-of-funds questions (the flow_of_funds and conducts_money_services_using_bridge fields on UpdateBusinessCustomerPayload in packages/stables/bridge-sdk/spec/openapi.json), so it belongs to the deferred Bridge-payload mapping, not the declaration matrix. investment_services remains an OPEN vendor question to confirm with Bridge's relationship manager (see Deferred). No matrix value changes for either overlap.
  4. A pure evaluator, config-driven, deterministic. ActivityDeclarationEvaluator takes the worst ActivitySupport per provider across the declared activities (other always scores Unknown), derives a base DeclarationVerdict from 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 prior unsupported declaration; and "denied once ⇒ never auto-allowed" — if the tenant's declaration lineage contains ANY version, from ANY source, ever verdicted unsupported or needs_review ($lineageHasDenial, computed by SubmitActivityDeclarationHandler as ActivityDeclaration:: 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 to needs_review. This closes a gap where an intervening needs_review version (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 operator allow still finds the lineage denial and lands back in needs_review. The daily re-evaluation sweep (ReevaluateActivityDeclarationsCommand) is the one caller that passes lineageHasDenial: false deliberately: 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).
  5. 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_declarations rather than mutating one; the highest version for 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.
  6. A central, cross-tenant fingerprint ledger prevents gaming. onboarding_declaration_ fingerprints is modelled exactly like provider_customers (D53/D54): a plain bigint tenant_id with no FK, so TableRLSManager never RLS-policies it — a per-tenant policy would make a cross-tenant match impossible. DeclarationFingerprinter computes HMAC-SHA256 digests over normalised registration_number, legal_name, and email_domain values (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 was unsupported, 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_domain after normalisation), NOT exhaustive — changing all three defeats it; it raises the cost of gaming, it does not make it impossible. APP_KEY caveat: with ONBOARDING_FINGERPRINT_KEY unset the HMAC key derives from app.key, so rotating APP_KEY invalidates every prior digest — set the dedicated key before any planned rotation.
  7. The KYB gate is four distinct 409s, checked in this order in SubmitOnboardingApplicationHandler::handle(): no declaration on file (ACTIVITY_DECLARATION_REQUIRED), latest verdict needs_review (ACTIVITY_DECLARATION_UNDER_REVIEW), latest verdict unsupported (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.
  8. The catalog never reveals support status. GetActivityDeclarationCatalogQuery returns 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.
  9. Notify-me + re-evaluation (wave 2). A tenant whose declaration is unsupported or needs_review can 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::ActivityDeclarationReview case, opened by a queued listener (OpenCaseForActivityDeclarationReview) on Modules\Onboarding\Events\ ActivityDeclarationNeedsReview, through the CaseIntake::openOrAdvance() seam keyed on (tenant_id, ActivityDeclarationReview, declaration uuid) — the same shape as the existing OpenCaseForOnboardingReview precedent. This lane is wired.
  • ReviewActivityDeclarationCommand (backoffice, web guard, step-up, rationale required): an operator decision of allow or unsupported, appending a NEW declaration version (verdict_source = operator) rather than mutating the reviewed one, written under tenancy()->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). allow resolves to allowed_with_disclosure when the stored evaluation shows any provider at disclose, else allowed; only a declaration whose latest version is needs_review/unsupported is reviewable, otherwise the command throws ACTIVITY_DECLARATION_REVIEW_STATE_INVALID (409). Both operator routes reuse OnboardingOperatorPermission::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, declares ReadsClassifiedData) and GetUnservedDemandQuery — an aggregate of activities/NAICS/ jurisdiction demand among tenants currently unsupported or needs_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 current policy_version and is not operator-decided; a verdict flip to an unlocking verdict for a wants_notification tenant fires ActivityDeclarationSupportedNotification. Scheduled daily (->daily()->onOneServer()->withoutOverlapping()) in OnboardingServiceProvider::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 RailsProvider adapter exists for Bridge yet (see bridge-development's "app-integration half"); the pinned enums (LegalStructure, EmploymentStatus, IndividualAccountPurpose, and the Bridge-verbatim BusinessActivity cases) exist so this mapping is a straight value-for-value translation when that adapter lands, not a redesign.
  • Switching evaluation_providers from a flat config list to jurisdiction_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 modelled disclose PROVISIONALLY; 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's conducts_money_services_using_bridge / flow_of_funds payload fields), captured when the deferred Bridge-payload mapping lands.
  • The device_signal fingerprint 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 only registration_number, legal_name, and email_domain fingerprints 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 404 ACTIVITY_DECLARATION_NOT_FOUND. The response is provider-neutral: it carries verdict, verdict_source, policy_version, and the declared fields, but NOT the raw evaluation snapshot or verdict_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 409 ACTIVITY_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 of ACTIVITY_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 (the SeedsApprovedOnboarding precedent).
  • Enums\ErrorCode gains 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-2 ACTIVITY_DECLARATION_REVIEW_STATE_INVALID, and ACTIVITY_DECLARATION_CONFLICT for 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 a gambling-flagged business eligible to onboard at all?") is resolved by this ruling: gambling is modelled prohibited, so the declaration step refuses it outright rather than forwarding it to Bridge — see that page's updated note.

← Decision log index