AP Processing Modes 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: Replace the tenant AP approvals boolean with append-only tenant AP processing modes that drive human review, inline editing, automatic output, and manual export authorization.

Architecture: Add tenant_ap_processing_config as the single runtime AP policy source, with latest row winning and no-row defaulting to read_only. Centralize policy and output authorization in com.getorcha.ap.processing; app handlers and workers call it instead of each callsite directly checking tenant_ap_approval_config, approval rows, or DATEV eligibility. Keep the implemented document output dispatcher as the output execution path, adding only the missing processing_completed trigger and new callsites.

Tech Stack: Clojure, HoneySQL via com.getorcha.db.sql, PostgreSQL migrations/enums, Integrant workers, SQS document output helper, HTMX/Hiccup admin and document UI, clojure.test.


File Structure

Task 1: Schema And Backfill

Files:

Create resources/migrations/20260501130000-add-ap-processing-modes.up.sql:

-- Up: AP processing modes

ALTER TYPE document_output_trigger ADD VALUE IF NOT EXISTS 'processing_completed';

--;;

CREATE TYPE ap_processing_mode AS ENUM (
  'read_only',
  'human_review_export',
  'straight_through_export'
);

--;;

CREATE TABLE tenant_ap_processing_config (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id  UUID NOT NULL REFERENCES tenant(id) ON DELETE CASCADE,
  mode       ap_processing_mode NOT NULL,
  created_by UUID REFERENCES "identity"(id) ON DELETE SET NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

--;;

CREATE INDEX idx_tenant_ap_processing_config_latest
  ON tenant_ap_processing_config (tenant_id, created_at DESC, id DESC);

--;;

INSERT INTO tenant_ap_processing_config (tenant_id, mode, created_by, created_at)
SELECT tenant.id,
       CASE
         WHEN COALESCE(tenant_ap_approval_config.is_enabled, false)
         THEN 'human_review_export'::ap_processing_mode
         ELSE 'read_only'::ap_processing_mode
       END,
       NULL,
       now()
FROM tenant
LEFT JOIN tenant_ap_approval_config
  ON tenant_ap_approval_config.tenant_id = tenant.id;

Create resources/migrations/20260501130000-add-ap-processing-modes.down.sql:

-- Down: AP processing modes
--
-- PostgreSQL cannot drop enum values from document_output_trigger without
-- recreating the type and rewriting dependent columns. Leave the added
-- processing_completed value in place on rollback; it is harmless when unused.

DROP TABLE IF EXISTS tenant_ap_processing_config;

--;;

DROP TYPE IF EXISTS ap_processing_mode;

Run:

clj -X:test:silent :nses '[com.getorcha.db.migrations-test]'

Expected: PASS.

git add resources/migrations/20260501130000-add-ap-processing-modes.up.sql resources/migrations/20260501130000-add-ap-processing-modes.down.sql
git commit -m "feat(ap): add processing mode schema"

Task 2: AP Processing Policy Namespace

Files:

In test/com/getorcha/app/document_output_test.clj, add:

(deftest active-job-ignores-terminal-jobs
  (let [tenant-id (helpers/create-tenant!)
        user-id   (helpers/create-identity!)
        doc-id    (helpers/create-completed-document! tenant-id user-id)
        failed    (document-output/create-job!
                   fixtures/*db*
                   {:tenant-id       tenant-id
                    :document-id     doc-id
                    :document-domain :ap
                    :trigger         :manual
                    :requested-by    user-id})]
    (db.sql/execute-one!
     fixtures/*db*
     {:update :document-output-job
      :set    {:status (db.sql/->cast "failed" :document-output-status)}
      :where  [:= :id (:document-output-job/id failed)]})
    (is (nil? (document-output/active-job fixtures/*db* doc-id)))
    (let [pending (document-output/create-job!
                   fixtures/*db*
                   {:tenant-id       tenant-id
                    :document-id     doc-id
                    :document-domain :ap
                    :trigger         :manual
                    :requested-by    user-id})]
      (is (= (:document-output-job/id pending)
             (:document-output-job/id
              (document-output/active-job fixtures/*db* doc-id)))))))

In src/com/getorcha/app/document_output.clj:

  1. Drop ^:private from enum-name so callers (including ap.processing) can reuse it instead of redefining.

  2. After latest-job, add:

(defn active-job
  "Returns the newest pending/running output job for a document, if any.
   Failed and dispatched jobs are terminal and do not block re-export."
  [db document-id]
  (db.sql/execute-one!
   db
   {:select   [:*]
    :from     [:document-output-job]
    :where    [:and
               [:= :document-id document-id]
               [:in :status [(db.sql/->cast "pending" :document-output-status)
                              (db.sql/->cast "running" :document-output-status)]]]
    :order-by [[:created-at :desc]]
    :limit    1}))

Run:

clj -X:test:silent :nses '[com.getorcha.app.document-output-test]'

Expected: PASS.

Create test/com/getorcha/ap/processing_test.clj:

(ns com.getorcha.ap.processing-test
  (:require [clojure.test :refer [deftest is testing use-fixtures]]
            [com.getorcha.ap.processing :as ap.processing]
            [com.getorcha.app.document-output :as document-output]
            [com.getorcha.db.document-processor-run :as db.run]
            [com.getorcha.db.sql :as db.sql]
            [com.getorcha.test.fixtures :as fixtures]
            [com.getorcha.test.notification-helpers :as helpers]))


(use-fixtures :once fixtures/with-running-system)
(use-fixtures :each fixtures/with-db-rollback)


(deftest current-mode-defaults-to-read-only
  (let [tenant-id (helpers/create-tenant!)]
    (is (= :read-only (ap.processing/current-mode fixtures/*db* tenant-id)))))


(deftest append-config!-is-append-only-and-latest-wins
  (let [tenant-id (helpers/create-tenant!)
        admin-id  (helpers/create-identity!)]
    (ap.processing/append-config! fixtures/*db*
                                  {:tenant-id tenant-id
                                   :mode      :read-only
                                   :created-by admin-id})
    (ap.processing/append-config! fixtures/*db*
                                  {:tenant-id tenant-id
                                   :mode      :straight-through-export
                                   :created-by admin-id})
    (is (= :straight-through-export
           (ap.processing/current-mode fixtures/*db* tenant-id)))
    (is (= 2 (:count (db.sql/execute-one!
                      fixtures/*db*
                      {:select [[[:count :*] :count]]
                       :from   [:tenant-ap-processing-config]
                       :where  [:= :tenant-id tenant-id]}))))))


(deftest human-review-output-requires-full-approval
  (let [tenant-id (helpers/create-tenant!)
        alice     (helpers/create-identity!)
        bob       (helpers/create-identity!)
        uploader  (helpers/create-identity!)]
    (ap.processing/append-config! fixtures/*db*
                                  {:tenant-id tenant-id
                                   :mode      :human-review-export
                                   :created-by alice})
    (helpers/add-approver! tenant-id alice 1)
    (helpers/add-approver! tenant-id bob 2)
    (let [doc-id (helpers/create-completed-document!
                  tenant-id uploader
                  :structured-data {:document-type "invoice"}
                  :tenant-ap-approval-snapshot? true)]
      (is (= :approval-pending
             (:reason (ap.processing/authorize-output
                       fixtures/*db* doc-id :manual))))
      (db.sql/execute!
       fixtures/*db*
       {:update :ap-invoice-approval
        :set    {:state      (db.sql/->cast "approved" :ap-invoice-approval-state)
                 :decided-at [:now]
                 :decided-by alice}
        :where  [:= :document-id doc-id]})
      (is (:authorized? (ap.processing/authorize-output
                         fixtures/*db* doc-id :manual))))))


(deftest straight-through-output-requires-terminal-matching-but-not-validation-success
  (let [tenant-id (helpers/create-tenant!)
        user-id   (helpers/create-identity!)
        doc-id    (helpers/create-completed-document!
                   tenant-id user-id
                   :structured-data {:document-type "invoice"})]
    (ap.processing/append-config! fixtures/*db*
                                  {:tenant-id tenant-id
                                   :mode      :straight-through-export
                                   :created-by user-id})
    (is (= :processing-not-terminal
           (:reason (ap.processing/authorize-output
                     fixtures/*db* doc-id :manual))))
    (let [run-id (db.run/insert-running!
                  fixtures/*db*
                  {:document-id      doc-id
                   :processor-id     "matching"
                   :trigger-kind     :manual
                   :document-version 0})]
      (db.run/fail-run! fixtures/*db* run-id "matching failed"))
    (is (:authorized? (ap.processing/authorize-output
                       fixtures/*db* doc-id :manual)))))


(deftest request-output!-enqueues-when-authorized
  (let [tenant-id (helpers/create-tenant!)
        user-id   (helpers/create-identity!)
        doc-id    (helpers/create-completed-document!
                   tenant-id user-id
                   :structured-data {:document-type "invoice"})]
    (ap.processing/append-config! fixtures/*db*
                                  {:tenant-id tenant-id
                                   :mode      :straight-through-export
                                   :created-by user-id})
    (let [run-id (db.run/insert-running!
                  fixtures/*db*
                  {:document-id      doc-id
                   :processor-id     "matching"
                   :trigger-kind     :manual
                   :document-version 0})]
      (db.run/complete-run! fixtures/*db* run-id {:result {:status "skipped"}}))
    (let [enqueued (atom [])]
      (with-redefs [document-output/enqueue-job!
                    (fn [_ctx job-id] (swap! enqueued conj job-id) "message-id")]
        (let [result (ap.processing/request-output!
                      {:db-pool fixtures/*db* :aws {}}
                      {:document-id  doc-id
                       :trigger      :processing-completed
                       :requested-by nil})]
          (is (= :enqueued (:status result)))
          (is (= 1 (count @enqueued)))
          (is (= "processing_completed"
                 (:document-output-job/trigger (:job result)))))))))

Create src/com/getorcha/ap/processing.clj:

(ns com.getorcha.ap.processing
  "Tenant AP processing policy and output authorization."
  (:require [clojure.string :as str]
            [clojure.tools.logging :as log]
            [com.getorcha.app.document-output :as document-output]
            [com.getorcha.db :as db]
            [com.getorcha.db.document-processor-run :as db.run]
            [com.getorcha.db.sql :as db.sql]
            [com.getorcha.schema.document :as schema.document]))


(def document-builder-fn
  (db/schema-row-builder schema.document/Document))


(def ^:private default-mode :read-only)


(defn current-config
  "Returns the latest AP processing config row for a tenant, or nil."
  [db tenant-id]
  (db.sql/execute-one!
   db
   {:select   [:*]
    :from     [:tenant-ap-processing-config]
    :where    [:= :tenant-id tenant-id]
    :order-by [[:created-at :desc] [:id :desc]]
    :limit    1}))


(defn current-mode
  "Returns the tenant's current AP processing mode keyword.
   No row means :read-only."
  [db tenant-id]
  (or (some-> (current-config db tenant-id)
              :tenant-ap-processing-config/mode
              (str/replace "_" "-")
              keyword)
      default-mode))


(defn append-config!
  "Appends a new AP processing config row and returns it."
  [db {:keys [tenant-id mode created-by]}]
  (db.sql/execute-one!
   db
   {:insert-into :tenant-ap-processing-config
    :values      [{:tenant-id  tenant-id
                   :mode       (db.sql/->cast (document-output/enum-name mode) :ap-processing-mode)
                   :created-by created-by}]
    :returning   [:*]}))


(defn approver-count
  "Returns number of configured tenant AP approvers."
  [db tenant-id]
  (:count
   (db.sql/execute-one!
    db
    {:select [[[:count :*] :count]]
     :from   [:tenant-ap-approver]
     :where  [:= :tenant-id tenant-id]})))


(defn load-document
  "Loads a document using the canonical schema row builder."
  [db document-id]
  (db.sql/execute-one!
   db
   {:select [:*]
    :from   [:document]
    :where  [:= :id document-id]}
   {:builder-fn document-builder-fn}))


(defn approval-rows
  "Loads AP approval rows for a document."
  [db document-id]
  (db.sql/execute!
   db
   {:select   [:*]
    :from     [:ap-invoice-approval]
    :where    [:= :document-id document-id]
    :order-by [[:position :asc]]}))


(defn approval-state
  "Returns aggregate approval state for a document's approval rows."
  [rows]
  (cond
    (empty? rows) :none
    (some #(= "rejected" (:ap-invoice-approval/state %)) rows) :rejected
    (every? #(= "approved" (:ap-invoice-approval/state %)) rows) :fully-approved
    :else :pending))


(defn approval-snapshotted?
  "True when a document has approval rows."
  [db document-id]
  (pos? (:count
         (db.sql/execute-one!
          db
          {:select [[[:count :*] :count]]
           :from   [:ap-invoice-approval]
           :where  [:= :document-id document-id]}))))


(defn invoice-document?
  "True when the document is an invoice and has structured data."
  [{:document/keys [type structured-data]}]
  (and (= "invoice" (some-> type name))
       (map? structured-data)))


(defn latest-ingestion-status
  [db document-id]
  (:ap-ingestion/status
   (db.sql/execute-one!
    db
    {:select   [:status]
     :from     [:ap-ingestion]
     :where    [:= :document-id document-id]
     :order-by [[:created-at :desc]]
     :limit    1})))


(defn matching-terminal?
  "True when latest matching run is completed or failed.
   A skipped matching result is represented as a completed run."
  [db document-id]
  (let [run (some #(when (= "matching" (:document-processor-run/processor-id %)) %)
                  (db.run/latest-runs-per-processor db document-id))]
    (#{"completed" "failed"} (:document-processor-run/status run))))


(defn terminal-for-output?
  "True when an invoice has completed ingestion and terminal matching."
  [db document-id]
  (and (= "completed" (latest-ingestion-status db document-id))
       (matching-terminal? db document-id)))


(defn authorize-output
  "Returns {:authorized? true} or {:authorized? false :reason kw}."
  [db document-id trigger]
  (if-let [doc (load-document db document-id)]
    (let [tenant-id (:document/tenant-id doc)
          mode      (current-mode db tenant-id)]
      (cond
        (not (invoice-document? doc))
        {:authorized? false :reason :not-exportable-invoice}

        (= :read-only mode)
        {:authorized? false :reason :read-only}

        (= :human-review-export mode)
        (let [state (approval-state (approval-rows db document-id))]
          (if (= :fully-approved state)
            {:authorized? true :mode mode :trigger trigger}
            {:authorized? false
             :reason      (case state
                            :none :approval-not-snapshotted
                            :rejected :approval-rejected
                            :approval-pending)}))

        (= :straight-through-export mode)
        (if (terminal-for-output? db document-id)
          {:authorized? true :mode mode :trigger trigger}
          {:authorized? false :reason :processing-not-terminal})

        :else
        {:authorized? false :reason :unknown-mode}))
    {:authorized? false :reason :document-not-found}))


(defn request-output!
  "Authorizes, creates, and enqueues a document output job.

   Returns:
   - {:status :enqueued :job row}
   - {:status :already-active :job row}
   - {:status :forbidden :reason kw}
   - {:status :enqueue-failed :job row :error message}"
  [{:keys [db-pool] :as context} {:keys [document-id trigger requested-by]}]
  (let [auth (authorize-output db-pool document-id trigger)]
    (if-not (:authorized? auth)
      {:status :forbidden :reason (:reason auth)}
      (if-let [active (document-output/active-job db-pool document-id)]
        {:status :already-active :job active}
        (let [doc (load-document db-pool document-id)
              job (document-output/create-job-if-not-active!
                   db-pool
                   {:tenant-id        (:document/tenant-id doc)
                    :document-id      document-id
                    :document-domain  :ap
                    :trigger          trigger
                    :document-version (:document/version doc)
                    :requested-by     requested-by})]
          (if (nil? job)
            {:status :already-active
             :job    (document-output/active-job db-pool document-id)}
            (try
              (document-output/enqueue-job! context (:document-output-job/id job))
              {:status :enqueued :job job}
              (catch Exception e
                (log/warn e "Failed to enqueue AP output job"
                          {:document-id document-id
                           :job-id      (:document-output-job/id job)
                           :trigger     trigger})
                (document-output/mark-send-failed-if-pending!
                 db-pool (:document-output-job/id job) (ex-message e))
                {:status :enqueue-failed
                 :job    job
                 :error  (ex-message e)}))))))))

Run:

clj -X:test:silent :nses '[com.getorcha.ap.processing-test com.getorcha.app.document-output-test]'

Expected: PASS.

git add src/com/getorcha/ap/processing.clj src/com/getorcha/app/document_output.clj test/com/getorcha/ap/processing_test.clj test/com/getorcha/app/document_output_test.clj
git commit -m "feat(ap): centralize processing policy"

Task 3: Admin AP Processing Section

Files:

Replace toggle-rejects-without-approvers-test and toggle-enables-when-approvers-present-test with:

(deftest save-mode-rejects-human-review-without-approvers-test
  (let [tenant-id (helpers/create-tenant!)
        super     (super-identity)
        response  (invoke-handler
                   #'approval/save-mode!
                   {:identity   super
                    :parameters {:path {:id tenant-id}
                                 :form {:mode "human_review_export"}}})]
    (is (= 400 (:status response)))))


(deftest save-mode-appends-human-review-when-approvers-present-test
  (let [tenant-id (helpers/create-tenant!)
        super     (super-identity)
        alice     (helpers/create-identity! :email "alice@t.com")]
    (helpers/add-approver! tenant-id alice 1)
    (let [response (invoke-handler
                    #'approval/save-mode!
                    {:identity   super
                     :parameters {:path {:id tenant-id}
                                  :form {:mode "human_review_export"}}})]
      (is (= 200 (:status response)))
      (is (= "human_review_export"
             (:tenant-ap-processing-config/mode
              (db.sql/execute-one!
               fixtures/*db*
               {:select   [:mode]
                :from     [:tenant-ap-processing-config]
                :where    [:= :tenant-id tenant-id]
                :order-by [[:created-at :desc] [:id :desc]]
                :limit    1}))))))

Add:

(deftest save-mode-preserves-approver-roster-test
  (let [tenant-id (helpers/create-tenant!)
        super     (super-identity)
        alice     (helpers/create-identity! :email "alice@keep.com")]
    (helpers/add-approver! tenant-id alice 1)
    (invoke-handler
     #'approval/save-mode!
     {:identity   super
      :parameters {:path {:id tenant-id}
                   :form {:mode "read_only"}}})
    (invoke-handler
     #'approval/save-mode!
     {:identity   super
      :parameters {:path {:id tenant-id}
                   :form {:mode "straight_through_export"}}})
    (is (= 1
           (:count (db.sql/execute-one!
                    fixtures/*db*
                    {:select [[[:count :*] :count]]
                     :from   [:tenant-ap-approver]
                     :where  [:= :tenant-id tenant-id]}))))))

Add a regression test that saving straight_through_export changes policy only. It must not enqueue output for historical terminal invoices:

(deftest save-straight-through-does-not-request-historical-output-test
  (let [tenant-id (helpers/create-tenant!)
        super     (super-identity)
        calls     (atom [])]
    (with-redefs [ap.processing/request-output!
                  (fn [& args]
                    (swap! calls conj args)
                    {:status :enqueued})]
      (let [response (invoke-handler
                      #'approval/save-mode!
                      {:identity   super
                       :parameters {:path {:id tenant-id}
                                    :form {:mode "straight_through_export"}}})]
        (is (= 200 (:status response)))
        (is (empty? @calls))))))

Add this require to the admin test namespace when adding the regression:

[com.getorcha.ap.processing :as ap.processing]

In src/com/getorcha/admin/http/tenants.clj, change:

["approvals"     "AP Approvals"]

to:

["approvals"     "AP Processing"]

In src/com/getorcha/admin/http/tenants/approval.clj:

  1. Add requires:
[clojure.string :as string]
[com.getorcha.ap.processing :as ap.processing]
  1. Replace load-config with:
(defn ^:private load-config-history
  [db-pool tenant-id]
  (db.sql/execute!
   db-pool
   {:select   [:tenant-ap-processing-config.*
               :identity.email
               :identity.display-name]
    :from     [:tenant-ap-processing-config]
    :left-join [[:identity] [:= :identity.id :tenant-ap-processing-config.created-by]]
    :where    [:= :tenant-ap-processing-config.tenant-id tenant-id]
    :order-by [[:tenant-ap-processing-config.created-at :desc]
               [:tenant-ap-processing-config.id :desc]]
    :limit    5}))
  1. Update approval-section to render AP Processing. Keep #approvals as the section id so the tenant page anchor and existing HTMX target continue working:
(defn approval-section
  "Render fn called from admin/http/tenants.clj's render-detail-page.
   Renders AP processing mode plus reusable approver roster."
  [db-pool tenant-id]
  (let [history   (load-config-history db-pool tenant-id)
        current   (first history)
        mode      (or (:tenant-ap-processing-config/mode current) "read_only")
        approvers (load-approvers db-pool tenant-id)
        members   (load-eligible-members db-pool tenant-id)
        active?   (= mode "human_review_export")]
    [:section#approvals.tenant-detail-section
     [:h2 "AP Processing"]
     [:p.section-hint
      "Choose how AP invoices move through review and output. Approvals and inline edits are active only in Human review then export."]
     [:form.tenant-form
      {:hx-post   (str "/organizations/-/tenants/" tenant-id "/approvals/mode")
       :hx-target "#approvals"
       :hx-swap   "outerHTML"}
      [:label {:for "ap-processing-mode"} "Mode"]
      [:select#ap-processing-mode {:name "mode"}
       [:option {:value "read_only" :selected (= mode "read_only")} "Read-only"]
       [:option (cond-> {:value "human_review_export"
                         :selected (= mode "human_review_export")}
                  (empty? approvers) (assoc :disabled true))
        "Human review then export"]
       [:option {:value "straight_through_export" :selected (= mode "straight_through_export")} "Straight-through export"]]
      [:button.btn.btn-secondary {:type "submit"} "Save mode"]]
     (when (empty? approvers)
       [:p.section-hint "Add at least one approver before selecting Human review then export."])
     [:p.section-hint
      (str "Current mode: "
           (case mode
             "read_only" "Read-only"
             "human_review_export" "Human review then export"
             "straight_through_export" "Straight-through export"
             mode)
           (when current
             (str " set at " (:tenant-ap-processing-config/created-at current))))]
     [:h3 "Approvers"]
     [:p.section-hint
      (if active?
        "Approvers are active for new invoices."
        "Approvers are preserved but inactive in the current mode.")]
     (if (seq approvers)
       [:ol.approver-list
        (for [a approvers
              :let [aid (:tenant-ap-approver/id a)]]
          ^{:key aid}
          [:li
           [:span.approver-position (str (:tenant-ap-approver/position a) ".")]
           [:span.approver-name (or (:identity/display-name a) (:identity/email a))]
           [:span.approver-actions
            [:button.btn-icon
             {:hx-post   (str "/organizations/-/tenants/" tenant-id "/approvals/approvers/" aid "/move")
              :hx-vals   "{\"direction\": \"up\"}"
              :hx-target "#approvals"
              :hx-swap   "outerHTML"
              :title     "Move up"}
             [:iconify-icon {:icon "lucide:arrow-up"}]]
            [:button.btn-icon
             {:hx-post   (str "/organizations/-/tenants/" tenant-id "/approvals/approvers/" aid "/move")
              :hx-vals   "{\"direction\": \"down\"}"
              :hx-target "#approvals"
              :hx-swap   "outerHTML"
              :title     "Move down"}
             [:iconify-icon {:icon "lucide:arrow-down"}]]
            [:button.btn-icon.danger
             {:hx-delete  (str "/organizations/-/tenants/" tenant-id "/approvals/approvers/" aid)
              :hx-target  "#approvals"
              :hx-swap    "outerHTML"
              :hx-confirm "Remove this approver?"
              :title      "Remove"}
             [:iconify-icon {:icon "lucide:trash-2"}]]]])]
       [:p.empty-state "No approvers configured."])
     (when (seq members)
       [:form.add-approver-form
        {:hx-post   (str "/organizations/-/tenants/" tenant-id "/approvals/approvers")
         :hx-target "#approvals"
         :hx-swap   "outerHTML"}
        [:select {:name "identity-id"}
         (for [m members]
           ^{:key (:identity/id m)}
           [:option {:value (str (:identity/id m))}
            (or (:identity/display-name m) (:identity/email m))])]
        [:button.btn.btn-secondary {:type "submit"} "Add approver"]])
     (when (seq history)
       [:div
        [:h3 "Recent mode changes"]
        [:ul.ap-processing-history
         (for [row history]
           ^{:key (:tenant-ap-processing-config/id row)}
           [:li
            (str (:tenant-ap-processing-config/mode row)
                 " at "
                 (:tenant-ap-processing-config/created-at row)
                 " by "
                 (or (:identity/display-name row)
                     (:identity/email row)
                     "system"))])]])]))
  1. Delete cancel-in-flight-approvals!. Mode changes no longer delete approval rows.

  2. Replace toggle! with:

(defn ^:private save-mode!
  "POST /organizations/-/tenants/:id/approvals/mode"
  [{:keys [db-pool identity parameters] :as _request} respond _raise]
  (let [tenant-id (get-in parameters [:path :id])
        mode      (keyword (some-> (get-in parameters [:form :mode])
                                   (string/replace "_" "-")))
        super-id  (:identity/id identity)
        result    (db.sql/with-transaction [tx db-pool]
                    (let [approver-cnt (ap.processing/approver-count tx tenant-id)]
                      (cond
                        (and (= :human-review-export mode) (zero? approver-cnt))
                        :no-approvers

                        (not (contains? #{:read-only
                                          :human-review-export
                                          :straight-through-export}
                                        mode))
                        :invalid-mode

                        :else
                        (do
                          (ap.processing/append-config!
                           tx
                           {:tenant-id  tenant-id
                            :mode       mode
                            :created-by super-id})
                          :saved))))]
    (respond
     (case result
       :no-approvers
       (ring.resp/bad-request "Add at least one approver before enabling human review.")

       :invalid-mode
       (ring.resp/bad-request "Invalid AP processing mode.")

       (ring.resp/ok (layout/partial-content (approval-section db-pool tenant-id)))))))
  1. Update routes:
["/mode"
 {:name ::save-mode
  :post {:parameters {:path {:id :uuid}
                      :form [:map [:mode [:enum "read_only" "human_review_export" "straight_through_export"]]]}
         :handler    #'save-mode!}}]

Remove the old "/toggle" route from this namespace, unless product needs a temporary compatibility route. If retained temporarily, make it call save-mode! through explicit mode values and mark it private to tests only.

Run:

clj -X:test:silent :nses '[com.getorcha.admin.http.tenants.approval-test]'

Expected: PASS.

git add src/com/getorcha/admin/http/tenants.clj src/com/getorcha/admin/http/tenants/approval.clj test/com/getorcha/admin/http/tenants/approval_test.clj
git commit -m "feat(admin): configure ap processing mode"

Task 4: Ingestion Snapshots And Inline Edit Gate

Files:

In test/com/getorcha/test/notification_helpers.clj, add a require:

[com.getorcha.ap.processing :as ap.processing]

Add the mode helper. Accept kebab-case keywords (matching ap.processing/append-config!) and delegate so the SQL-cast normalization lives in one place:

(defn set-ap-processing-mode!
  "Appends a tenant_ap_processing_config row for tests.
   Accepts a kebab-case mode keyword (e.g. :human-review-export)."
  [tenant-id mode]
  (ap.processing/append-config! fixtures/*db*
                                {:tenant-id  tenant-id
                                 :mode       mode
                                 :created-by nil}))

Update enable-ap-approvals! to use the new helper and drop the legacy tenant_ap_approval_config write — Task 8 confirms no runtime code reads that table any more, so the dual write is dead:

(defn enable-ap-approvals!
  "Switches a tenant to human-review mode for tests. Kept under the legacy name
   so existing call sites do not need to change."
  [tenant-id]
  (set-ap-processing-mode! tenant-id :human-review-export)
  nil)

Across the rest of this plan, every call to set-ap-processing-mode! uses kebab-case mode keywords (e.g. :read-only, :straight-through-export).

In test/com/getorcha/test/notification_helpers_test.clj, add:

(deftest set-ap-processing-mode-appends-row-test
  (let [tenant-id (helpers/create-tenant!)]
    (helpers/set-ap-processing-mode! tenant-id :straight-through-export)
    (is (= "straight_through_export"
           (:tenant-ap-processing-config/mode
            (db.sql/execute-one!
             fixtures/*db*
             {:select   [:mode]
              :from     [:tenant-ap-processing-config]
              :where    [:= :tenant-id tenant-id]
              :order-by [[:created-at :desc] [:id :desc]]
              :limit    1}))))))

In src/com/getorcha/workers/ap/ingestion.clj, add require:

[com.getorcha.ap.processing :as ap.processing]

Replace snapshot-approvers! with:

(defn ^:private snapshot-approvers!
  "Inside an ingestion-completion transaction, copies the tenant's ordered
   approver list into ap_invoice_approval rows for this document only when
   the tenant's current AP processing mode is human_review_export."
  [tx tenant-id document-id]
  (when (= :human-review-export (ap.processing/current-mode tx tenant-id))
    (let [approvers (db.sql/execute!
                     tx
                     {:select [:position :identity-id]
                      :from   [:tenant-ap-approver]
                      :where  [:= :tenant-id tenant-id]})]
      (when (seq approvers)
        (db.sql/execute!
         tx
         {:insert-into :ap-invoice-approval
          :values      (mapv (fn [a]
                               {:document-id document-id
                                :position    (:tenant-ap-approver/position a)
                                :identity-id (:tenant-ap-approver/identity-id a)})
                             approvers)})))))

In test/com/getorcha/workers/ap/ingestion_test.clj, update snapshot-approvers-on-completion-test so the production-path assertions cover all processing modes:

(testing "snapshot-approvers! writes rows only in human review mode"
  (let [tenant-id   (helpers/create-tenant!)
        uploader-id (helpers/create-identity!)
        alice       (helpers/create-identity! :email "alice@mode-test.com")]
    (helpers/add-approver! tenant-id alice 1)

    (helpers/set-ap-processing-mode! tenant-id :read-only)
    (let [read-only-doc-id (helpers/create-completed-document! tenant-id uploader-id)]
      (#'com.getorcha.workers.ap.ingestion/snapshot-approvers!
       fixtures/*db* tenant-id read-only-doc-id)
      (is (empty? (db.sql/execute!
                   fixtures/*db*
                   {:select [:id]
                    :from   [:ap-invoice-approval]
                    :where  [:= :document-id read-only-doc-id]}))))

    (helpers/set-ap-processing-mode! tenant-id :straight-through-export)
    (let [straight-through-doc-id (helpers/create-completed-document! tenant-id uploader-id)]
      (#'com.getorcha.workers.ap.ingestion/snapshot-approvers!
       fixtures/*db* tenant-id straight-through-doc-id)
      (is (empty? (db.sql/execute!
                   fixtures/*db*
                   {:select [:id]
                    :from   [:ap-invoice-approval]
                    :where  [:= :document-id straight-through-doc-id]}))))

    (helpers/set-ap-processing-mode! tenant-id :human-review-export)
    (let [human-review-doc-id (helpers/create-completed-document! tenant-id uploader-id)]
      (#'com.getorcha.workers.ap.ingestion/snapshot-approvers!
       fixtures/*db* tenant-id human-review-doc-id)
      (is (= 1
             (:count (db.sql/execute-one!
                      fixtures/*db*
                      {:select [[[:count :*] :count]]
                       :from   [:ap-invoice-approval]
                       :where  [:= :document-id human-review-doc-id]})))))))

Remove assertions that rely on tenant_ap_approval_config.is_enabled as the runtime switch. Keep existing completion-path tests that validate snapshots are created during complete-ingestion!; make them set :human-review-export through helpers/enable-ap-approvals! or helpers/set-ap-processing-mode!.

In src/com/getorcha/app/http/documents/shared.clj, replace approvals-enabled-for-document? with:

(defn approvals-enabled-for-document?
  "Returns true when `document-id` has approval rows.

   Inline editing is tied to the per-invoice human-review snapshot, not the
   tenant's current mode. Returns true when the document does not exist so
   callers can fall through to their own 404 handling."
  [db-pool document-id]
  (let [row (db.sql/execute-one!
             db-pool
             {:select [[[:count :ap-invoice-approval.id] :count]
                       [:document.id :document-id]]
              :from   [:document]
              :left-join [[:ap-invoice-approval]
                          [:= :ap-invoice-approval.document-id :document.id]]
              :where  [:= :document.id document-id]
              :group-by [:document.id]})]
    (or (nil? row)
        (pos? (:count row)))))

In src/com/getorcha/app/http/documents/edits.clj, update docstrings and the 403 text:

(defn ^:private approvals-enabled?
  "Returns true when this document has approval rows. Inline editing is active
   only for invoices snapshotted into human review."
  [db-pool document-id]
  (documents.shared/approvals-enabled-for-document? db-pool document-id))

and:

(respond (ring.resp/forbidden "human review not enabled for this invoice"))

In test/com/getorcha/app/http/documents/edits_test.clj, replace tests that toggle tenant_ap_approval_config with setup that creates or omits ap_invoice_approval rows.

For the blocked case, assert no rows:

(deftest scalar-edit-blocked-when-invoice-has-no-approval-snapshot-test
  (testing "scalar-edit returns 403 when the invoice has no approval rows"
    (let [tenant-id (helpers/create-tenant!)
          user-id   (helpers/create-identity!)
          doc-id    (helpers/create-completed-document!
                     tenant-id user-id
                     :structured-data {:document-type "invoice"})]
      ;; Invoke the existing scalar edit helper in this namespace.
      ;; Expected response remains 403; expected body contains:
      ;; human review not enabled for this invoice
      )))

Keep the existing invocation style in the file; only change the setup and expected error text.

For the allowed case, add approvers and create approval snapshots:

(helpers/add-approver! tenant-id user-id 1)
(let [doc-id (helpers/create-completed-document!
              tenant-id user-id
              :structured-data {:document-type "invoice"}
              :tenant-ap-approval-snapshot? true)]
  ;; Existing successful scalar edit assertion remains.
  )

Run:

clj -X:test:silent :nses '[com.getorcha.test.notification-helpers-test com.getorcha.workers.ap.ingestion-test com.getorcha.app.http.documents.edits-test]'

Expected: PASS.

git add src/com/getorcha/workers/ap/ingestion.clj src/com/getorcha/app/http/documents/shared.clj src/com/getorcha/app/http/documents/edits.clj test/com/getorcha/workers/ap/ingestion_test.clj test/com/getorcha/app/http/documents/edits_test.clj test/com/getorcha/test/notification_helpers.clj test/com/getorcha/test/notification_helpers_test.clj
git commit -m "feat(ap): gate edits by human review snapshot"

Task 5: Approval And Manual Export Authorization

Files:

In test/com/getorcha/app/http/documents/view/approval_test.clj, add a test that read-only mode does not enqueue:

(deftest final-approval-does-not-enqueue-when-current-mode-read-only-test
  (let [enqueued (atom [])
        {:keys [doc-id alice bob approval-rows tenant-id]} (setup-doc-with-approvers!)]
    (helpers/set-ap-processing-mode! tenant-id :read-only)
    (with-redefs [document-output/enqueue-job!
                  (fn [_ctx job-id] (swap! enqueued conj job-id) "msg-1")]
      (invoke-handler
       #'com.getorcha.app.http.documents.view.approval/approve!
       {:identity   {:identity/id alice :identity/is-super-admin false}
        :parameters {:path {:document-id doc-id
                            :approval-id (:ap-invoice-approval/id (first approval-rows))}}})
      (invoke-handler
       #'com.getorcha.app.http.documents.view.approval/approve!
       {:aws        {}
        :db-pool    fixtures/*db*
        :identity   {:identity/id bob :identity/is-super-admin false}
        :parameters {:path {:document-id doc-id
                            :approval-id (:ap-invoice-approval/id (second approval-rows))}}})
      (is (empty? @enqueued)))))

Update the existing final-approval-creates-and-enqueues-output-job-test setup to ensure current mode is human-review-export:

(helpers/set-ap-processing-mode! tenant-id :human-review-export)

In src/com/getorcha/app/http/documents/view/approval.clj:

  1. Add require:
[com.getorcha.ap.processing :as ap.processing]
  1. Remove direct document-output/create-job-if-not-active!, enqueue-job!, and send-failure logic from approve!.

  2. After the transaction commits and fully-approved? is true, call:

(when fully-approved?
  (let [result (ap.processing/request-output!
                request
                {:document-id  document-id
                 :trigger      :approval-completed
                 :requested-by (:identity/id identity)})]
    (when (= :enqueue-failed (:status result))
      (log/warn "Output enqueue after final approval failed"
                {:document-id document-id
                 :error       (:error result)}))))

Keep approval state changes committed even when output request is forbidden, already active, or enqueue fails.

In test/com/getorcha/app/http/documents/view/invoice_test.clj, add require:

[com.getorcha.ap.processing :as ap.processing]

Then add:

(deftest export-datev-forbidden-in-read-only-mode
  (let [document-id #uuid "019dce7b-aefc-709d-b04b-3c989d7e9100"
        tenant-id   #uuid "00000000-0000-0000-0000-000000000001"
        captured    (atom nil)]
    (with-redefs [ap.processing/request-output!
                  (fn [& _] {:status :forbidden :reason :read-only})
                  db.sql/execute-one! (fn [& _]
                                        {:document/id        document-id
                                         :document/tenant-id tenant-id
                                         :document/version   3})
                  shared/tenant-ids (constantly [tenant-id])
                  maesn/get-latest-export-audit (constantly nil)
                  view.invoice/datev-export-section
                  (fn [_router _doc-id _audit can-export? _pending? job]
                    (reset! captured {:can-export? can-export? :job job})
                    [:section])]
      (#'view.invoice/export-datev
       {:db-pool        ::db
        :identity       {:identity/id #uuid "00000000-0000-0000-0000-000000000010"}
        :parameters     {:path {:document-id document-id}}
        ::reitit/router test-router}
       (fn [response] (is (= 403 (:status response))))
       (fn [_error]))
      (is (false? (:can-export? @captured)))
      (is (= "failed" (get-in @captured [:job :document-output-job/status])))
      (is (= "read-only" (get-in @captured [:job :document-output-job/last-error]))))))

Also add:

(deftest export-datev-forbidden-while-human-review-approval-pending
  (let [document-id #uuid "019dce7b-aefc-709d-b04b-3c989d7e9101"
        tenant-id   #uuid "00000000-0000-0000-0000-000000000001"
        captured    (atom nil)]
    (with-redefs [ap.processing/request-output!
                  (fn [& _] {:status :forbidden :reason :approval-pending})
                  db.sql/execute-one! (fn [& _]
                                        {:document/id        document-id
                                         :document/tenant-id tenant-id
                                         :document/version   3})
                  shared/tenant-ids (constantly [tenant-id])
                  maesn/get-latest-export-audit (constantly nil)
                  view.invoice/datev-export-section
                  (fn [_router _doc-id _audit can-export? _pending? job]
                    (reset! captured {:can-export? can-export? :job job})
                    [:section])]
      (#'view.invoice/export-datev
       {:db-pool        ::db
        :identity       {:identity/id #uuid "00000000-0000-0000-0000-000000000010"}
        :parameters     {:path {:document-id document-id}}
        ::reitit/router test-router}
       (fn [response] (is (= 403 (:status response))))
       (fn [_error]))
      (is (false? (:can-export? @captured)))
      (is (= "failed" (get-in @captured [:job :document-output-job/status])))
      (is (= "approval-pending"
             (get-in @captured [:job :document-output-job/last-error]))))))

Keep existing uniqueness/failure tests, but update them to stub ap.processing/request-output! instead of document-output/create-and-enqueue-job!.

Add a render test asserting that approvers-section is not emitted in straight_through_export even when historical rows exist. Use the existing invoice render entry point in this namespace; assert that the rendered hiccup does not contain :section#section-approvers.

(deftest straight-through-export-hides-approvers-section-with-historical-rows
  (let [tenant-id (helpers/create-tenant!)
        user-id   (helpers/create-identity!)
        ;; Snapshot approvals while in human-review, then switch to straight-through.
        _         (helpers/add-approver! tenant-id user-id 1)
        _         (helpers/set-ap-processing-mode! tenant-id :human-review-export)
        doc-id    (helpers/create-completed-document!
                   tenant-id user-id
                   :structured-data {:document-type "invoice"}
                   :tenant-ap-approval-snapshot? true)]
    (helpers/set-ap-processing-mode! tenant-id :straight-through-export)
    (let [hiccup (render-invoice-detail fixtures/*db* doc-id user-id)]
      (is (not (contains-element? hiccup :section#section-approvers))
          "approvers section must not render in straight_through_export"))))

render-invoice-detail and contains-element? are existing test helpers in this namespace; if absent, inline a clojure.walk/postwalk check that searches the hiccup for the element id.

Add a tenant-scoping regression. request-output! must not be reached when the document belongs to a tenant the requester cannot access:

(deftest export-datev-rejects-document-from-inaccessible-tenant
  (let [document-id #uuid "019dce7b-aefc-709d-b04b-3c989d7e9102"
        request-output-called? (atom false)]
    (with-redefs [shared/tenant-ids (constantly [#uuid "00000000-0000-0000-0000-000000000999"])
                  db.sql/execute-one! (constantly nil)
                  ap.processing/request-output!
                  (fn [& _] (reset! request-output-called? true) {:status :enqueued})]
      (try
        (#'view.invoice/export-datev
         {:db-pool        ::db
          :identity       {:identity/id #uuid "00000000-0000-0000-0000-000000000010"}
          :parameters     {:path {:document-id document-id}}
          ::reitit/router test-router}
         (fn [_] (is false "respond should not be called"))
         (fn [_] (is false "raise should not be called")))
        (catch clojure.lang.ExceptionInfo e
          (is (= 404 (:status (ex-data e)))))))
    (is (false? @request-output-called?))))

In src/com/getorcha/app/http/documents/view/invoice.clj:

  1. Add require:
[com.getorcha.ap.processing :as ap.processing]
  1. Replace the body of export-datev with the tenant-scoped pre-check followed by the shared request:
(let [document-id (get-in parameters [:path :document-id])
      ;; Tenant access check. Without this, any authenticated identity could
      ;; trigger an export by guessing a document UUID, since
      ;; ap.processing/authorize-output only consults the document's own
      ;; tenant_id, not the requesting identity's accessible tenants.
      accessible? (boolean
                   (db.sql/execute-one!
                    db-pool
                    {:select [:id]
                     :from   [:document]
                     :where  [:and
                              [:= :id document-id]
                              [:in :tenant-id (shared/tenant-ids request)]]}))]
  (when-not accessible?
    (ring.resp/not-found! {:error "Document not found"}))
  (let [result (ap.processing/request-output!
                request
                {:document-id  document-id
                 :trigger      :manual
                 :requested-by (:identity/id (:identity request))})
        audit  (maesn/get-latest-export-audit db-pool document-id)]
    (case (:status result)
      :enqueued
      (respond (ring.resp/ok
                (datev-export-section router document-id audit false false (:job result))))

      :already-active
      (respond (ring.resp/ok
                (datev-export-section router document-id audit false false (:job result))))

      :enqueue-failed
      (respond (ring.resp/ok
                (datev-export-section
                 router document-id audit false false
                 {:document-output-job/status     "failed"
                  :document-output-job/last-error (:error result)})))

      :forbidden
      (respond (ring.resp/forbidden
                (datev-export-section
                 router document-id audit false false
                 {:document-output-job/status     "failed"
                  :document-output-job/last-error (name (:reason result))}))))))
  1. In the normal detail render path, replace export-eligible-by-approval? with a call to ap.processing/authorize-output when db-pool and document-id exist. Use the result to set can-export?.
(let [auth (when (and db-pool document-id)
             (ap.processing/authorize-output db-pool document-id :manual))
      pending-approval? (= :approval-pending (:reason auth))]
  (datev-export-section router document-id export-audit
                        (and can-export? (:authorized? auth))
                        pending-approval? output-job))
  1. Suppress the approvers section in straight_through_export regardless of historical rows. The spec's Document UI section is asymmetric on purpose: read_only keeps historical approval data visible (its own exception), but straight_through_export removes approval controls entirely so users do not act on stale state from a previous mode.

In src/com/getorcha/app/http/documents/view/invoice.clj, replace:

;; Approvers section (rendered when rows exist for this document)
(view.approval/approvers-section document-id approval-rows identity)

with:

;; Approvers section: hidden in straight_through_export even if historical
;; rows exist; in other modes, approvers-section already returns nil when
;; rows are empty.
(let [tenant-mode (when (and db-pool document-id)
                    (ap.processing/current-mode db-pool tenant-id))]
  (when-not (= :straight-through-export tenant-mode)
    (view.approval/approvers-section document-id approval-rows identity)))

tenant-id is already destructured from document in this render fn; if not present in scope, derive it from the loaded document. Add [com.getorcha.ap.processing :as ap.processing] to the namespace requires (it is already needed for the export authorization change above).

Run:

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

Expected: PASS.

git add src/com/getorcha/app/http/documents/view/approval.clj src/com/getorcha/app/http/documents/view/invoice.clj test/com/getorcha/app/http/documents/view/approval_test.clj test/com/getorcha/app/http/documents/view/invoice_test.clj
git commit -m "feat(ap): authorize output requests by processing mode"

Task 6: Straight-Through Output From Matching Worker

Files:

In test/com/getorcha/workers/ap/processors/matching/worker_test.clj, add:

(deftest process-document-with-output-requests-after-success-test
  (let [tenant-id (helpers/create-tenant!)
        doc-id    (helpers/create-document! tenant-id)
        calls     (atom [])]
    (db.sql/execute-one!
     fixtures/*db*
     {:update :document
      :set    {:structured-data [:lift {:document-type "invoice"}]
               :type            (db.sql/->cast "invoice" :document-type)}
      :where  [:= :id doc-id]})
    (with-redefs [app.ingestion/fire-diagnostics-recomputed! (fn [& _] nil)
                  matching/->matching (fn [] (->NoopProcessor :matching))
                  com.getorcha.ap.processing/request-output!
                  (fn [_ctx attrs] (swap! calls conj attrs) {:status :enqueued})]
      (worker/process-document-with-output!
       {:db-pool fixtures/*db* :llm-config {} :aws {}}
       doc-id))
    (is (= [{:document-id doc-id
             :trigger     :processing-completed
             :requested-by nil}]
           @calls))))

Add:

(deftest process-document-with-output-does-not-request-from-notify-finally-on-transient-failure-test
  (let [tenant-id (helpers/create-tenant!)
        doc-id    (helpers/create-document! tenant-id)
        calls     (atom [])]
    (with-redefs [app.ingestion/fire-diagnostics-recomputed! (fn [& _] nil)
                  com.getorcha.ap.processing/request-output!
                  (fn [& _] (swap! calls conj :called))]
      (try
        (worker/process-document-with-output!
         {:db-pool fixtures/*db* :llm-config {} :aws {}}
         doc-id)
        (catch Exception _)))
    (is (empty? @calls))))

In src/com/getorcha/workers/ap/processors/matching/worker.clj, add require:

[com.getorcha.ap.processing :as ap.processing]

Add a new public helper next to process-document-with-notify!:

(defn process-document-with-output!
  "Runs matching, fires diagnostics recompute, then requests straight-through
   output on successful terminal matching. Does not request output from the
   notify finally path."
  [context document-id]
  (process-document-with-notify! context document-id)
  (ap.processing/request-output!
   context
   {:document-id  document-id
    :trigger      :processing-completed
    :requested-by nil}))

In the SQS success path, replace:

(process-document-with-notify! config document-id)

with:

(process-document-with-output! config document-id)

Change handle-failure! to return a terminal marker:

(cond
  (not transient?)
  (do
    (write-failed-run! db-pool document-id error-msg)
    (aws/delete-message! sqs-client queue-url message)
    :terminal-failed)

  (< receive-count max-receive-count)
  (do
    (log/info "Extending message visibility for retry"
              {:document-id   document-id
               :receive-count receive-count
               :delay-seconds (backoff-seconds receive-count)})
    (aws/extend-visibility! sqs-client queue-url message
                            (backoff-seconds receive-count))
    :retrying)

  :else
  (do
    (write-failed-run! db-pool document-id error-msg)
    :terminal-failed))

In the SQS catch path, after handle-failure!:

(when (= :terminal-failed (handle-failure! config message document-id t))
  (ap.processing/request-output!
   config
   {:document-id  document-id
    :trigger      :processing-completed
    :requested-by nil}))

Ensure the code calls handle-failure! once and stores its return value.

Run:

clj -X:test:silent :nses '[com.getorcha.workers.ap.processors.matching.worker-test]'

Expected: PASS.

git add src/com/getorcha/workers/ap/processors/matching/worker.clj test/com/getorcha/workers/ap/processors/matching/worker_test.clj
git commit -m "feat(ap): request straight-through output after matching"

Task 7: DATEV Capability And Dispatch Tests

Files:

In test/com/getorcha/workers/document_output_test.clj, add:

(deftest dispatch-job!-does-not-block-validation-errors
  (let [calls (atom 0)
        job   (output-job! "pending")]
    (db.sql/execute-one!
     fixtures/*db*
     {:update :document
      :set    {:diagnostics [:lift {:validations {:required-fields {:status "error"}}}]}
      :where  [:= :id (:document-id job)]})
    (with-redefs [maesn/connected-datev-integration
                  (fn [& _] {:tenant-datev-integration/disconnect-reason nil})
                  maesn/create-booking-proposal!
                  (fn [& _] (swap! calls inc) {:task-id "task-1"})]
      (worker/dispatch-job!
       {:aws {} :db-pool fixtures/*db* :integrations {}}
       (:document-output-job/id job)
       600)
      (is (= 1 @calls)))))

If connected-datev-integration cannot be redefined because of visibility, keep this regression in a new test/com/getorcha/integrations/ap/maesn_test.clj focused directly on export-eligible?.

In src/com/getorcha/integrations/ap/maesn.clj, replace the function with:

(defn export-eligible?
  "True when the document has a usable DATEV integration.

   Validation, matching, and reconciliation diagnostics do not block export;
   they are output content and should be reflected in the cover page/audit."
  [db-pool {:document/keys [tenant-id structured-data] :as _document}]
  (let [integration (connected-datev-integration {:db-pool db-pool} tenant-id)]
    (and (map? structured-data)
         (some? integration)
         (nil? (:tenant-datev-integration/disconnect-reason integration)))))

Run:

clj -X:test:silent :nses '[com.getorcha.workers.document-output-test]'

Expected: PASS.

git add src/com/getorcha/integrations/ap/maesn.clj test/com/getorcha/workers/document_output_test.clj
git commit -m "fix(datev): allow export with diagnostics errors"

Task 8: Remove Runtime Uses Of Legacy Approval Config

Files:

Run:

rg -n "tenant-ap-approval-config|tenant_ap_approval_config|is-enabled" src/com/getorcha

Expected remaining allowed references after earlier tasks:

Use these replacements:

Do not delete tenant_ap_approval_config in this implementation. It remains unused rollback scaffolding and a migration backfill source.

Run:

clj -X:test:silent :nses '[com.getorcha.admin.http.tenants.approval-test com.getorcha.app.http.documents.edits-test com.getorcha.app.http.documents.view.approval-test com.getorcha.app.http.documents.view.invoice-test com.getorcha.workers.ap.processors.matching.worker-test com.getorcha.workers.document-output-test com.getorcha.ap.processing-test]'

Expected: PASS.

git add src test
git commit -m "refactor(ap): remove legacy approval config policy reads"

Task 9: Integration Verification

Files:

clj -X:test:silent :nses '[com.getorcha.db.migrations-test]'

Expected: PASS.

clj -X:test:silent :nses '[com.getorcha.ap.processing-test com.getorcha.admin.http.tenants.approval-test com.getorcha.app.document-output-test com.getorcha.app.http.documents.edits-test com.getorcha.app.http.documents.view.approval-test com.getorcha.app.http.documents.view.invoice-test com.getorcha.workers.ap.processors.matching.worker-test com.getorcha.workers.document-output-test]'

Expected: PASS.

clj-kondo --lint src test dev

Expected: no findings.

clj -X:test

Expected: PASS.

rg -n "tenant-ap-approval-config|tenant_ap_approval_config|is-enabled" src/com/getorcha

Expected: no runtime policy reads. If comments remain, they must describe legacy state.

rg -n "processing-completed|processing_completed" src test resources/migrations

Expected:

If any verification fixes changed files:

git status --short
git add src test resources/migrations
git commit -m "test(ap): verify processing mode integration"

Before running git add, inspect git status --short and only stage files modified by this implementation. If no files changed, skip this commit.