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.

Matching SSE Updates Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: When document matching completes (succeeded or failed), the detail page's matches section updates live via SSE without a page reload.

Architecture: Extend the existing Postgres NOTIFY → core.async pub → SSE looper pipeline with a new matching event type. A Postgres trigger on document.matching_status emits to document_events. The detail-events SSE handler gets a new :matching case that re-renders #section-matches. The matches-section Hiccup function gains an SSE div when matching is in progress.

Tech Stack: Postgres triggers, Malli schema, core.async pub/sub, HTMX SSE extension, Hiccup


Task 1: Postgres Trigger Migration

Files:

Step 1: Create the migration

Run:

bb migrate create "add-matching-event-trigger"

Step 2: Write the up migration

Replace the generated .up.sql content with:

CREATE OR REPLACE FUNCTION notify_matching_event()
RETURNS TRIGGER AS $$
DECLARE
  payload jsonb;
  le_tenant_id uuid;
BEGIN
  -- Only fire for terminal status transitions
  IF NEW.matching_status NOT IN ('succeeded', 'failed') THEN
    RETURN NEW;
  END IF;

  SELECT le.tenant_id INTO le_tenant_id
  FROM legal_entity le
  WHERE le.id = NEW.legal_entity_id;

  payload := jsonb_build_object(
    'event/type', 'matching',
    'document/id', NEW.id::text,
    'document/matching-status', NEW.matching_status::text,
    'legal-entity/id', NEW.legal_entity_id::text,
    'tenant/id', le_tenant_id::text,
    'old-status', CASE WHEN OLD.matching_status IS NULL THEN NULL ELSE OLD.matching_status::text END
  );

  PERFORM pg_notify('document_events', payload::text);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
--;;
CREATE TRIGGER trigger_document_matching_event
    AFTER UPDATE OF matching_status ON document
    FOR EACH ROW
    WHEN (OLD.matching_status IS DISTINCT FROM NEW.matching_status)
    EXECUTE FUNCTION notify_matching_event();

Key details:

Step 3: Write the down migration

DROP TRIGGER IF EXISTS trigger_document_matching_event ON document;
--;;
DROP FUNCTION IF EXISTS notify_matching_event();

Step 4: Apply the migration locally

Run:

psql -h localhost -U postgres -d orcha -c "SELECT COUNT(*) FROM schema_migrations"

Then start the REPL and run (reset) (migrations run on startup), or apply manually:

psql -h localhost -U postgres -d orcha -f resources/migrations/<timestamp>-add-matching-event-trigger.up.sql

Step 5: Verify the trigger exists

psql -h localhost -U postgres -d orcha -c "\df notify_matching_event"
psql -h localhost -U postgres -d orcha -c "SELECT tgname FROM pg_trigger WHERE tgname = 'trigger_document_matching_event'"

Step 6: Test the trigger fires

In one terminal, listen:

psql -h localhost -U postgres -d orcha -c "LISTEN document_events; SELECT 1; \watch 1"

In another terminal, update a document's matching_status:

psql -h localhost -U postgres -d orcha -c "UPDATE document SET matching_status = 'succeeded' WHERE id = (SELECT id FROM document LIMIT 1)"

Verify you see a JSON notification with "event/type": "matching" in the listening terminal.

Step 7: Commit

