Skip to main content

D83 — Async-request platform: pollable tenant-scoped request/status resource in a new async-requests module (RESOLVED)

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.

Clients (and, once a back-office consumer lands, the back office) kick off long-running requests (on-demand statements, exports, computed answers) whose result cannot ride the initiating HTTP response. The platform for that is a new async-requests module (namespace Modules\AsyncRequests) owning one generic, pollable, tenant-scoped (RLS) resource: async_requests (conventional shape — identifiers(), tenant_id FK, soft-deletes, blamable). Only the client poll route exists today; see "Deliberately NOT built" below.

Why a new module (not Foundation). The platform owns a table, a permission, an HTTP surface, and a lifecycle state machine — Foundation is the dependency-free shared kernel and owns no domain tables. As a module it slots into the graph as a Notification-peer infrastructure module (depends DOWN on Foundation/Tenancy/Identity/Authorization only) and consumers form a normal one-directional edge INTO it — expressible and guarded in ModuleBoundaryTest, which a Foundation placement would blur.

The status lifecycle. queued → processing → succeeded | failed (AsyncRequestStatus, string-backed). complete/fail may fire straight from queued (a fast job need not mark processing); markProcessing is retry-idempotent; succeeded/failed are TERMINAL and immutable — every transition out of a terminal status is rejected with ASYNC_REQUEST_ALREADY_TERMINAL (409), so a duplicate job retry can never overwrite a delivered result. Terminal transitions stamp completed_at; failures carry a human-readable failure_reason.

The typed result. A succeeded request carries result_type (AsyncRequestResultType: link | confirmation | answer) + a redaction-safe jsonb result payload the FE branches on: link = a short-lived signed URL {url, expires_at} (the documents DocumentDownloadData shape — the artefact never rides the poll response); confirmation = {confirmed, message}; answer = a small consumer-defined structured payload. context (jsonb) optionally captures redaction-safe request parameters at create time for display/triage.

The consumer seam (D52). Consumers never touch this module's commands: the published Integration\Contracts\AsyncRequestPlatform interface (bound to the private, bus-dispatching AsyncRequestPlatformService — the compliance CaseIntake precedent) exposes create/markProcessing/complete/fail. A consumer's own GATED command handler calls create() (that permission is the create gate — the platform mints none) and returns the uuid to the FE; the consumer's queued job drives the transitions (platform-internal commands, authorize() return true on the AuthorizationCoverageTest PUBLIC_MESSAGES list — the documents RecordProviderDocumentIdCommand peer) in the tenant RLS scope the job carries. The FE polls GET /api/v1/async-requests/{uuid} — gated on the module's own async-requests.view client permission (full-catalog owner/admin pick it up automatically at seed time). Payments exports (Task 05) and Notification CTAs (Task 03) are the first planned adopters; each currently sits on the module-boundary forbidden list and removes its own entry when it adopts.

Deliberately NOT built: no generic job runner (consumers run their own queued jobs and call the transition seam), no broadcast/realtime/SSE push (polling only — revisit if poll volume hurts), no client-facing create endpoint, no list endpoint (added when a consumer needs an inbox of requests), no retention/pruning policy yet (rows are small; revisit with the first high-volume consumer), and no operator/back-office poll surface — deferred to the first back-office consumer (the create/transition seam is guard-agnostic and already supports it; only the operator poll route/permission is missing).

2026-07-04 amendment — list endpoint + retention DELIVERED. Two of the deferrals above landed once the FE stated it cannot hold poll state across sessions: GET /api/v1/async-requests (exact type/status filters, inclusive created_after/created_before, newest-first, cursor pagination via the Foundation MappedCursorPaginator; same async-requests.view gate + route stack as the poll) is the poll-state recovery surface, and the async-requests:prune daily sweep (config async-requests.retention_days, default 30, null = never) force-deletes TERMINAL rows past the window — display state only; the audit trail keeps every transition and consumers' object-storage artifacts are untouched. The (type, created_at) index removed in the original review as speculative was re-added with the list endpoint that justifies it. Still not built: the job runner, push, the client create endpoint, and the operator/back-office poll surface.

Poll visibility is TENANT-wide by design (recorded posture, review flagged it consciously): any tenant member holding async-requests.view can poll any of the tenant's requests by uuid — consistent with every sibling tenant read surface (payments/accounts reads behave identically under the roles-as-UX / single-permission posture), but broader than the notification inbox, which additionally scopes to the actor. The considered consequence: async-requests.view alone lets a member read the terminal result of a request created under a permission they do not hold (e.g. follow another member's export link). Consumers whose results are per-user-sensitive or must stay behind the originating capability's permission MUST NOT put a broad raw signed URL in the link payload — point the link at a permission-gated endpoint of their own instead (the documents signed-route pattern), or take the platform's created_by (Blamable) into their own read model. Creator-scoping the platform poll (with an async-requests.view-all for admins) is the recorded fallback if a consumer needs it. Related freeze-posture edge for the first consumer (Task 05): the poll route inherits account-not-frozen, so a frozen tenant cannot poll a pre-freeze export result — the first consumer decides + documents whether that is desired.


← Decision log index