Architecture flow map — how a request moves through stables-core-app
What this covers / who it's for. The code-grounded, whole-app runtime map: the module fleet, the path of a request, the CQRS bus gate order, the webhook→mirror ingest pipeline, the async platforms, the notifications/documents flows, the audit/DLP seams, and the scheduled backbone. Everything else in the engineering wiki hangs off this page. Code is the source of truth: when this map and the code disagree, the code wins and this page gets fixed in the same change.
Companions: the stables-app-development
skill
(patterns + golden rules, with per-layer reference files), each module's
app-modules/<module>/README.md (current specifics — indexed in the
module catalog), docs/conduit/README.md
(the integration plan), the decision log (D-numbered ADRs, through D93),
and docs/EVENTS.md (the generated domain-event catalog).
1. The module fleet
One Laravel app, two surfaces: the client (tenant) JSON API under /api/v1/* (client
guard, Sanctum stateful, RLS-scoped) consumed by a separate TanStack SPA, and the operator
back-office JSON API under /api/v1/backoffice/* (web guard, central/BYPASSRLS) plus a thin
Livewire shell. 25 modules under app-modules/* (internachi/modular path packages;
tests/Pest.php discovers them from disk), in three groups (the skill's grouping):
Dependencies point down to the shared kernel and are acyclic;
tests/Architecture/ModuleBoundaryTest.php is the enforced, hand-maintained graph — the
module catalog carries the per-module sketch of it. Cross-module use is
allowed only via \Models, Events, Integration\Contracts, Enums/DataObjects, and
Concerns (D52). The shared cross-module vocabulary lives in Foundation as typed enums:
FinanceProvider (D84 — the provider identity keying every mirror row) and SupportedAsset
(D90 — the closed asset set with pinned decimals that feed money rounding).
2. A tenant API request, end to end
The canonical read/write path (route file example:
app-modules/funding/routes/funding-routes.php):
- Middleware (aliases in
bootstrap/app.php):correlation→Modules\Foundation\Http\Middleware\AssignCorrelationId; tenancy init is the FQCNStancl\Tenancy\Middleware\InitializeTenancyByRequestData(configured inconfig/tenancy.php, resolving theX-Tenantuuid to the tenant);tenant.member→Modules\Tenancy\Http\Middleware\EnsureUserBelongsToCurrentTenant;account-active→EnsureAccountActive;account-not-frozen→EnsureAccountNotFrozen(the compliance freeze wall — mounted on every authenticated tenant group);account-not-closedgates client login. - Auth: tenant surface =
auth:sanctumresolving aModules\Identity\Models\ClientUser(NOBYPASSRLS connection + thestables.current_tenantsession variable). Operator surface =auth:webresolving aModules\Identity\Models\User(BYPASSRLS). Deep dive: Auth & RBAC. - Isolation is the database's job: every tenant-scoped table has a forced RLS policy
generated by
php artisan tenants:rls(stanclTableRLSManager), verified bytenants:verify-isolation. Handlers never writewhere('tenant_id', …)on the tenant surface. Deep dive: Tenancy & RLS. - Responses: resources extend
Modules\Foundation\Http\Resources\JsonApiResource(toType/toId/toAttributes); errors are rendered byModules\Foundation\Http\JsonApiExceptionHandler(registered inbootstrap/app.php) from codedModules\Foundation\Exceptions\DomainException+Modules\Foundation\Enums\ErrorCode. Public ids are always the UUIDv7uuid(PostgresDEFAULT uuidv7()), never bigints or provider ids. Deep dive: API conventions.
The operator (back-office) variant
Operator groups (e.g. app-modules/compliance/routes/compliance-routes.php,
app-modules/support/routes/support-routes.php,
app-modules/task-runner/routes/task-runner-routes.php) use the shorter central stack —
correlation → EnsureFrontendRequestsAreStateful → throttle:60,1 → auth:web →
account-active — with no tenancy init and no tenant.member. The operator connection is
BYPASSRLS, so a handler reading a tenant-scoped model sees every tenant's rows (the
sanctioned cross-tenant read — e.g. compliance GetCtrCandidatesHandler, support
GetTenantAccountsHandler); per-tenant scoping there is an explicit tenant_id predicate.
Every guarded route declares ->requires(<PermissionKey>), exported to
openapi/endpoint-permission-map.json by php artisan abilities:export.
3. The CQRS bus pipeline (the only path to domain data)
Modules\Foundation\Bus\LaravelCommandBus / LaravelQueryBus (facades CommandBus/QueryBus).
Controllers and Livewire never touch Eloquent (CqrsBoundaryTest); every message declares
handledBy(): class-string and its authorization. The true gate order inside
LaravelCommandBus::dispatch() (verified against the class; deep dive:
The CQRS bus):
authorize($actor)— declared viaChecksPermission+ the AuthorizationAuthorizesViaDeclaredPermissionconcern (resolved throughPermissionGate:web→ spatiecan(),client→hasTenantPermission()), or a bespokeauthorize()on theAuthorizationCoverageTestallow-list. Denied → auditcommand.denied+CommandDeniedevent (durable, D69) +AccessDeniedHttpException(403).- Step-up MFA —
RequiresFreshMfa+ stale step-up →STEP_UP_REQUIRED(403). - Freeze gate —
RequiresAccountActive→AccountFreezeGuard::assertNotFrozen(tenantId)(Foundation declares the contract + a fail-open no-op default; compliance binds the enforcing implementation reading centralaccount_states, D48) →ACCOUNT_FROZEN/ACCOUNT_CLOSED. - Feature gate —
RequiresFeature→FeatureGuard::ensureAvailable()(the features module's entitlement engine; fail-closed — an unbound guard throws). - Rationale gate —
RequiresRationalewith an emptyrationale()→RATIONALE_REQUIRED(422, D46). - Execute — audit
command.dispatched, then:Idempotentmessages run throughModules\Foundation\Bus\CommandDeduplicatorinsideDB::transaction(dedup INSERT first; a completed duplicate replays its stored result — D28/D56);WithoutTransactionruns bare (auth/session commands); everything else runs insideDB::transaction. (HTTP-level idempotency is separate: theidempotency-keymiddlewareEnforceClientIdempotency+ theSupportsIdempotencyKeydocs attribute.) - Telemetry — success fires
CommandExecuted(Modules\Foundation\Bus\Events); failures fireCommandFailed(unexpected, 5xx) orCommandRejected(security/compliance-significant expected 4xx — the D76 durable-rejection carve-out; routine 4xx stays file-channel-only) fromModules\Foundation\Events, recorded synchronously on the independentpgsql_logssession so the audit row survives the rolled-back transaction. Queries mirror this withQueryExecutedand no transaction.
Those bus events feed the cross-cutting consumers in §7.
4. Provider-mirror ingest (webhook → mirror → projection)
Conduit stays authoritative; the app holds tenant-scoped, RLS-policied mirror tables synced by webhooks + a scheduled reconcile backstop (D59). Full map: Provider integration. The inbound pipeline:
- Mirror writes all flow through
Modules\ProviderMirror\Services\AbstractMirrorUpsert(race-safe, idempotent, order-tolerant); concrete mirrors: accountsWalletMirror/VirtualAccountMirror/WalletSignerMirror/SigningQuorumMirror, fundingTransactionMirror, onboardingCustomerMirror/ApplicationMirror, paymentsOrderMirror/PayoutMirror/RegisteredAddressMirror/WhitelistRecipientMirror. The accounts mirrors overwrite theaccount_balancesprojection wholesale inafterApplyviaModules\Accounts\Projection\BalanceProjector(never incremented, D42). - Domain events off the ingest: funding emits
DepositAwaitingSenderInformation(travel-rule RFI → notification relay),DepositHeldForSenderInfoTimeout(→ complianceCaseIntakeopens a case + travel-rule record),TransactionAwaitingUserSignature(→ payments payout co-sign). Payments registers theorder.*/whitelist_recipient.*handlers; accounts thewallet.*/virtual_account.*handlers. - Client writes never touch a mirror (the maker pattern): local intent → a rails
capability call (
Modules\Rails\Contracts\*— 13 capability interfaces implemented byConduitRailsProviderover the first-party SDK, every attempt logged to centralvendor_request_logson thepgsql_logsside-effect connection) → providerexternal_id→ the webhook/reconcile updates the mirror. - Backstop:
mirrors:reconcile-stale(hourly,ProviderMirrorServiceProvider) sweeps stale non-terminal rows per theMirrorReconciliationRegistrydescriptors each domain registers inboot()— since D93 dispatched as the task-runner typeprovider-mirror.reconcile_stale, one unit per tenant. Reconcilers pull provider truth, recordmirror_drift, and self-heal — but the basediff()compares status only; each reconciler overrides it for its own columns (the coarse-status caveat in Provider integration).
5. The async platforms (three of them — pick deliberately)
The background-work toolbox is three platforms; the decision guide is Async platforms:
- Plain queued jobs on six dedicated Horizon queues (
default,webhooks,documents,audit,dlp,tasks—config/horizon.php) for fire-and-forget side effects with queue-native retries. - Task-runner (D93,
app-modules/task-runner): durable multi-unit batches on the centraltask_runs/task_run_unitsledger — one job per unit, per-unit tenancy, per-unit failure containment, per-type rate limiting,tasks:*console + a read-only backoffice ledger API. The four converted fleet sweeps:payments.monthly_statements,provider-mirror.reconcile_stale,documents.redrive_forward_failed,features.roll_periods. - Async-requests (D83,
app-modules/async-requests): the tenant-facing pollable store for "kick off and poll" UX — the RLS-scopedasync_requeststable (queued → processing → succeeded | failed, typedlink | confirmation | answerresult), theAsyncRequestPlatformseam, andGET /api/v1/async-requests/{uuid}(+ the filtered list endpoint), permissionasync-requests.view, pruned byasync-requests:prune.
Worked consumer — the payments transactions export: RequestTransactionsExportCommand creates
the request via the platform seam and dispatches GenerateTransactionsExportJob after commit →
the job marks processing, streams BuildTransactionsExportQuery through TransactionsCsvWriter
into PaymentsExportStorage, then completes the request with a link result → the SPA polls
the uuid and follows the link (DownloadExportArtifactQuery). Monthly statements combine both
platforms: the task-runner sweep fans out one unit per tenant, and each unit job completes that
tenant's payments.monthly_statement async request.
6. Notifications and documents
Notifications (app-modules/notification): the NotificationDispatcher contract delivers
to client users per notification_preferences over two channels — the custom InboxChannel
(the tenant-scoped notifications inbox) and mail. Compliance-critical notices
(ComplianceNotice, e.g. onboarding/funding RFIs) are non-opt-out. Operator alerts resolve
recipients by responsibility (ResponsibilityBasedRecipientResolver over
config('notification.responsibilities') role mappings — the DLP incident + compliance SLA
escalation path). Notification CTAs may carry an async-request poll uuid as plain data
(NotificationActionData::asyncRequest() — no module dependency). Every send is durably
recorded in the spatie notification log (TenantNotificationLogItem, nullable-tenant RLS),
pruned by notifications:prune-log (D77 class-aware retention).
Documents vault (app-modules/documents): StoreDocumentCommand captures bytes
store-before-forward with per-file envelope encryption (Encryption\EnvelopeEncryptor);
downloads are signed-URL-only (IssueDocumentDownloadUrlQuery / StreamDocumentQuery — the
latter carries the ReadsClassifiedData + EgressesData DLP markers) with per-serve
document_accesses logging; documents:verify-integrity sweeps ciphertext hashes; a queued
ForwardDocumentToProviderJob (a task-runner tracked single job) pushes to the finance rail,
redriven by documents:redrive-forward-failed. Two config-gated extensions ship OFF by
default (ship-state honesty — an environment enables them deliberately):
documents.direct_transfer.enabled (direct-to-object-storage via presigned PUT/GET) and
documents.scanning.enabled (malware-scan quarantine: uploads land scan_status = pending,
ScanDocumentJob promotes to clean or refuses as rejected). Onboarding's KYB submit is the
canonical consumer (vault first, then rails).
The object-storage layout behind all of these flows — the bucket inventory, object-key families,
and the WORM/SSE-KMS/lifecycle posture — lives in
docs/tracking/infra-requests.md.
7. Cross-cutting seams: audit and DLP
Both consume the bus telemetry events (§3.7) — nothing imports them:
- Audit (
app-modules/audit):RecordSuccessfulBusMessage(async, post-commit, onCommandExecuted/QueryExecuted→PersistAuditEventJobon theauditqueue) andRecordBusAttemptOutcome(synchronous — survives the rollback — onCommandDenied/CommandFailed/CommandRejected) append to the central, append-only, hash-chainedaudit_events(per-partition sha256 chain + advisory-lock append, checkpoint anchors). Verified byaudit:verify-chain --checkpoint(hourly — gated byaudit.schedule_verification, ON by default); exported byaudit:export-worm+audit:verify-worm(daily incremental S3 Object-Lock WORM segments, D85 — gated byaudit.worm_export.enabled, OFF by default). - DLP (
app-modules/dlp): declare-driven (D58) — a message implementingReadsClassifiedDatawhose declared model theDataClassificationRegistryclassifies Pii/Secret is captured byRecordClassifiedAccessand persisted viaPersistDataAccessLogJob(thedlpqueue) todata_access_logs(central surface) ortenant_data_access_logs(tenant surface), with tripwire rules (dlp:evaluate-rules) openingdlp_incidents.EgressesData+RecordEgressmirror this for downloads/exports (dlp:evaluate-egress-rules). Modules contribute column classifications by pushing aClassificationProviderinto the registry fromboot()(onboarding, payments, documents…). DLP also provides task-runner'sScrubsLedgerTextimplementation — its PAN/IBAN/SSN detectors mask ledgered error text.
8. The scheduled backbone
No central kernel schedule — each module registers into
Illuminate\Console\Scheduling\Schedule from its own ServiceProvider (callAfterResolving).
22 scheduled commands today:
| Command | Cadence | Owner |
|---|---|---|
treasury:warm-rates | every 15s | treasury (rate cache ≤20s freshness) |
compliance:scan-sla-breaches | every minute | compliance (idempotent SLA escalation) |
dlp:evaluate-rules / dlp:evaluate-egress-rules | every 15 min | dlp |
mirrors:reconcile-stale | hourly | provider-mirror (D59 backstop, rides task-runner) |
audit:verify-chain --checkpoint | hourly (gated audit.schedule_verification, ON by default) | audit |
features:roll-periods | hourly (a task-runner sweep) | features |
documents:redrive-forward-failed | hourly (a task-runner sweep) | documents |
tasks:detect-stuck | hourly | task-runner (surfaces stuck units, never auto-fails) |
audit:export-worm + audit:verify-worm | daily — only when audit.worm_export.enabled (OFF by default) | audit |
documents:verify-integrity, documents:prune-accesses | daily | documents |
notifications:prune-log | daily | notification |
mirrors:prune-drift | daily | provider-mirror |
foundation:prune-command-dedup-records | daily | foundation |
rails:prune-vendor-logs | daily | rails |
model:prune (InboundWebhookCall) | daily | webhooks |
async-requests:prune | daily 03:30 | async-requests (terminal rows past retention_days) |
tasks:prune | daily 03:45 | task-runner (terminal runs past retention_days) |
queue:prune-batches | daily 04:00 | task-runner (the underlying job_batches) |
payments:generate-monthly-statements | monthly (1st, 02:00) | payments (task-runner + async-requests) |
9. Where to go deeper
| Topic | Read |
|---|---|
| Patterns, golden rules, worked example | the stables-app-development skill (+ its reference/*.md) |
| A module's current surface/data model | app-modules/<module>/README.md — indexed in the module catalog |
| Tenancy/RLS internals | Tenancy & RLS; the tenancy-for-laravel-development skill |
| The bus, markers, middleware seams | The CQRS bus |
| The vendor seam / mirrors / webhooks / reconciliation | Provider integration; the multi-provider-development, provider-mirror-development, webhook-client-development, conduit-mirror-domains skills |
| Queues / task-runner / async-requests | Async platforms; the task-runner + async-requests-platform skills |
| Payments, compliance, audit, DLP, documents, features, permissions | their named skills (payments-development, compliance-operations, audit-trail, dlp-and-classification, documents-vault, feature-entitlements, permission-management) |
| Decision rationale | the decision log |
| OpenAPI/docs generation | the scramble-development skill; committed exports openapi/* (regenerate: make openapi-export) |