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.rejectedatwarning(mirroring the existing warning-level bus lines — sanitizedcode+statusonly, never the raw exception message) and do NOT fireCommandFailed. Because the audit module'sRecordBusAttemptOutcomelistener keys onCommandFailed, not firing it is sufficient to exclude the rejection from the durableaudit_eventstrail — 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.ProviderUnavailable503), a rawHttpException, aRuntimeException, aQueryException, any unforeseen throwable →command.failedaterror(exception class + code only) + durableCommandFailedon thepgsql_logswriter.
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(paymentsPayoutBodyTamperedException= a body-tamper integrity attack,SelfApprovalForbiddenException= a dual-control breach, backofficeSuperAdminGrantForbiddenException= a super-admin-grant privilege-escalation); - rejections thrown as a generic
DomainExceptionare recognised by an ErrorCode registry,ErrorCode::isDurableRejection()→INSUFFICIENT_PRIVILEGE(privilege-escalation viaRestrictsRoleGrantsin Team + Backoffice — reached afterauthorize()onManageRolespasses, so there is noCommandDeniedfor 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 / unexpected → command.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.