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.

Reconciliation Scoping Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Scope reconciliation findings to the document being viewed and prevent contracts from inflating clusters.

Architecture: Two changes: (1) assign-cluster! skips cluster operations when either document is a contract, (2) UI filters reconciliation issues by current document ID before rendering.

Tech Stack: Clojure, HoneySQL, Hiccup, clojure.test with embedded Postgres


Task 1: Contracts skip cluster assignment

Files:

Add these tests after the existing assign-cluster-test block (after line 207 in core_test.clj):

(deftest assign-cluster-skips-contracts-test
  (testing "skips cluster creation when source doc is a contract"
    (let [le-id      (helpers/create-legal-entity!)
          contract-id (helpers/create-document-with-type! le-id :contract {:counterparty {:name "A"}})
          invoice-id  (helpers/create-document-with-type! le-id :invoice {:issuer {:name "A"}})]
      (matching/assign-cluster! fixtures/*db* contract-id invoice-id)
      (let [contract (get-document contract-id)
            invoice  (get-document invoice-id)]
        (is (nil? (:document/cluster-id contract)))
        (is (nil? (:document/cluster-id invoice))))))

  (testing "skips cluster creation when candidate doc is a contract"
    (let [le-id      (helpers/create-legal-entity!)
          invoice-id  (helpers/create-document-with-type! le-id :invoice {:issuer {:name "A"}})
          contract-id (helpers/create-document-with-type! le-id :contract {:counterparty {:name "A"}})]
      (matching/assign-cluster! fixtures/*db* invoice-id contract-id)
      (let [contract (get-document contract-id)
            invoice  (get-document invoice-id)]
        (is (nil? (:document/cluster-id contract)))
        (is (nil? (:document/cluster-id invoice))))))

  (testing "skips cluster merge when one doc is a contract with existing cluster"
    (let [le-id       (helpers/create-legal-entity!)
          invoice-id  (helpers/create-document-with-type! le-id :invoice {:issuer {:name "A"}})
          contract-id (helpers/create-document-with-type! le-id :contract {:counterparty {:name "A"}})
          cluster     (db.matching/create-cluster! fixtures/*db*)
          cluster-id  (:ap-document-cluster/id cluster)]
      ;; Invoice already in a cluster
      (db.matching/set-cluster-id! fixtures/*db* [invoice-id] cluster-id)
      (matching/assign-cluster! fixtures/*db* invoice-id contract-id)
      (let [invoice  (get-document invoice-id)
            contract (get-document contract-id)]
        ;; Invoice keeps its cluster, contract stays out
        (is (= cluster-id (:document/cluster-id invoice)))
        (is (nil? (:document/cluster-id contract)))))))

Run: clj -X:test:silent :nses '[com.getorcha.workers.ap.matching.core-test]' Expected: 3 new test failures — contracts get assigned to clusters.

In src/com/getorcha/workers/ap/matching/core.clj, modify assign-cluster! to early-return when either document is a contract. The function already fetches both documents from the DB (line 107), so the types are available:

(defn assign-cluster!
  "Assign cluster to matched documents.

   Cases:
   - Either document is a contract: no-op (contracts don't participate in clusters)
   - Neither has a cluster: create a new one for both
   - One has a cluster: add the other to it
   - Both have different clusters: merge cluster-b into cluster-a
   - Both share the same cluster: no-op

   TODO: contract-level compliance reconciliation — contracts are excluded from
   clusters to keep line-item reconciliation focused, but a second-tier
   reconciliation pass comparing all documents under a contract (cumulative
   spend, price compliance, coverage) would be valuable. See #332."
  [db doc-a-id doc-b-id]
  (let [docs      (db.matching/get-documents-by-ids db [doc-a-id doc-b-id])
        doc-a     (first (filter #(= doc-a-id (:document/id %)) docs))
        doc-b     (first (filter #(= doc-b-id (:document/id %)) docs))
        contract? (fn [{:document/keys [type]}] (= type "contract"))]
    (when-not (or (contract? doc-a) (contract? doc-b))
      (let [cluster-a (:document/cluster-id doc-a)
            cluster-b (:document/cluster-id doc-b)]
        (cond
          (and cluster-a cluster-b (= cluster-a cluster-b))
          nil

          (and cluster-a cluster-b)
          (let [docs-to-move (db.matching/get-cluster-documents db cluster-b)]
            (db.matching/set-cluster-id! db (mapv :document/id docs-to-move) cluster-a)
            (db.matching/delete-cluster! db cluster-b))

          cluster-a
          (db.matching/set-cluster-id! db [doc-b-id] cluster-a)

          cluster-b
          (db.matching/set-cluster-id! db [doc-a-id] cluster-b)

          :else
          (let [cluster (db.matching/create-cluster! db)]
            (db.matching/set-cluster-id! db [doc-a-id doc-b-id] (:ap-document-cluster/id cluster))))))))

Run: clj -X:test:silent :nses '[com.getorcha.workers.ap.matching.core-test]' Expected: all tests pass, including existing assign-cluster-test cases.

Run: clj-kondo --lint src/com/getorcha/workers/ap/matching/core.clj test/com/getorcha/workers/ap/matching/core_test.clj Expected: no warnings or errors.

git add src/com/getorcha/workers/ap/matching/core.clj test/com/getorcha/workers/ap/matching/core_test.clj
git commit -m "feat: skip cluster assignment for contract matches (#332)"

Task 2: Document-scoped reconciliation filtering in UI

Files:

The filtering logic is needed in two places (badge and issues), so add it inline where used. In shared.clj, replace the existing reconciliation-badge function (lines 238-249):

(defn ^:private reconciliation-badge
  "Renders the reconciliation status badge next to the Matches heading.
   Filters issues to only those referencing the given document."
  [document-id reconciliation]
  (let [issues (filterv #(some #{(str document-id)} (:document-ids %))
                        (:issues reconciliation))]
    (if (seq issues)
      (let [issue-count (count issues)
            has-error?  (some #(= "error" (:severity %)) issues)
            badge-class (if has-error? "math-badge math-error" "math-badge math-warning")]
        [:span {:class badge-class}
         (str issue-count " issue" (when (not= 1 issue-count) "s") " found")])
      (when (:status reconciliation)
        [:span.math-badge.math-pass "Reconciled"]))))

Replace the existing reconciliation-issues function (lines 252-278):

(defn ^:private reconciliation-issues
  "Renders the list of reconciliation issues below match cards.
   Only shows issues where the given document is a party to the discrepancy."
  [document-id reconciliation]
  (let [issues (filterv #(some #{(str document-id)} (:document-ids %))
                        (:issues reconciliation))]
    (when (seq issues)
      [:div.reconciliation-issues
       [:h4 {:style "margin: 16px 0 8px; font-size: 14px; color: #c9d1d9;"} "Reconciliation Issues"]
       (for [{:keys [severity summary details]} issues]
         [:div.reconciliation-issue {:class (str "severity-" severity)}
          [:div.issue-header
           [:iconify-icon {:icon  (if (= "error" severity)
                                    "lucide:alert-circle"
                                    "lucide:alert-triangle")
                           :style (str "color: " (if (= "error" severity) "#f85149" "#d29922")
                                       "; flex-shrink: 0; font-size: 16px;")}]
           [:div.issue-body
            [:span.issue-summary summary]]]
          (when (seq details)
            [:details.issue-details {:open true}
             [:summary "Field comparisons"]
             [:table.details-table
              [:thead [:tr [:th "Field"] [:th "Expected"] [:th "Actual"]]]
              [:tbody
               (for [{:keys [field expected actual]} details]
                 [:tr
                  [:td field]
                  [:td (or expected "\u2014")]
                  [:td (or actual "\u2014")]])]]])])])))

In matches-section (line 295 and 324), update the two callsites:

Line 295 — change:

        title-with-badge  [:span "Matches" (reconciliation-badge reconciliation)]]

to:

        title-with-badge  [:span "Matches" (reconciliation-badge document-id reconciliation)]]

Line 324 — change:

      (reconciliation-issues reconciliation)))))

to:

      (reconciliation-issues document-id reconciliation)))))

Run: clj-kondo --lint src/com/getorcha/app/http/documents/view/shared.clj Expected: no warnings or errors.

git add src/com/getorcha/app/http/documents/view/shared.clj
git commit -m "feat: filter reconciliation issues by current document (#332)"

Task 3: Smoke test the full flow

No new code — verify the changes work together by running the full test suite.

Run: clj -X:test:silent :nses '[com.getorcha.workers.ap.matching.core-test]' Expected: all pass.

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