git add resources/migrations/*-add-matching-event-trigger.up.sql resources/migrations/*-add-matching-event-trigger.down.sql
git commit -m "feat: add Postgres trigger for matching status events"

Task 2: Add MatchingEvent Schema

Files:

Step 1: Add the MatchingEvent schema

After ExportEvent (line 221) and before DocumentEvent (line 224), add:

(def MatchingEvent
  "Schema for matching events from Postgres NOTIFY.

   Events fire when document matching_status changes to a terminal state
   (succeeded or failed)."
  [:map
   [:event/type [:= :matching]]
   [:document/id schema.document/ID]
   [:document/matching-status [:enum "succeeded" "failed"]]
   [:legal-entity/id schema.legal-entity/ID]
   [:tenant/id schema.tenant/ID]
   [:old-status [:maybe :string]]])

Step 2: Add :matching to the DocumentEvent multi

Change the DocumentEvent def from:

(def DocumentEvent
  "Union schema for document events (ingestion or export)."
  (m/schema
   [:multi {:dispatch :event/type}
    [:ingestion IngestionEvent]
    [:export ExportEvent]]))

To:

(def DocumentEvent
  "Union schema for document events (ingestion, export, or matching)."
  (m/schema
   [:multi {:dispatch :event/type}
    [:ingestion IngestionEvent]
    [:export ExportEvent]
    [:matching MatchingEvent]]))

Step 3: Verify it compiles

Use /clojure-eval to evaluate:

(require 'com.getorcha.erp.ingestion :reload)

Step 4: Commit

git add src/com/getorcha/erp/ingestion.clj
git commit -m "feat: add MatchingEvent schema to DocumentEvent multi"

Task 3: Handle :matching Events in SSE Handler

Files:

The detail-events function has a case event-type with :ingestion and :export branches. Add a :matching branch.

Step 1: Add the :matching case

In detail-events (line 872), the case event-type currently has :ingestion and :export. Add after the :export branch (after line 923, before the nil fallthrough on line 926):

               :matching
               ;; Matching complete — re-render matches section
               (let [document (db.sql/execute-one!
                                db-pool
                                {:select [:type]
                                 :from   [:document]
                                 :where  [:= :id document-id]})
                     doc-type (some-> (:document/type document) keyword)
                     matches  (db.matching/get-matched-documents db-pool document-id)]
                 (when doc-type
                   {:event "matching-complete"
                    :data  (hiccup/html
                             (matches-section router document-id doc-type matches))}))

Note: We fetch doc-type from the DB because the SSE handler doesn't have it in scope (unlike detail-page-content which destructures it from the document). This is a lightweight query (single column, by primary key).

No :stop? — the connection stays open. The ingestion SSE connection stops because it re-renders the entire #document-area. The matching SSE doesn't need to stop because it only replaces #section-matches.

Step 2: Update the docstring

Change the detail-events docstring from:

"SSE endpoint for detail page — sends update when document status changes.
   Pings every second to detect client disconnections.
   Handles both ingestion and export events.

To:

"SSE endpoint for detail page — sends update when document status changes.
   Pings every second to detect client disconnections.
   Handles ingestion, export, and matching events.

Step 3: Verify it compiles

Use /clojure-eval:

(require 'com.getorcha.erp.http.documents.view.shared :reload)

Step 4: Commit

git add src/com/getorcha/erp/http/documents/view/shared.clj
git commit -m "feat: handle matching events in detail page SSE handler"

Task 4: Add SSE Div to Matches Section

Files:

The matches-section function currently takes [router document-id doc-type matches]. We need to add matching-status so it knows whether to render the SSE connector.

Step 1: Add matching-status parameter and SSE div

Change the function signature and add the SSE div inside the collapsible-section body.

Current function:

(defn ^:private matches-section
  "Renders the document matches section — always shown for matchable types.
   Shows the newest 5 matches (any type) initially; the rest are loaded via
   HTMX 'Show more' button in batches of 10."
  [router document-id doc-type matches]
  (let [counterparts    (get counterpart-types doc-type [])
        matches-by-type (group-by (comp keyword :type) matches)
        empty-types     (filterv #(empty? (get matches-by-type %)) counterparts)
        visible         (take initial-matches-shown matches)
        remaining       (- (count matches) (count visible))]
    (erp.ui.components/collapsible-section
     "Matches"
     "section-matches"
     [:div.matches-list
      ;; Initial visible match cards (newest first, any type)
      (for [m visible]
        (match-card router (keyword (:type m)) m))
      ;; Show more button (loads next batch via HTMX)
      (when (pos? remaining)
        [:button.match-show-more
         {:hx-get    (str "/documents/view/" document-id "/matches?offset=" initial-matches-shown)
          :hx-target "closest .match-show-more"
          :hx-swap   "outerHTML"}
         (str "Show " (min matches-page-size remaining) " more")])
      ;; No-counterpart placeholders (always visible)
      (for [ct empty-types]
        [:div.match-card.match-card-not-found
         [:span {:class (str "badge badge-" (name ct))} (counterpart-type-label ct)]
         [:span.match-title.match-no-result "No counterpart"]])])))

New function:

(defn ^:private matches-section
  "Renders the document matches section — always shown for matchable types.
   Shows the newest 5 matches (any type) initially; the rest are loaded via
   HTMX 'Show more' button in batches of 10.

   When matching is in progress, includes an SSE connector that will replace
   this section when matching completes."
  [router document-id doc-type matches matching-status]
  (let [counterparts      (get counterpart-types doc-type [])
        matches-by-type   (group-by (comp keyword :type) matches)
        empty-types       (filterv #(empty? (get matches-by-type %)) counterparts)
        visible           (take initial-matches-shown matches)
        remaining         (- (count matches) (count visible))
        matching-pending? (contains? #{"pending" "in-progress"} matching-status)]
    (erp.ui.components/collapsible-section
     "Matches"
     "section-matches"
     (list
      ;; SSE subscription for matching status updates while pending
      (when matching-pending?
        [:div {:hx-ext      "sse"
               :sse-connect (erp.http.routes/path-for router :com.getorcha.erp.http.documents.view/detail-events {:document-id document-id})
               :sse-swap    "matching-complete"
               :hx-target   "#section-matches"
               :hx-swap     "outerHTML"}])
      [:div.matches-list
       ;; Initial visible match cards (newest first, any type)
       (for [m visible]
         (match-card router (keyword (:type m)) m))
       ;; Show more button (loads next batch via HTMX)
       (when (pos? remaining)
         [:button.match-show-more
          {:hx-get    (str "/documents/view/" document-id "/matches?offset=" initial-matches-shown)
           :hx-target "closest .match-show-more"
           :hx-swap   "outerHTML"}
          (str "Show " (min matches-page-size remaining) " more")])
       ;; No-counterpart placeholders (always visible)
       (for [ct empty-types]
         [:div.match-card.match-card-not-found
          [:span {:class (str "badge badge-" (name ct))} (counterpart-type-label ct)]
          [:span.match-title.match-no-result "No counterpart"]])]))))

Key changes:

Step 2: Update all callsites of matches-section

There are three callsites. All need the new matching-status arg.

Callsite 1: detail-page-content (line 424-425 in shared.clj)

The document is destructured at line 283: {:document/keys [id type ...] ...}. The document row comes from SELECT * and includes matching_status as :document/matching-status. But the destructuring doesn't include it. We don't need to change the destructuring — just access it inline.

Change:

               matches-hiccup  (when (get counterpart-types doc-type)
                                 (matches-section router id doc-type matches))]

To:

               matches-hiccup  (when (get counterpart-types doc-type)
                                 (matches-section router id doc-type matches (:document/matching-status _document)))]

Note: _document is already bound via :as _document in the parameter destructuring (line 285). Access :document/matching-status directly from it. The value is a string (e.g., "pending", "succeeded") or nil.

Callsite 2: detail-events :matching handler (added in Task 3)

The :matching handler we added in Task 3 calls matches-section when matching is complete. At that point, matching-status is already terminal — pass nil since we don't want an SSE div in the re-rendered section:

(matches-section router document-id doc-type matches nil)

Callsite 3: detail-events :ingestion handler (line 914)

The ingestion handler renders detail-area-content which calls detail-page-content which calls matches-section. The document is fetched fresh from DB (SELECT * at line 878), so :document/matching-status will be available on the document map passed to detail-page-content. No changes needed here — it flows through automatically.

Step 3: Verify it compiles

Use /clojure-eval:

(require 'com.getorcha.erp.http.documents.view.shared :reload)

Step 4: Commit

git add src/com/getorcha/erp/http/documents/view/shared.clj
git commit -m "feat: add SSE connector to matches section for live updates"

Task 5: Lint and Final Verification

Step 1: Run the linter

clj-kondo --lint src test dev

Fix any issues found.

Step 2: Run affected tests

clj -X:test:silent :nses '[com.getorcha.erp.http.documents.view-test]'

If no view-specific tests exist, run the full suite:

clj -X:test:silent 2>&1 | grep -A 5 -E "(FAIL in|ERROR in|Execution error|failed because|Ran .* tests)"

Step 3: Manual verification

  1. Start the system with (reset) in REPL
  2. Navigate to a document detail page (any invoice or contract)
  3. In another terminal, simulate matching completion:
psql -h localhost -U postgres -d orcha -c "UPDATE document SET matching_status = 'pending' WHERE id = '<document-id>'"
psql -h localhost -U postgres -d orcha -c "UPDATE document SET matching_status = 'succeeded' WHERE id = '<document-id>'"
  1. Verify #section-matches updates without page reload

Step 4: Commit any fixes

git add <fixed-files>
git commit -m "fix: lint and test fixes for matching SSE"