Skip to main content

D76 — Expected client 4xx reclassified at the command-bus catch path: command.rejected@warning, excluded from the durable CommandFailed trail

Architecture decision record, relocated verbatim from the retired single-file docs/tenancy/ decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.

LaravelCommandBus::dispatch() previously caught every Throwable from a handler and audited it identically: command.failed at error level on the audit file channel plus a durable, hash-chained CommandFailed row (D69). That over-classified a routine, expected domain rejection — a coded 4xx DomainException the caller deliberately asked to branch on (a 404 not-found, a 409 conflict, a 422 domain-validation) — as a system failure: error-level log spam on high-traffic endpoints, and a durable "failed attempt" row for what is neither a bug/outage nor a compliance-significant attempt the way a denied freeze/SAR is (raised in the Phase-5 notification review, N11).

Decision. The bus now splits the catch path with a single narrow predicate, Modules\Foundation\Bus\ExpectedClientError::describes(Throwable): bool — true only for a Modules\Foundation\Exceptions\DomainException (which always carries an ErrorCode) whose HTTP status is in [400, 499]. On the two branches:

  • Expected client 4xx (predicate true) → audit command.rejected at warning (mirroring the existing warning-level bus lines — sanitized code + status only, never the raw exception message) and do NOT fire CommandFailed. Because the audit module's RecordBusAttemptOutcome listener keys on CommandFailed, not firing it is sufficient to exclude the rejection from the durable audit_events trail — no listener change needed. The exception is re-thrown unchanged, so the HTTP layer still renders the 4xx.
  • Genuine 5xx / unexpected (predicate false) → unchanged regression-guard path: a 5xx DomainException (e.g. ProviderUnavailable 503), a raw HttpException, a RuntimeException, a QueryException, any unforeseen throwable → command.failed at error (exception class + code only) + durable CommandFailed on the pgsql_logs writer.

The authorize()-denied 403 (command.denied + CommandDenied) is a separate, earlier path and is untouched — it never reaches the catch block. Caller behaviour is identical on both branches (same exception re-thrown); only the log level + durable-trail classification change. The predicate uses a @phpstan-assert-if-true DomainException annotation so the catch block reads $e->errorCode/$e->getStatusCode() under level 6 without a redundant instanceof. Proven by CommandBusTest (rejected@warning + assertNotDispatched(CommandFailed); a 5xx DomainException regression guard), ExpectedClientErrorTest (the predicate: 400/404/409/422/429 → true; 503 / raw HttpException / RuntimeException → false), and AuditAttemptSurvivesRollbackTest (an expected 4xx writes no durable row).

OPEN COMPLIANCE QUESTION (needs compliance sign-off — NOT decided here). Whether an expected 4xx on a money-movement / compliance-significant command — e.g. a payout rejected with a domain 409, an order/withdrawal refused with a coded 4xx — must STILL be recorded durably (as a distinct rejected attempt, not a failed one). D76's general rule drops expected 4xx from the durable trail as routine client noise; that may be wrong for the narrow set of money/compliance commands where even a refused action is itself an auditable event a regulator expects to see. This is flagged, not silently dropped (see docs/tracking/audit-trail.md): if compliance rules it in, the targeted fix is a marker (e.g. DurableRejection) on those specific commands that fires a durable rejected-type attempt onto the same pgsql_logs writer, leaving the high-traffic default (drop) intact.

D76.1 — DurableRejection carve-out: security / dual-control / privilege-escalation 4xx stay durable

Follow-on to D76 (this branch). The blanket "drop every expected 4xx from the durable trail" rule was a correctness regression for a regulated fintech: several handler-thrown 4xx are not routine client noise but security / compliance-significant attempts that were durable before D76 (and must stay durable for a regulator-grade trail + intrusion detection) — inconsistent with the authorize()-denied 403, which stays durable via CommandDenied (D69). We ship the DurableRejection carve-out D76 anticipated, now, rather than deferring the whole question.

