Note (2026-04-24): After this document was written,
legal_entitywas renamed totenantand the oldtenantwas renamed toorganization. Read references to these terms with the pre-rename meaning.
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
Files:
resources/migrations/<timestamp>-add-matching-event-trigger.up.sqlresources/migrations/<timestamp>-add-matching-event-trigger.down.sqlStep 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:
AFTER UPDATE OF matching_status — column-specific, not on every document update.WHEN clause prevents firing when status doesn't actually change.succeeded/failed).legal_entity_id is directly on the document row (no join to document needed unlike ingestion trigger which lives on ingestion table).IngestionEvent/ExportEvent pattern: has event/type, document/id, legal-entity/id, tenant/id, old-status.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"
Files:
src/com/getorcha/erp/ingestion.clj:194-229Step 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"
:matching Events in SSE HandlerFiles:
src/com/getorcha/erp/http/documents/view/shared.clj:841-926 (the detail-events function)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"
Files:
src/com/getorcha/erp/http/documents/view/shared.clj:237-265 (the matches-section function)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:
matching-status parameter (5th arg).(list ...) to include the SSE div before the matches list.invoice.clj:95-100.matching-pending? check uses string comparison because the Document Malli schema doesn't include matching-status, so it comes through as a raw string from the DB row builder.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"
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
(reset) in REPLpsql -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>'"
#section-matches updates without page reloadStep 4: Commit any fixes
git add <fixed-files>
git commit -m "fix: lint and test fixes for matching SSE"