Note (2026-04-24): After this document was written, legal_entity was renamed to tenant and the old tenant was renamed to organization. Read references to these terms with the pre-rename meaning.

Atomic Diagnostics Updates — Design

Date: 2026-04-17 Status: Approved for implementation planning

Problem

Per-processor SSE events (diagnostic-run-started:<id> and diagnostic-run-completed:<id>) drive diagnostic section updates in the document detail view. For each IProcessor run, a DB trigger on document_processor_run fires a pg_notify; the SSE listener forwards a fragment swap for the matching section.

This model fails for fast processors. Validations completes in ~42 ms; its started and completed events arrive at the client within the same HTMX swap window. outerHTML swap removes the element that owned the SSE listener before the new element's listener re-registers. The second event is dropped. The section stays on "Recomputing…" indefinitely.

Tax Compliance has an adjacent problem: the SSE re-render path wraps only the tax-issues slice into the renderer, dropping fields that come from structured-data (issuer/recipient country, service category). The section re-renders without its header chips.

Both symptoms are consequences of a model that pushes per-processor granular updates to the UI when the UI only has five coarse-grained diagnostic sections. The work the UI does on each granular event is the same: fetch current DB state and re-render the section.

Goal

Replace per-processor events with one atomic diagnostics-recomputed SSE event per recompute cycle, emitted by the application layer. The event's payload is one SSE message containing hx-swap-oob fragments for every diagnostic section, each targeting its stable id. The browser processes all OOB fragments together; no listener churn, no race.

The "recomputing underway" UI state is conveyed by OOB swaps in the edit HTTP response (sections render as :stale since the user's edit bumped document.version past the completed runs). No SSE event is needed for that transition.

Architecture

Event model

Single SSE event: diagnostics-recomputed.

Payload shape (NOTIFY JSON):

{"event/type":      "diagnostics-recomputed",
 "document/id":     "…uuid…",
 "legal-entity/id": "…uuid…",
 "tenant/id":       "…uuid…"}

No section-specific data in the payload. The SSE handler reads authoritative state from the DB and renders every section.

Producers

Three application-layer producers fire diagnostics-recomputed via pg_notify on the document_events channel:

Transport

pg_notify → existing document_events LISTEN connection in app/ingestion.clj. Adding :diagnostics-recomputed to the DocumentEvent malli schema lets it flow through the current listener/publisher with no new plumbing. Cross-JVM-safe for when the recompute and SSE endpoints live in different processes.

Consumer

SSE handler in app/http/documents/view/shared.clj:detail-events gets a new case:

:diagnostics-recomputed
{:event "diagnostics-recomputed"
 :data  (hiccup/html (render-all-diagnostic-sections db-pool document-id le-id-set))}

render-all-diagnostic-sections does one DB read per source (document, latest-runs-per-processor, matches/reconciliation/cluster-peers, supplier-verification), then concatenates section fragments. Each fragment has hx-swap-oob="outerHTML" and its stable id (#diagnostic-section-validations, #diagnostic-section-fraud-detector, #diagnostic-section-tax-compliance-analyzer, #diagnostic-section-reconciliation, #section-matches).

Stale transition on edit

The edit HTTP response (app/http/documents/edits.clj) calls render-all-diagnostic-sections and appends the output to the existing fragment. Because the edit has just bumped document.version past every completed run's document_version, the section classifier returns :stale for each one. The browser OOB-swaps all sections to stale without any SSE round-trip.

Components

New

Changed

Removed (dead code)

Failure handling

Ingestion flow

Unchanged except for what the matching worker emits:

Testing

Unit tests

Integration tests

Out of scope for tests

Full NOTIFY → listener → SSE round-trip. with-db-rollback fixture discards pg_notify, same as today. We cover the listener coercion and renderer paths with direct input tests instead.

Migration / rollout

One PR, one migration. Client and server must roll out together — the DB trigger is dropped in the same migration that removes the per-processor event cases from the listener and SSE handler. The diagnostic section components are updated in lockstep to stop relying on sse-swap.

No backward-compat shim. A running server with the old UI loaded won't receive per-processor events after the migration; it will receive the new atomic event, which its old sections don't listen for. Users with a stale tab see "Recomputing…" indefinitely — same failure mode that prompted this work — and need to refresh. Acceptable given the scope of the deploy (local/staging/prod all upgrade at once).

Non-goals