Mechanism. A new marker interface Modules\Foundation\Exceptions\DurableRejection marks an expected 4xx that must still be recorded durably. It is recognised two ways, unioned by Modules\Foundation\Bus\ExpectedClientError::isDurableRejection(Throwable): bool:

  • rejections with a dedicated exception class implements DurableRejection (payments PayoutBodyTamperedException = a body-tamper integrity attack, SelfApprovalForbiddenException = a dual-control breach, backoffice SuperAdminGrantForbiddenException = a super-admin-grant privilege-escalation);
  • rejections thrown as a generic DomainException are recognised by an ErrorCode registry, ErrorCode::isDurableRejection()INSUFFICIENT_PRIVILEGE (privilege-escalation via RestrictsRoleGrants in Team + Backoffice — reached after authorize() on ManageRoles passes, so there is no CommandDenied for it), SELF_REVIEW_FORBIDDEN / SELF_SUBMIT_FORBIDDEN (SAR maker/checker SoD, D63). The two mechanisms partition the durable set with no overlap (a dedicated class carries the marker; a codeless generic throw carries the code).

Round-2 completion (this branch). The D76.1 carve-out originally covered INSUFFICIENT_PRIVILEGE (Team/Backoffice RestrictsRoleGrants) but MISSED UpdateOperatorRolesHandler's super-admin-grant escalation, which threw a generic DomainException(ErrorCode::InsufficientPlatformPrivilege, 403). That code is NOT in the registry — and CANNOT be, because the SAME code is reused for the routine 404 "Operator not found" throws (UpdateOperator/Reactivate/Deactivate handlers), which must stay non-durable — so the escalation attempt (reached AFTER authorize()) fell only to the ephemeral file channel. FIX: a dedicated Modules\Backoffice\Exceptions\SuperAdminGrantForbiddenException (extends DomainException implements DurableRejection, same InsufficientPlatformPrivilege + 403 + message) thrown at the super-admin-grant site; the routine 404 stays a plain DomainException (non-durable, correct). A codebase sweep confirmed no OTHER security-significant handler-thrown 4xx was missing durability (SAR SoD, payout tamper/self-approval, and RestrictsRoleGrants escalation were all already durable).

Bus branch (three-way). In the catch path: a DurableRejection expected 4xx → command.rejected@warning (unchanged file line) AND fire the new Modules\Foundation\Events\CommandRejected domain event, which the audit module's RecordBusAttemptOutcome persists as an event_type = 'rejected' audit_events row synchronously on pgsql_logs — so it survives the rolled-back command transaction, exactly like CommandFailed (D69). A routine expected 4xx (plain not-found, ordinary state conflict) → command.rejected@warning only, no durable row (D76 behaviour, correct for noise). A 5xx / unexpectedcommand.failed@error + durable CommandFailed (unchanged). All three still re-throw unchanged, so the HTTP 4xx/5xx is identical to the caller. Ran php artisan events:catalog (new bus.command.rejected wire event).

Proven by CommandBusTest (routine 4xx → neither attempt event; durable 4xx → CommandRejected not CommandFailed), ExpectedClientErrorTest (isDurableRejection: marker + registry true, routine/5xx/non-DomainException false), AuditAttemptSurvivesRollbackTest (a durable rejection writes an event_type='rejected' row that survives rollback), the real security path in TenantRoleManagementTest (an actor with ManageRoles but lacking permission X, minting a role granting X, lands a durable rejected row), and SuperAdminGrantEscalationAuditTest (a non-super-admin granting super-admin lands a durable rejected row; the routine 404 "Operator not found" does NOT).

Residual open question (narrowed). The tamper / dual-control / SoD / privilege-escalation subset is now durable by default. What remains for compliance sign-off is only whether routine money-movement 4xx — an ordinary domain 409 on a payout/order that is not a security attempt — must also be durable. That subset is still dropped; if compliance rules it in, mark those specific exceptions/commands DurableRejection (or add their codes to the registry) — the machinery is now in place.


← Decision log index