Skip to main content

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): correlationModules\Foundation\Http\Middleware\AssignCorrelationId; tenancy init is the FQCN Stancl\Tenancy\Middleware\InitializeTenancyByRequestData (configured in config/tenancy.php, resolving the X-Tenant uuid to the tenant); tenant.memberModules\Tenancy\Http\Middleware\EnsureUserBelongsToCurrentTenant; account-activeEnsureAccountActive; account-not-frozenEnsureAccountNotFrozen (the compliance freeze wall — mounted on every authenticated tenant group); account-not-closed gates client login.
  • Auth: tenant surface = auth:sanctum resolving a Modules\Identity\Models\ClientUser (NOBYPASSRLS connection + the stables.current_tenant session variable). Operator surface = auth:web resolving a Modules\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 (stancl TableRLSManager), verified by tenants:verify-isolation. Handlers never write where('tenant_id', …) on the tenant surface. Deep dive: Tenancy & RLS.
  • Responses: resources extend Modules\Foundation\Http\Resources\JsonApiResource (toType/toId/toAttributes); errors are rendered by Modules\Foundation\Http\JsonApiExceptionHandler (registered in bootstrap/app.php) from coded Modules\Foundation\Exceptions\DomainException + Modules\Foundation\Enums\ErrorCode. Public ids are always the UUIDv7 uuid (Postgres DEFAULT 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 — correlationEnsureFrontendRequestsAreStatefulthrottle:60,1auth:webaccount-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):

  1. authorize($actor) — declared via ChecksPermission + the Authorization AuthorizesViaDeclaredPermission concern (resolved through PermissionGate: web → spatie can(), clienthasTenantPermission()), or a bespoke authorize() on the AuthorizationCoverageTest allow-list. Denied → audit command.denied + CommandDenied event (durable, D69) + AccessDeniedHttpException (403).
  2. Step-up MFARequiresFreshMfa + stale step-up → STEP_UP_REQUIRED (403).
  3. Freeze gateRequiresAccountActiveAccountFreezeGuard::assertNotFrozen(tenantId) (Foundation declares the contract + a fail-open no-op default; compliance binds the enforcing implementation reading central account_states, D48) → ACCOUNT_FROZEN/ACCOUNT_CLOSED.
  4. Feature gateRequiresFeatureFeatureGuard::ensureAvailable() (the features module's entitlement engine; fail-closed — an unbound guard throws).
  5. Rationale gateRequiresRationale with an empty rationale()RATIONALE_REQUIRED (422, D46).
  6. Execute — audit command.dispatched, then: Idempotent messages run through Modules\Foundation\Bus\CommandDeduplicator inside DB::transaction (dedup INSERT first; a completed duplicate replays its stored result — D28/D56); WithoutTransaction runs bare (auth/session commands); everything else runs inside DB::transaction. (HTTP-level idempotency is separate: the idempotency-key middleware EnforceClientIdempotency + the SupportsIdempotencyKey docs attribute.)
  7. Telemetry — success fires CommandExecuted (Modules\Foundation\Bus\Events); failures fire CommandFailed (unexpected, 5xx) or CommandRejected (security/compliance-significant expected 4xx — the D76 durable-rejection carve-out; routine 4xx stays file-channel-only) from Modules\Foundation\Events, recorded synchronously on the independent pgsql_logs session so the audit row survives the rolled-back transaction. Queries mirror this with QueryExecuted and 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: accounts WalletMirror/VirtualAccountMirror/WalletSignerMirror/SigningQuorumMirror, funding TransactionMirror, onboarding CustomerMirror/ApplicationMirror, payments OrderMirror/PayoutMirror/RegisteredAddressMirror/WhitelistRecipientMirror. The accounts mirrors overwrite the account_balances projection wholesale in afterApply via Modules\Accounts\Projection\BalanceProjector (never incremented, D42).
  • Domain events off the ingest: funding emits DepositAwaitingSenderInformation (travel-rule RFI → notification relay), DepositHeldForSenderInfoTimeout (→ compliance CaseIntake opens a case + travel-rule record), TransactionAwaitingUserSignature (→ payments payout co-sign). Payments registers the order.*/whitelist_recipient.* handlers; accounts the wallet.*/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 by ConduitRailsProvider over the first-party SDK, every attempt logged to central vendor_request_logs on the pgsql_logs side-effect connection) → provider external_id → the webhook/reconcile updates the mirror.
  • Backstop: mirrors:reconcile-stale (hourly, ProviderMirrorServiceProvider) sweeps stale non-terminal rows per the MirrorReconciliationRegistry descriptors each domain registers in boot() — since D93 dispatched as the task-runner type provider-mirror.reconcile_stale, one unit per tenant. Reconcilers pull provider truth, record mirror_drift, and self-heal — but the base diff() 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, tasksconfig/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 central task_runs/task_run_units ledger — 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-scoped async_requests table (queued → processing → succeeded | failed, typed link | confirmation | answer result), the AsyncRequestPlatform seam, and GET /api/v1/async-requests/{uuid} (+ the filtered list endpoint), permission async-requests.view, pruned by async-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, on CommandExecuted/QueryExecutedPersistAuditEventJob on the audit queue) and RecordBusAttemptOutcome (synchronous — survives the rollback — on CommandDenied/CommandFailed/CommandRejected) append to the central, append-only, hash-chained audit_events (per-partition sha256 chain + advisory-lock append, checkpoint anchors). Verified by audit:verify-chain --checkpoint (hourly — gated by audit.schedule_verification, ON by default); exported by audit:export-worm + audit:verify-worm (daily incremental S3 Object-Lock WORM segments, D85 — gated by audit.worm_export.enabled, OFF by default).
  • DLP (app-modules/dlp): declare-driven (D58) — a message implementing ReadsClassifiedData whose declared model the DataClassificationRegistry classifies Pii/Secret is captured by RecordClassifiedAccess and persisted via PersistDataAccessLogJob (the dlp queue) to data_access_logs (central surface) or tenant_data_access_logs (tenant surface), with tripwire rules (dlp:evaluate-rules) opening dlp_incidents. EgressesData + RecordEgress mirror this for downloads/exports (dlp:evaluate-egress-rules). Modules contribute column classifications by pushing a ClassificationProvider into the registry from boot() (onboarding, payments, documents…). DLP also provides task-runner's ScrubsLedgerText implementation — 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:

CommandCadenceOwner
treasury:warm-ratesevery 15streasury (rate cache ≤20s freshness)
compliance:scan-sla-breachesevery minutecompliance (idempotent SLA escalation)
dlp:evaluate-rules / dlp:evaluate-egress-rulesevery 15 mindlp
mirrors:reconcile-stalehourlyprovider-mirror (D59 backstop, rides task-runner)
audit:verify-chain --checkpointhourly (gated audit.schedule_verification, ON by default)audit
features:roll-periodshourly (a task-runner sweep)features
documents:redrive-forward-failedhourly (a task-runner sweep)documents
tasks:detect-stuckhourlytask-runner (surfaces stuck units, never auto-fails)
audit:export-worm + audit:verify-wormdaily — only when audit.worm_export.enabled (OFF by default)audit
documents:verify-integrity, documents:prune-accessesdailydocuments
notifications:prune-logdailynotification
mirrors:prune-driftdailyprovider-mirror
foundation:prune-command-dedup-recordsdailyfoundation
rails:prune-vendor-logsdailyrails
model:prune (InboundWebhookCall)dailywebhooks
async-requests:prunedaily 03:30async-requests (terminal rows past retention_days)
tasks:prunedaily 03:45task-runner (terminal runs past retention_days)
queue:prune-batchesdaily 04:00task-runner (the underlying job_batches)
payments:generate-monthly-statementsmonthly (1st, 02:00)payments (task-runner + async-requests)

9. Where to go deeper

TopicRead
Patterns, golden rules, worked examplethe stables-app-development skill (+ its reference/*.md)
A module's current surface/data modelapp-modules/<module>/README.md — indexed in the module catalog
Tenancy/RLS internalsTenancy & RLS; the tenancy-for-laravel-development skill
The bus, markers, middleware seamsThe CQRS bus
The vendor seam / mirrors / webhooks / reconciliationProvider integration; the multi-provider-development, provider-mirror-development, webhook-client-development, conduit-mirror-domains skills
Queues / task-runner / async-requestsAsync platforms; the task-runner + async-requests-platform skills
Payments, compliance, audit, DLP, documents, features, permissionstheir named skills (payments-development, compliance-operations, audit-trail, dlp-and-classification, documents-vault, feature-entitlements, permission-management)
Decision rationalethe decision log
OpenAPI/docs generationthe scramble-development skill; committed exports openapi/* (regenerate: make openapi-export)

← Engineering wiki index