D105 — Operator writes to tenant-RLS rows enter tenant context + the KYB unhappy path (ACCEPTED)
Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. Program context + the onboarding build plan live in
docs/tracking/multi-provider/10-onboarding-identity.md§4g.
Two decisions ship together: the KYB unhappy path, and the operator-write invariant it forced.
The operator-write invariant (the load-bearing rule)
An operator (central web/BYPASSRLS guard) that must WRITE a tenant-RLS row does so by resolving the
target tenant by uuid, ENTERING that tenant's RLS context (tenancy()->run($tenant, …)), and dispatching
the domain's EXISTING tenant-scoped writer command inside it — never a direct Eloquent write, and never a
BYPASSRLS write keyed by tenant_id. BYPASSRLS writes keyed by tenant_id are prohibited on ALL write
paths, operator or system: the tenant boundary is the database's job (D7), and a BYPASSRLS write that hand-
supplies tenant_id re-implements isolation in application code where a bug leaks silently across tenants.
Consequences, all realised by the onboarding confirm/reopen commands
(OperatorApplicationReviewWriter): (a) the write reuses the same mirror writer
(UpsertApplicationMirrorCommand → ApplicationMirror) the webhook path uses, so there is ONE write
discipline; (b) it is subject to the tenant NOBYPASSRLS role, so addressing the row with the WRONG
tenant's context simply cannot see it (404), which is the isolation proof; (c) Blamable stamps the central
operator User morph natively (the guard's authenticated actor survives the tenancy switch); (d) the
operator command is WithoutTransaction, so no central transaction spans the tenancy switch (the
webhook-job tenancy()->run precedent); and (e) the mirror envelope is FRESHNESS-LESS, so the base upsert
applies it while PRESERVING the row's stored out-of-order markers — an operator flip can never be "healed
away" by a later stale provider webhook. This generalises beyond onboarding: any future operator surface
that mutates a tenant-scoped row follows the same shape.
The review-lock guard (post-acceptance hardening — the BLOCKER fix)
The original text of this section only covered a STALE (reordered) webhook — the out-of-order guard
(provider_sequence/provider_updated_at) protects against a delivery that is older-or-equal to what is
already stored. It said nothing about a GENUINELY FRESH webhook (a higher sequence, not reordered) landing
on a row an operator has already, explicitly, CONFIRMED a rejection on: that webhook legitimately passes
the out-of-order guard and — before the fix below — AbstractMirrorUpsert::upsert() applied its status
unconditionally, silently reverting the operator's decision. A slow-in-flight application.approved that
was already on the wire when the operator confirmed, or any later out-of-band provider status flip, could
therefore overwrite rejection_confirmed back to approved with no operator involvement and no error.
The fix (Modules\Onboarding\Mirror\GuardsReviewConfirmedRejection, composed onto ApplicationMirror
alongside GuardsAgainstSubmitDowngrade) is a SECOND, independent isStale() precondition: any envelope
whose source is NOT the operator writer's own ('operator_review') is refused outright while the STORED
row's review_status is rejection_confirmed — regardless of freshness. Only the operator's own reopen
(which flips review_status away from rejection_confirmed FIRST) re-enables provider-driven status
movement. The refusal is logged (Log::channel('audit')->warning('onboarding.application.review_locked_write_refused', …), a distinct message key) so a swallowed provider decision stays observable rather than silently
vanishing. This generalises the invariant stated above: an operator decision recorded through THIS
mechanism is authoritative over non-operator writes going forward, not merely over reordered ones.
The KYB unhappy path (the feature that forced it)
The happy-path KYB approval gate (D95/§4c) never rejects or re-opens. This ADR adds the unhappy path, per fixed product decisions:
- A provider RED-FINAL does NOT auto-reject. When
SyncVerificationFromProviderHandlerpulls aVerificationDecision::Declinedfor a non-terminal application, it flags the applicationreview_status = pending_rejection_review(a NEW nullable, DLP-Internal, onboarding-localApplicationReviewStatuscolumn onapplications; the client keeps seeingstatus = pending— it is NOT exposed on the clientApplicationResource) and raises a compliance case. An operator must CONFIRM the rejection before the client-visible status changes. - Operator review is single-operator + step-up + rationale (the account freeze/close precedent, NOT
maker/checker). Confirm flips
status → rejected+review_status → rejection_confirmed; reopen setsreview_status → reopened+status → pending. A newweb-guard permissiononboarding.applications.reviewgates both. - A confirmed rejection blocks re-submission until re-opened.
SubmitOnboardingApplicationHandlerrefuses (before any document capture / provider call) when the tenant holds any application withreview_status = rejection_confirmed— codedAPPLICATION_REJECTED_AWAITING_REOPEN(409). - Applications stuck non-terminal beyond a configurable threshold (
onboarding.stuck_application_days, default 7) escalate to compliance. A daily task-runner fleet sweep (onboarding:escalate-stuck-applications, per-tenant unit jobs, skip-not-fail) raises the same compliance signal per stuck application.
The compliance case-open is DECOUPLED (not a direct import)
Onboarding opens the CaseType::OnboardingReview case NOT by importing compliance, but by EMITTING a
domain event OnboardingReviewSignalRaised (both triggers — RED-FINAL and stuck-sweep — emit it, keyed by
the application external_id, discriminated by a reason); the compliance OpenCaseForOnboardingReview
listener consumes it and calls CaseIntake::openOrAdvance. This is the accounts
CustodyTransactionAmlScreened / custody-controls ApprovalPostureTightened precedent: onboarding imports
NOTHING from compliance, the "nothing depends on compliance" invariant holds, and the edge stays one-way
(compliance → onboarding \Events). openOrAdvance is idempotent on
(tenant_id, type, subject_external_ref), so a redelivery, a re-fired RED-FINAL, or a subsequent sweep run
advances the one open case rather than duplicating it.
Deferred
Sumsub-side reopen is LOCAL-ONLY: there is no provider capability to reset a RED-FINAL applicant, so reopen
clears the local block without re-opening the provider's decision. Tracked in
10-onboarding-identity.md §4g.
This decision builds on D95 (the Sumsub Onboarding grant + the happy-path approval gate) and the D7/D53/D54 tenancy invariants; it does not restate them.