Skip to main content

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 (UpsertApplicationMirrorCommandApplicationMirror) 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:

  1. A provider RED-FINAL does NOT auto-reject. When SyncVerificationFromProviderHandler pulls a VerificationDecision::Declined for a non-terminal application, it flags the application review_status = pending_rejection_review (a NEW nullable, DLP-Internal, onboarding-local ApplicationReviewStatus column on applications; the client keeps seeing status = pending — it is NOT exposed on the client ApplicationResource) and raises a compliance case. An operator must CONFIRM the rejection before the client-visible status changes.
  2. 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 sets review_status → reopened + status → pending. A new web-guard permission onboarding.applications.review gates both.
  3. A confirmed rejection blocks re-submission until re-opened. SubmitOnboardingApplicationHandler refuses (before any document capture / provider call) when the tenant holds any application with review_status = rejection_confirmed — coded APPLICATION_REJECTED_AWAITING_REOPEN (409).
  4. 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.


← Decision log index