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, andtests/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, viabindIf()(notbind(), so onboarding's hard binding always wins regardless of module-provider registration order — theAccountFreezeGuardprecedent).LaravelCommandBus::dispatch()resolves the gate right after the freeze gate and before the feature gate. The onboarding module'sOnboardingServiceProvider::register()overrides the binding withSupport\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 anApplicationwithstatus = Approvedwhose linkedCustomerhasverification_status = Approved— the "review-green" half ofApplicationApprovalEvaluator::subjectReviewApproved(). It is deliberately not cached on the application row:ApplicationApprovalEvaluatoris happy-path-only and never reverts an application'sstatusonce 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(defaulttrue) 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'sNullOnboardingStatusGuarddefault is bound (no onboarding module wired). Both carve-outs match theAccountFreezeGuardposture exactly. - Deliberately NO route-middleware counterpart. The freeze gate has both a bus gate AND
EnsureAccountNotFrozen/EnsureAccountNotClosedmiddleware 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/CancelPayoutCommandare not marked with eitherRequiresAccountActiveorRequiresApprovedOnboarding— 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-payoutsandcustody-controls:reconcile-custody-transactionssweeps can each re-drive a stranded request that is a genuine FIRST disbursement, so both now resolveOnboardingStatusGuarddirectly 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-existingAccountFreezeGuardcheck already had.
Consequences
- A new money-movement command must add
RequiresApprovedOnboardingalongsideRequiresAccountActiveor 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, mirroringOnboardingApprovalStatus::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 aDurableRejection) — a tenant that has simply not finished onboarding yet is client noise, not a security/compliance-significant attempt, consistent with howACCOUNT_FROZENis also excluded fromErrorCode::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).