Skip to main content

D113 — RequiresApprovedOnboarding bus gate

Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. The mechanics live in app-modules/foundation/src/Bus/Contracts/{RequiresApprovedOnboarding,OnboardingStatusGuard}.php, app-modules/foundation/src/Bus/{NullOnboardingStatusGuard,LaravelCommandBus}.php, app-modules/foundation/src/Providers/FoundationServiceProvider.php, app-modules/onboarding/src/Support/{BusOnboardingStatusGuard,OnboardingApprovalStatus}.php, app-modules/onboarding/src/Providers/OnboardingServiceProvider.php, app-modules/onboarding/config/onboarding.php, and tests/Architecture/BusMarkerPairingTest.php.

Context

Before this change, an outgoing client money/privilege command was refused only while the tenant's account was frozen/closed by compliance (RequiresAccountActive, D48). Nothing at the bus stopped a tenant that had never completed onboarding — no approved KYB/KYC application at all — from initiating a payout, creating an order, registering a payment destination, provisioning a custody wallet, or opening a custody-controls approval request. The freeze gate only engages once compliance has acted on an existing tenant; it does not gate a tenant that was simply never approved to move money in the first place.

Decision

Every command that already carries RequiresAccountActive also carries a new Foundation marker, Bus\Contracts\RequiresApprovedOnboarding — the exact same 18-command set across accounts, payments, funding, and custody-controls. tests/Architecture/BusMarkerPairingTest.php discovers every command class on disk and fails the suite if one implements RequiresAccountActive without also implementing RequiresApprovedOnboarding, so the pairing cannot silently drift as new money-movement commands are added.

  • Seam shape mirrors the freeze gate exactly (binding inversion). Foundation owns the marker + Bus\Contracts\OnboardingStatusGuard (assertOnboardingApproved(int $tenantId): void) and binds a no-op default, NullOnboardingStatusGuard, via bindIf() (not bind(), so onboarding's hard binding always wins regardless of module-provider registration order — the AccountFreezeGuard precedent). LaravelCommandBus::dispatch() resolves the gate right after the freeze gate and before the feature gate. The onboarding module's OnboardingServiceProvider::register() overrides the binding with Support\BusOnboardingStatusGuard. Foundation never depends on the onboarding module — this preserves onboarding's pre-existing "edges point OUT" invariant (nothing imports onboarding): the money-movement modules depend only on the Foundation contract.
  • The predicate is a live read, not a cached flag. Support\OnboardingApprovalStatus::isApproved() checks the tenant holds an Application with status = Approved whose linked Customer has verification_status = Approved — the "review-green" half of ApplicationApprovalEvaluator::subjectReviewApproved(). It is deliberately not cached on the application row: ApplicationApprovalEvaluator is happy-path-only and never reverts an application's status once approved, so a customer whose verification later regresses (an RTBF/expiry re-review, an operator downgrade) would otherwise still read as approved. A fresh read on every dispatch catches that regression; the cost is one indexed tenant-scoped query per gated command.
  • onboarding.enforce_gate (default true) is the kill switch, not a rollout flag. No environment overrides it. Decided 2026-08-19: enforce ON by default, because there are no live tenants yet — a default-off rollout with a later flip would have meant retrofitting enforcement onto tenants already mid-flow. The config exists purely as an emergency escape hatch (flip in a hotfix, no deploy) if enforcement must be disabled.
  • Fail-open, twice. The gate no-ops when tenant('id') <= 0 (system/console/queue context — no tenant session to scope the mirror read; mirrors how these off-request contexts have no account to freeze either) and when only Foundation's NullOnboardingStatusGuard default is bound (no onboarding module wired). Both carve-outs match the AccountFreezeGuard posture exactly.
  • Deliberately NO route-middleware counterpart. The freeze gate has both a bus gate AND EnsureAccountNotFrozen/EnsureAccountNotClosed middleware backstops. The onboarding gate has only the bus gate. A pre-KYB tenant must be able to use the full non-financial API (browse account status, read requirements, submit the KYB application itself, manage non-money settings) — gating at the middleware layer would 403 the onboarding endpoints a not-yet-approved tenant needs to reach approval. Only the specific money/privilege commands refuse.
  • Cancellations stay unmarked, deliberately. CancelOrderCommand/CancelPayoutCommand are not marked with either RequiresAccountActive or RequiresApprovedOnboarding — cancelling a local intent never moves money out, so a tenant (frozen, or never onboarded) must still be able to withdraw their own pending request.
  • The reclaim sweeps re-assert onboarding approval OUT of the bus, mirroring their existing freeze re-check. The payments:reconcile-payouts and custody-controls:reconcile-custody-transactions sweeps can each re-drive a stranded request that is a genuine FIRST disbursement, so both now resolve OnboardingStatusGuard directly and defer (never re-drive, never throw) a re-drive for a tenant whose onboarding is no longer approved — the same defer-not-abort shape their pre-existing AccountFreezeGuard check already had.

Consequences

  • A new money-movement command must add RequiresApprovedOnboarding alongside RequiresAccountActive or the architecture test fails the build — the pairing is enforced mechanically, not by convention.
  • Test suites exercising a gated command must seed an approved application/customer pair; the shared root fixture Tests\Support\SeedsApprovedOnboarding::seedApprovedOnboarding() does this, mirroring OnboardingApprovalStatus::isApproved() exactly so the fixture cannot drift from the predicate it stands in for.
  • Enums\ErrorCode::OnboardingIncomplete (ONBOARDING_INCOMPLETE, 403) is a new public error code; it is a routine rejection (not a DurableRejection) — a tenant that has simply not finished onboarding yet is client noise, not a security/compliance-significant attempt, consistent with how ACCOUNT_FROZEN is also excluded from ErrorCode::isDurableRejection().
  • The gate is enforced on by default with no live tenants to migrate — if that ever changes (a tenant onboarded under the old, ungated behaviour), the kill switch is the escape hatch, not a per-tenant exemption (none exists).

← Decision log index