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 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: Persist inline edits of document.structured_data with a full audit trail, optimistic locking, and line-item CRUD/reorder. Replace the ingestion-to-document DB trigger with application-level transactional writes that go through the same audit trail.
Architecture: A new append-only document_history table stores RFC 6902 patches (extended with [id=X] for array-by-id paths) for both ingestion completions and user edits. document.structured_data stays the materialized current state; a new version column gives all-or-nothing optimistic locking. Four HTMX endpoints on /documents/:id/… return HTML fragments; client JS just calls htmx.ajax and lets HTMX swap. Provenance (which paths are human-edited) is computed on-the-fly from document_history at render time.
Tech Stack: Clojure 1.12, PostgreSQL 17 (JSONB, native uuidv7(), enums), Migratus, HoneySQL, next-jdbc, Reitit, Malli, Hiccup2, HTMX, SortableJS.
Spec: docs/superpowers/specs/2026-04-13-document-edit-history-design.md.
REPL workflow:
clj-nrepl-eval --discover-ports to find the running nREPL.clj-nrepl-eval -p <PORT> "(require 'com.getorcha.X :reload)" after pure function edits.(integrant.repl/reset) after component (ig/init-key) changes — ~10s.resources/app/public/ with a cache-busting query string; page reload picks up changes without server restart.Verification: Most tasks have automated Clojure tests against embedded Postgres via the shared test/com/getorcha/test/fixtures.clj machinery. UI-layer tasks have manual verification steps via Playwright CLI (npx playwright-cli …) against the running dev server; no JS test framework in this repo.
New files:
resources/migrations/<timestamp>-add-document-history.up.sql — DDL + backfill + trigger drop.resources/migrations/<timestamp>-add-document-history.down.sql — rollback.resources/migrations/PENDING-CLEANUPS.md — documents the deferred drop of ingestion.structured_data / ingestion.valid_structured_data.src/com/getorcha/json_patch.clj — in-house RFC 6902 applier with [id=X] extension. Pure Clojure, no DB dependency.src/com/getorcha/json_patch/path.clj — converts Clojure vector paths to JSON Pointer strings; pairs with the applier.src/com/getorcha/db/document_history.clj — query/insert helpers for document_history.src/com/getorcha/schema/document_history.clj — Malli schema for document_history rows.src/com/getorcha/app/http/documents/edits.clj — the four edit endpoints (scalar, add, remove, reorder) and their shared handler helpers.src/com/getorcha/app/http/documents/view/provenance.clj — document-provenance helper: walks document_history for a document, returns path → {:edited-by, :edited-at}.resources/app/public/js/sortable-glue.js — ~15 lines of htmx.onLoad hook wiring SortableJS to .line-items-sortable elements.resources/app/public/js/vendor/Sortable.min.js — SortableJS vendored (no CDN per existing asset convention).test/com/getorcha/json_patch_test.cljtest/com/getorcha/json_patch/path_test.cljtest/com/getorcha/db/document_history_test.cljtest/com/getorcha/app/http/documents/view/provenance_test.cljtest/com/getorcha/app/http/documents/edits_test.cljModified files:
src/com/getorcha/schema/invoice/structured_data.clj — LineItem gains required :id, :order.src/com/getorcha/schema/purchase_order/structured_data.clj — ditto (if it has a line-item schema).src/com/getorcha/schema/goods_received_note/structured_data.clj — ditto (if it has one).src/com/getorcha/schema/contract/structured_data.clj — ditto (if it has one).src/com/getorcha/schema/document.clj — Document gains :document/version :int.src/com/getorcha/schema/ingestion.clj — remove :ap-ingestion/structured-data and :ap-ingestion/valid-structured-data from Ingestion.src/com/getorcha/workers/ap/ingestion.clj — add the annotate-line-items post-processing step before validation; replace the ingestion-completion write with a transactional block that writes document_history + document + ingestion.src/com/getorcha/app/http/documents.clj — mount the edits sub-route.src/com/getorcha/app/http/documents/view/shared.clj — emit data-document-version on the detail-container wrapper; invoke document-provenance once and thread it into the view.src/com/getorcha/app/http/documents/view/invoice.clj / purchase_order.clj / goods_received_note.clj / contract.clj / notice.clj — accept and thread provenance-map down to component calls.src/com/getorcha/app/ui/components.clj — editable-value converts Clojure vector paths to JSON Pointer strings, accepts a :provenance option decorating the wrapper. Callers' :path values migrate from EDN-vector-via-pr-str to vector-of-keywords understood by the path converter. line-items-table / line-item-card stop using indices in paths and use {:id <id>} segments instead. Add "+ Add line item" and per-row delete buttons; add SortableJS-compatible hooks on <tbody>.resources/app/public/js/editable-fields.js — on commit, call htmx.ajax('PATCH', …) instead of mutating the DOM locally. Keep click→input swap and Escape→cancel unchanged. Listen to HX-Trigger: focusNewItem to open a just-added line-item's description for editing.resources/app/public/css/style.css — .is-human-edited indicator; .edit-error-banner inline error; drag-handle cursor.scripts/debug_fetch_document.clj — copy document_history rows alongside document + ingestion when fetching from prod.scripts/debug_common.clj — add a JSONB-keys constant for document_history.patch.test/com/getorcha/workers/ap/ingestion_test.clj — update assertions from ingestion.structured_data to document.structured_data + document_history; add cases for the new transactional write, version bump, and conflict behavior..claude/skills/debug-doc/SKILL.md — replace ap_ingestion.structured_data query with document_history join; fix ap_ingestion / ingestion name if stale..claude/skills/ingestion-regression-test/inspector-prompt.md — replace all three ingestion.structured_data reads with document_history reads.:id and :order to LineItem schemasFiles:
Modify: src/com/getorcha/schema/invoice/structured_data.clj:82-104 (the LineItem def).
Modify: src/com/getorcha/schema/purchase_order/structured_data.clj — check for LineItem def; if present, mirror the change.
Modify: src/com/getorcha/schema/goods_received_note/structured_data.clj — ditto.
Modify: src/com/getorcha/schema/contract/structured_data.clj — ditto.
Step 1: Read each schema to confirm which document types actually have a LineItem schema.
grep -n "^(def LineItem" src/com/getorcha/schema/*/structured_data.clj
Record which files need updating. Only update the ones that actually define a line-item schema.
:id and :order as required fields to invoice LineItem.Current (line 82-104):
(def LineItem
"Individual invoice line item."
[:map
[:description :string]
[:article-code [:maybe :string]]
;; … existing fields …
[:page-location [:tuple :int :int]]])
Change to (new fields at the top of the map, immediately after the docstring):
(def LineItem
"Individual invoice line item."
[:map
[:id :string]
[:order :int]
[:description :string]
[:article-code [:maybe :string]]
;; … existing fields …
[:page-location [:tuple :int :int]]])
Repeat for each other LineItem schema identified in Step 1.
cd orcha && clj -X:test:silent :nses '[com.getorcha.workers.ap.ingestion-test]' 2>&1 | grep -E "(FAIL|ERROR|Ran)"
Expected: existing tests that construct line-item fixtures without :id/:order will fail the schema validation. These will be fixed in Task 8; accept the breakage for now.
clj-kondo --lint src/com/getorcha/schema/invoice/structured_data.clj
Expected: no errors or warnings.
git add src/com/getorcha/schema
git commit -m "schema: require :id and :order on LineItem"
:document/version to the Document Malli schemaFiles:
Modify: src/com/getorcha/schema/document.clj:31-48 (the Document def).
Step 1: Add the new field.
Current:
(def Document
"…"
(m/schema
[:map
[:document/id ID]
[:document/legal-entity-id schema.legal-entity/ID]
;; … existing fields …
[:document/created-at inst?]
[:document/updated-at inst?]]))
Insert [:document/version :int] immediately after [:document/id ID]:
(def Document
"…"
(m/schema
[:map
[:document/id ID]
[:document/version :int]
[:document/legal-entity-id schema.legal-entity/ID]
;; … existing fields …
[:document/created-at inst?]
[:document/updated-at inst?]]))
clj-kondo --lint src/com/getorcha/schema/document.clj
git add src/com/getorcha/schema/document.clj
git commit -m "schema: add :document/version to Document"
PENDING-CLEANUPS.mdFiles:
Create: resources/migrations/PENDING-CLEANUPS.md.
Step 1: Write the file.
# Pending schema cleanups
Tracks columns, tables, and triggers that are no longer written or read
but have not yet been dropped. Each entry states what's stale, what
replaces it, and the gating condition for removal.
## `ingestion.structured_data` (JSONB)
- **Replaced by:** `document_history.patch` (for per-ingestion state)
and `document.structured_data` (for the current materialized state).
- **Stopped being written:** the migration that added
`document_history` and dropped `trg_update_document_from_ingestion`.
After that point the ingestion worker's completion handler writes
to `document_history` + `document` transactionally.
- **Gate to drop:** the migration above has been verified stable in
production with no open regressions referencing this column.
## `ingestion.valid_structured_data` (BOOLEAN)
- **Replaced by:** Malli schema validation in the ingestion worker,
recorded implicitly by the presence of a `document_history` row
with `change_type='ingestion'` (failed validation means no row).
- **Stopped being written:** same as above.
- **Gate to drop:** same as above.
git add resources/migrations/PENDING-CLEANUPS.md
git commit -m "docs: track deferred drop of ingestion.structured_data columns"
Files:
resources/migrations/<timestamp>-add-document-history.up.sql.resources/migrations/<timestamp>-add-document-history.down.sql.Migratus uses sortable filename-prefix timestamps, e.g. 20260413120000-…. Use date +%Y%m%d%H%M%S for the timestamp.
-- up
CREATE TYPE document_history_change_type AS ENUM ('ingestion', 'edit');
CREATE TABLE document_history (
id UUID PRIMARY KEY DEFAULT uuidv7(),
document_id UUID NOT NULL REFERENCES document(id) ON DELETE CASCADE,
change_type document_history_change_type NOT NULL,
ingestion_id UUID REFERENCES ingestion(id) ON DELETE SET NULL,
edited_by UUID REFERENCES "identity"(id) ON DELETE SET NULL,
patch JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT document_history_source_xor CHECK (
(change_type = 'ingestion' AND ingestion_id IS NOT NULL AND edited_by IS NULL)
OR
(change_type = 'edit' AND edited_by IS NOT NULL AND ingestion_id IS NULL)
)
);
CREATE INDEX idx_document_history_document_created
ON document_history(document_id, created_at);
ALTER TABLE document ADD COLUMN version INT NOT NULL DEFAULT 1;
-- down
ALTER TABLE document DROP COLUMN IF EXISTS version;
DROP INDEX IF EXISTS idx_document_history_document_created;
DROP TABLE IF EXISTS document_history;
DROP TYPE IF EXISTS document_history_change_type;
psql -h localhost -U postgres -d orcha -c "\d document_history" 2>&1 || true
cd orcha && clj -X:dev com.getorcha.db.migrations/migrate
psql -h localhost -U postgres -d orcha -c "\d document_history"
psql -h localhost -U postgres -d orcha -c "\d document" | grep version
Expected: table exists with all columns; document has a version column, default 1.
cd orcha && clj -X:dev com.getorcha.db.migrations/rollback
psql -h localhost -U postgres -d orcha -c "\d document_history" 2>&1 | grep -q "Did not find" && echo "rolled back OK"
cd orcha && clj -X:dev com.getorcha.db.migrations/migrate
git add resources/migrations/*-add-document-history.*
git commit -m "migration: add document_history table and document.version"
Files:
Modify: the up file created in Task 4.
Modify: the down file created in Task 4.
Step 1: Append backfill + trigger drop to the up file.
After the DDL, add:
-- Backfill :id and :order onto existing line items.
-- idx is 1-based from WITH ORDINALITY; we want 0-based, hence (idx - 1).
UPDATE document SET structured_data = (
SELECT jsonb_set(
structured_data,
'{line-items}',
(SELECT jsonb_agg(
item || jsonb_build_object(
'id', gen_random_uuid()::text,
'order', (idx - 1)
) ORDER BY idx
)
FROM jsonb_array_elements(structured_data->'line-items')
WITH ORDINALITY AS t(item, idx))
)
)
WHERE structured_data ? 'line-items'
AND jsonb_typeof(structured_data->'line-items') = 'array';
-- Backfill one history row per document from its most recent successful ingestion.
INSERT INTO document_history (document_id, change_type, ingestion_id, patch, created_at)
SELECT
d.id,
'ingestion'::document_history_change_type,
latest.id,
jsonb_build_array(jsonb_build_object(
'op', 'replace',
'path', '',
'value', d.structured_data
)),
COALESCE(latest.completed_at, d.created_at)
FROM document d
LEFT JOIN LATERAL (
SELECT id, completed_at
FROM ingestion
WHERE ingestion.document_id = d.id
AND status = 'completed'
ORDER BY completed_at DESC
LIMIT 1
) latest ON TRUE
WHERE d.structured_data IS NOT NULL;
-- Drop the trigger; app-level code now handles ingestion → document.
DROP TRIGGER IF EXISTS trg_update_document_from_ingestion ON ingestion;
DROP FUNCTION IF EXISTS update_document_from_ingestion();
At the END of the down file (before the final table drops), add the trigger function back verbatim from resources/migrations/init.sql:276-304:
-- Recreate the dropped trigger
CREATE OR REPLACE FUNCTION update_document_from_ingestion()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.status = 'completed' AND OLD.status = 'in-progress' THEN
UPDATE document
SET
type = 'invoice',
structured_data = NEW.structured_data,
needs_human_review = (
NOT COALESCE(NEW.valid_structured_data, true)
OR EXISTS (
SELECT 1 FROM jsonb_each(NEW.structured_data->'validation-results') AS v(k, val)
WHERE val->>'status' = 'error'
)
),
updated_at = now()
WHERE id = NEW.document_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_update_document_from_ingestion
AFTER UPDATE ON ingestion
FOR EACH ROW
EXECUTE FUNCTION update_document_from_ingestion();
Note: the trigger references NEW.structured_data — it will only work after a rollback if ingestion.structured_data still contains data (which it will for pre-migration rows). For new ingestions made against the new code and then rolled back, the column will be NULL and the trigger is a no-op.
First seed a document with line items (or use any existing one). Then:
cd orcha && clj -X:dev com.getorcha.db.migrations/rollback
cd orcha && clj -X:dev com.getorcha.db.migrations/migrate
psql -h localhost -U postgres -d orcha -c "SELECT id, jsonb_path_query_array(structured_data, '\$.\"line-items\"[*].id') AS item_ids FROM document WHERE structured_data ? 'line-items' LIMIT 3" -x
psql -h localhost -U postgres -d orcha -c "SELECT document_id, change_type, jsonb_array_length(patch) AS ops FROM document_history LIMIT 5" -x
Expected: line-items have UUID ids; each document with structured_data has at least one history row of change_type='ingestion' with 1-op patch.
git add resources/migrations/*-add-document-history.*
git commit -m "migration: backfill document_history and drop the ingestion-to-document trigger"
Files:
src/com/getorcha/json_patch/path.clj.test/com/getorcha/json_patch/path_test.clj.Design: callers in view code specify paths as Clojure vectors. Segments are keywords (object keys), ints (array indices), or maps of the form {:id "<uuid>"} (array-by-id lookup — rendered as [id=X] appended to the preceding segment). A leading :structured-data is stripped for convenience.
(ns com.getorcha.json-patch.path-test
(:require [clojure.test :refer [deftest is testing]]
[com.getorcha.json-patch.path :as path]))
(deftest clj-path->pointer-test
(testing "empty path (root)"
(is (= "" (path/clj-path->pointer []))))
(testing "scalar key"
(is (= "/invoice-number"
(path/clj-path->pointer [:invoice-number]))))
(testing "nested scalar"
(is (= "/issuer/name"
(path/clj-path->pointer [:issuer :name]))))
(testing "leading :structured-data is stripped"
(is (= "/invoice-number"
(path/clj-path->pointer [:structured-data :invoice-number]))))
(testing "numeric array index"
(is (= "/line-items/0/amount"
(path/clj-path->pointer [:line-items 0 :amount]))))
(testing "array-by-id segment attaches to preceding key"
(is (= "/line-items[id=li-abc]/amount"
(path/clj-path->pointer [:line-items {:id "li-abc"} :amount]))))
(testing "array-by-id at the tail"
(is (= "/line-items[id=li-abc]"
(path/clj-path->pointer [:line-items {:id "li-abc"}]))))
(testing "deeply nested with :structured-data prefix and id lookup"
(is (= "/line-items[id=li-abc]/debit-account/number"
(path/clj-path->pointer [:structured-data :line-items {:id "li-abc"}
:debit-account :number])))))
(deftest pointer->clj-path-test
(testing "empty (root)"
(is (= [] (path/pointer->clj-path ""))))
(testing "scalar"
(is (= [:invoice-number] (path/pointer->clj-path "/invoice-number"))))
(testing "nested scalar"
(is (= [:issuer :name] (path/pointer->clj-path "/issuer/name"))))
(testing "array-by-id segment"
(is (= [:line-items {:id "li-abc"} :amount]
(path/pointer->clj-path "/line-items[id=li-abc]/amount"))))
(testing "numeric array index"
(is (= [:line-items 0 :amount]
(path/pointer->clj-path "/line-items/0/amount")))))
cd orcha && clj -X:test:silent :nses '[com.getorcha.json-patch.path-test]' 2>&1 | grep -E "(FAIL|ERROR|Ran)"
(ns com.getorcha.json-patch.path
"Bidirectional conversion between Clojure vector paths and JSON Pointer
strings extended with `[id=X]` array-by-id segments.
Vector form: `[:structured-data :line-items {:id \"li-abc\"} :amount]`
Pointer: `/line-items[id=li-abc]/amount`
Leading `:structured-data` in the vector form is stripped — the patches
operate on the `structured-data` value directly."
(:require [clojure.string :as string]))
(defn ^:private segment->pointer-piece
[seg]
(cond
(keyword? seg) (name seg)
(int? seg) (str seg)
:else (throw (ex-info "unsupported path segment" {:segment seg}))))
(defn clj-path->pointer
"Returns the JSON Pointer string for `path`. Returns `\"\"` for root."
[path]
(let [segs (if (= :structured-data (first path)) (rest path) path)]
(if (empty? segs)
""
(loop [[seg & more] segs
out ""]
(cond
(nil? seg)
out
(and (map? seg) (contains? seg :id))
(recur more (str out "[id=" (:id seg) "]"))
:else
(recur more (str out "/" (segment->pointer-piece seg))))))))
(defn ^:private parse-pointer-segment
"Parses a single segment like `line-items[id=li-abc]` into
`[:line-items {:id \"li-abc\"}]` or simply `[:plain-key]`."
[piece]
(if-let [[_ base id] (re-matches #"([^\[]+)\[id=([^\]]+)\]" piece)]
[(keyword base) {:id id}]
[(if (re-matches #"\d+" piece)
(parse-long piece)
(keyword piece))]))
(defn pointer->clj-path
"Parses a JSON Pointer `pointer` (with optional `[id=X]` segments)
into a Clojure vector path."
[pointer]
(if (= "" pointer)
[]
(into [] (mapcat parse-pointer-segment)
(rest (string/split pointer #"/")))))
cd orcha && clj -X:test:silent :nses '[com.getorcha.json-patch.path-test]' 2>&1 | grep -E "(FAIL|ERROR|Ran)"
Expected: Ran N tests … 0 failures, 0 errors.
clj-kondo --lint src/com/getorcha/json_patch test/com/getorcha/json_patch
git add src/com/getorcha/json_patch test/com/getorcha/json_patch
git commit -m "feat: json-patch path helper"
[id=X] extension)Files:
src/com/getorcha/json_patch.clj.test/com/getorcha/json_patch_test.clj.Design: one entry point, apply-patch, that takes a document value and a patch (vector of ops, each a map with "op", "path", and "value"/"from" as needed) and returns the new value — or throws a typed exception on invalid input.
Supported ops: "replace", "add", "remove". No other ops for MVP (no "move", "copy", "test"). Apply rules per RFC 6902, except array lookup by [id=X] rather than numeric index when the path segment is in that form.
(ns com.getorcha.json-patch-test
(:require [clojure.test :refer [deftest is testing]]
[com.getorcha.json-patch :as jp]))
(deftest replace-scalar
(testing "at root"
(is (= {:a 2}
(jp/apply-patch {:a 1}
[{"op" "replace" "path" "" "value" {:a 2}}]))))
(testing "nested scalar"
(is (= {:invoice-number "INV-99"}
(jp/apply-patch {:invoice-number "INV-0"}
[{"op" "replace" "path" "/invoice-number"
"value" "INV-99"}]))))
(testing "missing path raises :path-not-found"
(is (thrown-with-msg? clojure.lang.ExceptionInfo
#"path-not-found"
(jp/apply-patch {}
[{"op" "replace"
"path" "/nope"
"value" 1}])))))
(deftest replace-array-by-id
(let [doc {:line-items [{:id "a" :amount 100}
{:id "b" :amount 200}]}]
(testing "replace a sub-field by id"
(is (= {:line-items [{:id "a" :amount 100}
{:id "b" :amount 999}]}
(jp/apply-patch doc
[{"op" "replace"
"path" "/line-items[id=b]/amount"
"value" 999}]))))
(testing "id not resolvable raises :id-not-resolvable"
(is (thrown-with-msg? clojure.lang.ExceptionInfo
#"id-not-resolvable"
(jp/apply-patch doc
[{"op" "replace"
"path" "/line-items[id=zz]/amount"
"value" 1}]))))))
(deftest add-to-array
(testing "append via `-`"
(is (= {:line-items [{:id "a"} {:id "b"}]}
(jp/apply-patch {:line-items [{:id "a"}]}
[{"op" "add"
"path" "/line-items/-"
"value" {:id "b"}}])))))
(deftest remove-by-id
(is (= {:line-items [{:id "a"}]}
(jp/apply-patch {:line-items [{:id "a"} {:id "b"}]}
[{"op" "remove"
"path" "/line-items[id=b]"}]))))
(deftest multi-op-patch
(testing "reorder via multiple replaces"
(is (= {:line-items [{:id "a" :order 1}
{:id "b" :order 0}]}
(jp/apply-patch {:line-items [{:id "a" :order 0}
{:id "b" :order 1}]}
[{"op" "replace"
"path" "/line-items[id=a]/order"
"value" 1}
{"op" "replace"
"path" "/line-items[id=b]/order"
"value" 0}])))))
(deftest root-replace-for-ingestion
(let [new-value {:invoice-number "INV-1" :line-items [{:id "x"}]}]
(is (= new-value
(jp/apply-patch {:invoice-number "old"}
[{"op" "replace" "path" "" "value" new-value}])))))
cd orcha && clj -X:test:silent :nses '[com.getorcha.json-patch-test]' 2>&1 | grep -E "(FAIL|ERROR|Ran)"
(ns com.getorcha.json-patch
"In-house applier for RFC 6902 JSON Patch with one extension: array
elements may be addressed by `[id=X]` instead of numeric index.
Supported ops: `replace`, `add`, `remove`. Throws `ex-info` with
`:type` of `:path-not-found`, `:id-not-resolvable`, `:unsupported-op`,
or `:bad-patch` on failures."
(:require [clojure.string :as string]))
(defn ^:private resolve-segment
"Given a value and a parsed segment, returns the next value or throws."
[val seg]
(cond
(and (map? seg) (contains? seg :id))
(if-let [idx (some (fn [[i m]] (when (= (:id seg) (get m "id")) i))
(map-indexed vector val))]
[idx (nth val idx)]
(throw (ex-info "id-not-resolvable"
{:type :id-not-resolvable :segment seg})))
(keyword? seg)
(let [k (name seg)]
(if (contains? val k)
[k (get val k)]
(throw (ex-info "path-not-found"
{:type :path-not-found :segment seg}))))
(int? seg)
(if (<= 0 seg (dec (count val)))
[seg (nth val seg)]
(throw (ex-info "path-not-found"
{:type :path-not-found :segment seg})))
:else
(throw (ex-info "bad-patch"
{:type :bad-patch :segment seg}))))
(defn ^:private parse-segments
"Parses a JSON Pointer to a vector of segment parsers usable by
`resolve-segment`. Uses the same logic as the path helper but is
inlined here to avoid a circular require."
[pointer]
(if (= "" pointer)
[]
(into []
(mapcat (fn [piece]
(if-let [[_ base id] (re-matches #"([^\[]+)\[id=([^\]]+)\]" piece)]
[(keyword base) {:id id}]
[(if (re-matches #"\d+" piece)
(parse-long piece)
(keyword piece))])))
(rest (string/split pointer #"/")))))
(defn ^:private assoc-at
"Recursively replaces the value at `segs` inside `val` with `new-val`."
[val segs new-val]
(if (empty? segs)
new-val
(let [[seg & more] segs
[k _] (resolve-segment val seg)]
(cond
(vector? val) (assoc val k (assoc-at (nth val k) more new-val))
(map? val) (assoc val k (assoc-at (get val k) more new-val))))))
(defn ^:private dissoc-at
"Removes the element/key at `segs` inside `val`."
[val segs]
(let [[seg & more] segs
[k _] (resolve-segment val seg)]
(cond
(empty? more)
(cond
(vector? val) (into (subvec val 0 k) (subvec val (inc k)))
(map? val) (dissoc val k))
(vector? val)
(assoc val k (dissoc-at (nth val k) more))
(map? val)
(assoc val k (dissoc-at (get val k) more)))))
(defn ^:private apply-add
[val pointer added]
(cond
;; "/line-items/-" appends to the array at that parent.
(string/ends-with? pointer "/-")
(let [parent-pointer (subs pointer 0 (- (count pointer) 2))
parent-segs (parse-segments parent-pointer)]
(assoc-at val parent-segs
(conj (get-in val (mapv (fn [s]
(if (keyword? s) (name s) s))
parent-segs))
added)))
:else
(assoc-at val (parse-segments pointer) added)))
(defn ^:private apply-op
[val {:strs [op path value] :as operation}]
(case op
"replace"
(let [segs (parse-segments path)]
(if (empty? segs)
value
(assoc-at val segs value)))
"add"
(apply-add val path value)
"remove"
(dissoc-at val (parse-segments path))
(throw (ex-info "unsupported-op"
{:type :unsupported-op :op op :operation operation}))))
(defn apply-patch
"Applies a vector of RFC 6902-ish ops to `document-value` and returns
the new value. Ops are applied in order; the result of one op is the
input to the next. Throws on any failure — callers should catch
`ExceptionInfo` with `(:type (ex-data e))`."
[document-value patch]
(reduce apply-op document-value patch))
cd orcha && clj -X:test:silent :nses '[com.getorcha.json-patch-test]' 2>&1 | grep -E "(FAIL|ERROR|Ran)"
clj-kondo --lint src/com/getorcha/json_patch.clj test/com/getorcha/json_patch_test.clj
git add src/com/getorcha/json_patch.clj test/com/getorcha/json_patch_test.clj
git commit -m "feat: json-patch applier with [id=X] array extension"
document_history schema + DB helpersFiles:
Create: src/com/getorcha/schema/document_history.clj.
Create: src/com/getorcha/db/document_history.clj.
Create: test/com/getorcha/db/document_history_test.clj.
Step 1: Write the Malli schema.
(ns com.getorcha.schema.document-history
"Schema for document_history rows — audit log of ingestion completions
and user edits on document.structured_data."
(:require [com.getorcha.schema.document :as schema.document]
[com.getorcha.schema.ingestion :as schema.ingestion]
[malli.core :as m]))
(def ChangeType [:enum :ingestion :edit])
(def DocumentHistory
(m/schema
[:map
[:document-history/id :uuid]
[:document-history/document-id schema.document/ID]
[:document-history/change-type ChangeType]
[:document-history/ingestion-id [:maybe schema.ingestion/ID]]
[:document-history/edited-by [:maybe :uuid]]
[:document-history/patch [:vector :map]]
[:document-history/created-at inst?]]))
(ns com.getorcha.db.document-history-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[com.getorcha.db :as db]
[com.getorcha.db.document-history :as dh]
[com.getorcha.db.sql :as db.sql]
[com.getorcha.test.fixtures :as fixtures]))
(use-fixtures :once fixtures/system-fixture)
(use-fixtures :each fixtures/db-transaction-fixture)
(defn ^:private seed-document!
[]
(let [le-id (db.sql/execute-one!
fixtures/*db*
{:select [:id] :from [:legal-entity] :limit 1})
id (random-uuid)]
(db.sql/execute-one!
fixtures/*db*
{:insert-into :document
:values [{:id id
:legal-entity-id (:legal-entity/id le-id)
:content-hash (str "test-" id)
:file-path "x.pdf"
:structured-data [:cast (cheshire.core/generate-string
{:document-type "invoice"
:invoice-number "INV-1"})
:jsonb]}]})
id))
(deftest insert-and-fetch-history
(testing "inserting an edit row with patch, then fetching newest→oldest"
(let [doc-id (seed-document!)
identity-id (:identity/id (db.sql/execute-one!
fixtures/*db*
{:select [:id] :from [:identity] :limit 1}))]
(dh/insert! fixtures/*db*
{:document-id doc-id
:change-type :edit
:edited-by identity-id
:patch [{"op" "replace"
"path" "/invoice-number"
"value" "INV-2"}]})
(let [rows (dh/rows-for-document fixtures/*db* doc-id)]
(is (= 1 (count rows)))
(is (= :edit (:document-history/change-type (first rows))))
(is (= [{"op" "replace" "path" "/invoice-number" "value" "INV-2"}]
(:document-history/patch (first rows))))))))
cd orcha && clj -X:test:silent :nses '[com.getorcha.db.document-history-test]' 2>&1 | grep -E "(FAIL|ERROR|Ran)"
(ns com.getorcha.db.document-history
"Query and insert helpers for document_history."
(:require [cheshire.core :as json]
[com.getorcha.db :as db]
[com.getorcha.db.sql :as db.sql]
[com.getorcha.schema.document-history :as schema.document-history]))
(def ^:private row-builder
(db/schema-row-builder schema.document-history/DocumentHistory))
(defn insert!
"Inserts a history row. `row` keys: :document-id, :change-type
(:ingestion or :edit), :ingestion-id or :edited-by (exactly one),
:patch (vector of op maps)."
[db-pool {:keys [document-id change-type ingestion-id edited-by patch]}]
(db.sql/execute-one!
db-pool
{:insert-into :document-history
:values [{:document-id document-id
:change-type [:cast (name change-type)
:document-history-change-type]
:ingestion-id ingestion-id
:edited-by edited-by
:patch [:cast (json/generate-string patch) :jsonb]}]}))
(defn rows-for-document
"Returns all history rows for a document, ordered newest first."
[db-pool document-id]
(db.sql/execute!
db-pool
{:select [:*]
:from [:document-history]
:where [:= :document-id document-id]
:order-by [[:created-at :desc]]}
{:builder-fn row-builder}))
cd orcha && clj -X:test:silent :nses '[com.getorcha.db.document-history-test]' 2>&1 | grep -E "(FAIL|ERROR|Ran)"
clj-kondo --lint src/com/getorcha/db/document_history.clj src/com/getorcha/schema/document_history.clj test/com/getorcha/db/document_history_test.clj
git add src/com/getorcha/schema/document_history.clj src/com/getorcha/db/document_history.clj test/com/getorcha/db/document_history_test.clj
git commit -m "feat: document_history schema and db helpers"
:id/:order during ingestionFiles:
Modify: src/com/getorcha/workers/ap/ingestion.clj — add an annotate-line-items fn, call it as the last post-processing step (immediately before schema validation).
Modify: test/com/getorcha/workers/ap/ingestion_test.clj — add a test for the annotation.
Step 1: Locate the post-processing pipeline.
Search for the final validate call (where schema validation runs, around the current line ~340 per the earlier grep):
grep -n "validate\|structured-data" src/com/getorcha/workers/ap/ingestion.clj | head -20
Find the function that runs validation (the function body including (update ingestion :structured-data validation/validate legal-entity)). The annotation must happen before this call.
Add a private function near the top of the file (after the ns form):
(defn ^:private annotate-line-items
"Adds :id (random UUID string) and :order (0-based index) to every
line item in structured-data. Idempotent: existing ids and orders
are preserved."
[structured-data]
(if (seq (:line-items structured-data))
(update structured-data :line-items
(fn [items]
(vec (map-indexed
(fn [idx item]
(-> item
(update :id #(or % (str (random-uuid))))
(update :order #(or % idx))))
items))))
structured-data))
Locate the validation step in the pipeline. Replace
(update ingestion :structured-data validation/validate legal-entity)
with
(-> ingestion
(update :structured-data annotate-line-items)
(update :structured-data validation/validate legal-entity))
If the actual structure differs, insert the annotate-line-items call directly before whatever invokes validation, threading through the same structured-data value.
Append to test/com/getorcha/workers/ap/ingestion_test.clj:
(deftest annotate-line-items-assigns-id-and-order
(testing "line-items without id or order get both"
(let [out (#'com.getorcha.workers.ap.ingestion/annotate-line-items
{:line-items [{:description "a"}
{:description "b"}]})]
(is (every? (fn [{:keys [id order]}]
(and (string? id) (int? order)))
(:line-items out)))
(is (= [0 1] (mapv :order (:line-items out))))))
(testing "pre-existing ids are preserved"
(let [out (#'com.getorcha.workers.ap.ingestion/annotate-line-items
{:line-items [{:id "already-set" :description "a"}]})]
(is (= "already-set" (-> out :line-items first :id))))))
cd orcha && clj -X:test:silent :nses '[com.getorcha.workers.ap.ingestion-test]' 2>&1 | grep -A 3 -E "(FAIL|ERROR|Ran)"
Expected: the two new tests pass. Pre-existing ingestion tests that construct fixtures without :id/:order may still fail at the schema step — those are fixed in Task 10.
clj-kondo --lint src/com/getorcha/workers/ap/ingestion.clj test/com/getorcha/workers/ap/ingestion_test.clj
git add src/com/getorcha/workers/ap/ingestion.clj test/com/getorcha/workers/ap/ingestion_test.clj
git commit -m "feat(ingestion): annotate line-items with :id and :order"
Files:
Modify: src/com/getorcha/workers/ap/ingestion.clj — the completion write.
Modify: test/com/getorcha/workers/ap/ingestion_test.clj — assertions now read from document + document_history.
Step 1: Find the current UPDATE ingestion completion call.
grep -n "update.*ingestion.*status\|:status.*completed\|next-jdbc\|with-transaction" src/com/getorcha/workers/ap/ingestion.clj | head
Find the function that finalizes an ingestion (where it sets status = :completed). In the current code this is a single UPDATE ingestion that relies on the trigger to copy structured_data into document.
Replace the existing finalize block with:
(defn ^:private derive-needs-human-review?
[structured-data valid-structured-data?]
(or (not (boolean valid-structured-data?))
(some (fn [[_ v]] (= "error" (get v :status)))
(:validation-results structured-data))))
(defn ^:private finalize-ingestion!
"Commits a successful ingestion: writes document_history row, updates
document.structured_data + version + type + needs_human_review, and
marks the ingestion row completed — all in one transaction."
[db-pool {:ap-ingestion/keys [id document-id structured-data valid-structured-data] :as _ingestion}]
(jdbc/with-transaction [tx db-pool]
(jdbc.sql/execute! tx {:select [:version]
:from [:document]
:where [:= :id document-id]
:for :update})
(com.getorcha.db.document-history/insert!
tx
{:document-id document-id
:change-type :ingestion
:ingestion-id id
:patch [{"op" "replace"
"path" ""
"value" structured-data}]})
(db.sql/execute-one!
tx
{:update :document
:set {:structured-data [:cast (cheshire.core/generate-string structured-data)
:jsonb]
:type [:cast (name (keyword (:document-type structured-data)))
:document-type]
:needs-human-review (derive-needs-human-review?
structured-data valid-structured-data)
:version [:raw "version + 1"]
:updated-at (java.time.Instant/now)}
:where [:= :id document-id]})
(db.sql/execute-one!
tx
{:update :ingestion
:set {:status [:cast "completed" :ingestion-status]
:completed-at (java.time.Instant/now)}
:where [:= :id id]})))
Add the requires needed at the top (alphabetized):
[cheshire.core]
[com.getorcha.db.document-history]
[next.jdbc :as jdbc]
Call finalize-ingestion! at the end of a successful pipeline in place of the previous single-UPDATE. Ensure structured-data and valid-structured-data are the already-annotated, validated values.
In test/com/getorcha/workers/ap/ingestion_test.clj, wherever tests build a :line-items vector for fixtures, add :id (str (random-uuid)) :order <idx> to each item. Also, where tests previously asserted against :ap-ingestion/structured-data, switch to:
(let [doc (db.sql/execute-one!
fixtures/*db*
{:select [:*] :from [:document] :where [:= :id doc-id]}
{:builder-fn shared/document-builder-fn})]
(is (= "INV-99" (-> doc :document/structured-data :invoice-number)))
(is (= 2 (:document/version doc))))
(let [history (com.getorcha.db.document-history/rows-for-document fixtures/*db* doc-id)]
(is (= 1 (count history)))
(is (= :ingestion (-> history first :document-history/change-type))))
cd orcha && clj -X:test:silent :nses '[com.getorcha.workers.ap.ingestion-test]' 2>&1 | grep -A 5 -E "(FAIL|ERROR|Ran)"
Expected: all green. If specific tests still reference ap-ingestion/structured-data, migrate each.
clj-kondo --lint src/com/getorcha/workers/ap/ingestion.clj test/com/getorcha/workers/ap/ingestion_test.clj
git add src/com/getorcha/workers/ap/ingestion.clj test/com/getorcha/workers/ap/ingestion_test.clj
git commit -m "feat(ingestion): transactional completion writes document_history"
structured-data / valid-structured-data from the ingestion Malli schemaFiles:
Modify: src/com/getorcha/schema/ingestion.clj:51-52.
Step 1: Remove the two fields.
Delete these lines from the Ingestion schema:
[:ap-ingestion/structured-data [:maybe schema.structured-data/StructuredData]]
[:ap-ingestion/valid-structured-data [:maybe :boolean]]
Also remove the require for schema.structured-data if it becomes unused.
clj-kondo --lint src/com/getorcha/schema/ingestion.clj
cd orcha && clj -X:test:silent 2>&1 | grep -E "(FAIL|ERROR|Ran .* tests)"
Expected: no new failures. Any existing test that reads :ap-ingestion/structured-data should be updated to read :document/structured-data instead (see Task 10 Step 3 for the pattern).
git add src/com/getorcha/schema/ingestion.clj
git commit -m "schema(ingestion): remove structured-data fields (column kept per PENDING-CLEANUPS)"
document-provenance helperFiles:
Create: src/com/getorcha/app/http/documents/view/provenance.clj.
Create: test/com/getorcha/app/http/documents/view/provenance_test.clj.
Step 1: Write failing tests.
(ns com.getorcha.app.http.documents.view.provenance-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[com.getorcha.app.http.documents.view.provenance :as provenance]
[com.getorcha.db.document-history :as dh]
[com.getorcha.db.sql :as db.sql]
[com.getorcha.test.fixtures :as fixtures]))
(use-fixtures :once fixtures/system-fixture)
(use-fixtures :each fixtures/db-transaction-fixture)
(defn ^:private seed-doc-with-ingestion!
"Inserts a document, an ingestion row, and a history 'ingestion' row.
Returns {:doc-id, :ingestion-id, :identity-id}."
[structured-data]
(let [le-id (:legal-entity/id
(db.sql/execute-one! fixtures/*db*
{:select [:id] :from [:legal-entity] :limit 1}))
identity-id (:identity/id
(db.sql/execute-one! fixtures/*db*
{:select [:id] :from [:identity] :limit 1}))
doc-id (random-uuid)
ingestion-id (random-uuid)]
(db.sql/execute-one!
fixtures/*db*
{:insert-into :document
:values [{:id doc-id
:legal-entity-id le-id
:content-hash (str "hash-" doc-id)
:file-path "x.pdf"
:version 1
:structured-data [:cast (cheshire.core/generate-string structured-data)
:jsonb]}]})
(db.sql/execute-one!
fixtures/*db*
{:insert-into :ingestion
:values [{:id ingestion-id
:document-id doc-id
:uploaded-by identity-id
:status [:cast "completed" :ingestion-status]}]})
(dh/insert! fixtures/*db*
{:document-id doc-id
:change-type :ingestion
:ingestion-id ingestion-id
:patch [{"op" "replace" "path" "" "value" structured-data}]})
{:doc-id doc-id :ingestion-id ingestion-id :identity-id identity-id}))
(deftest empty-when-no-edits
(let [{:keys [doc-id]} (seed-doc-with-ingestion!
{:invoice-number "INV-1"
:line-items []})]
(is (= {} (provenance/document-provenance fixtures/*db* doc-id)))))
(deftest single-edit-returns-map
(let [{:keys [doc-id identity-id]} (seed-doc-with-ingestion!
{:invoice-number "INV-1"})]
(dh/insert! fixtures/*db*
{:document-id doc-id
:change-type :edit
:edited-by identity-id
:patch [{"op" "replace"
"path" "/invoice-number"
"value" "INV-2"}]})
(let [pm (provenance/document-provenance fixtures/*db* doc-id)]
(is (= 1 (count pm)))
(is (= identity-id (-> pm (get "/invoice-number") :edited-by))))))
(deftest newest-edit-at-path-wins
(let [{:keys [doc-id identity-id]} (seed-doc-with-ingestion!
{:invoice-number "INV-1"})]
(dh/insert! fixtures/*db*
{:document-id doc-id :change-type :edit :edited-by identity-id
:patch [{"op" "replace" "path" "/invoice-number" "value" "INV-2"}]})
(Thread/sleep 10)
(dh/insert! fixtures/*db*
{:document-id doc-id :change-type :edit :edited-by identity-id
:patch [{"op" "replace" "path" "/invoice-number" "value" "INV-3"}]})
(let [{:keys [edited-at]} (get (provenance/document-provenance fixtures/*db* doc-id)
"/invoice-number")]
(is (some? edited-at)))))
(deftest walker-stops-at-most-recent-ingestion
(let [{:keys [doc-id ingestion-id identity-id]}
(seed-doc-with-ingestion! {:invoice-number "INV-1"})]
;; Pre-ingestion edit (older) — should be ignored.
(dh/insert! fixtures/*db*
{:document-id doc-id :change-type :edit :edited-by identity-id
:patch [{"op" "replace" "path" "/invoice-number" "value" "IGNORED"}]})
;; Then an ingestion row (fresh extraction) — supersedes the edit above.
(dh/insert! fixtures/*db*
{:document-id doc-id :change-type :ingestion :ingestion-id ingestion-id
:patch [{"op" "replace" "path" "" "value" {:invoice-number "INV-2"}}]})
(is (= {} (provenance/document-provenance fixtures/*db* doc-id)))))
Implement the seed-doc-with-ingestion! helper (inline at Step 2 of this task) using direct SQL inserts into document, ingestion, identity, and document_history.
(ns com.getorcha.app.http.documents.view.provenance
"Computes a path → {:edited-by, :edited-at} map for a document's
structured-data, by walking document_history rows since the most
recent change_type='ingestion'."
(:require [com.getorcha.db.document-history :as dh]))
(defn document-provenance
"Returns {json-pointer-path-string → {:edited-by, :edited-at}} for every
path with a human edit since the most recent ingestion for this document.
Paths absent from the map are implicitly LLM-sourced."
[db-pool document-id]
(let [rows (dh/rows-for-document db-pool document-id)
post-ingest (take-while #(not= :ingestion
(:document-history/change-type %))
rows)]
(reduce
(fn [acc {:document-history/keys [patch edited-by created-at]}]
(reduce (fn [acc' op]
(let [path (get op "path")]
(if (contains? acc' path)
acc'
(assoc acc' path {:edited-by edited-by
:edited-at created-at}))))
acc
patch))
{}
(reverse post-ingest))))
cd orcha && clj -X:test:silent :nses '[com.getorcha.app.http.documents.view.provenance-test]' 2>&1 | grep -A 3 -E "(FAIL|ERROR|Ran)"
clj-kondo --lint src/com/getorcha/app/http/documents/view/provenance.clj test/com/getorcha/app/http/documents/view/provenance_test.clj
git add src/com/getorcha/app/http/documents/view/provenance.clj test/com/getorcha/app/http/documents/view/provenance_test.clj
git commit -m "feat: document-provenance walker"
editable-value converts vector paths and accepts :provenanceFiles:
Modify: src/com/getorcha/app/ui/components.clj — update editable-value, require the path helper.
Modify: resources/app/public/css/style.css — add .is-human-edited rules.
Step 1: Update editable-value.
Find the current editable-value in components.clj (around line 542 after the merge). Replace with:
(defn editable-value
"Wraps a display value in a span that client JS turns into an inline editor.
path - Clojure vector of keywords/maps, e.g.
`[:structured-data :invoice-number]` or
`[:structured-data :line-items {:id \"li-abc\"} :amount]`.
Leading `:structured-data` is stripped before conversion.
field-type - :text | :number | :currency | :date | :enum | :multiline
opts - {:raw-value ... :currency ... :options ... :class ...
:provenance {:edited-by <uuid> :edited-at <inst>}}
display - the hiccup currently rendered for this value."
[path field-type {:keys [raw-value currency options class provenance] :as _opts} display]
(let [pointer (com.getorcha.json-patch.path/clj-path->pointer path)
classes (cond-> "editable-value"
(some? class) (str " " class)
(some? provenance) (str " is-human-edited"))]
[:span
(cond-> {:class classes
:data-field-path pointer
:data-field-type (name field-type)}
(some? raw-value) (assoc :data-raw-value (str raw-value))
(some? currency) (assoc :data-currency currency)
(seq options) (assoc :data-field-options (json/generate-string options))
(some? provenance) (assoc :title
(str "Edited at "
(str (:edited-at provenance)))))
display]))
Add to the ns form's :require:
[com.getorcha.json-patch.path]
Append to resources/app/public/css/style.css:
.editable-value.is-human-edited {
box-shadow: inset 0 -1px 0 rgba(63, 185, 80, 0.6);
}
.edit-error-banner {
display: block;
margin-top: 4px;
padding: 6px 8px;
background-color: rgba(248, 81, 73, 0.1);
border: 1px solid rgba(248, 81, 73, 0.4);
border-radius: 4px;
color: #f85149;
font-size: 12px;
line-height: 1.4;
}
.line-items-sortable .drag-handle {
cursor: grab;
color: #6e7681;
padding: 0 8px;
}
.line-items-sortable .drag-handle:active {
cursor: grabbing;
}
clj-kondo --lint src/com/getorcha/app/ui/components.clj
git add src/com/getorcha/app/ui/components.clj resources/app/public/css/style.css
git commit -m "feat(ui): editable-value accepts :provenance, converts vector paths"
Files:
Modify: src/com/getorcha/app/http/documents/view/shared.clj — add data-document-version on the detail container and pre-compute the provenance map.
Modify: src/com/getorcha/app/http/documents/view/invoice.clj (and siblings) — accept provenance-map where invoice components are called, thread it down. For MVP, propagating only into invoice (the only actively editable doc type) is fine; other view ns's can take it as an unused param or not at all.
Step 1: Locate the detail-container rendering.
grep -n "detail-container\|document-detail" src/com/getorcha/app/http/documents/view/shared.clj
Find the hiccup vector that emits the outer .detail-container div.
In the shared detail-container builder:
(defn detail-container
[{:keys [db-pool document] :as _request} body]
(let [provenance-map (com.getorcha.app.http.documents.view.provenance/document-provenance
db-pool (:document/id document))]
[:div.detail-container
{:data-document-version (:document/version document)
:data-document-id (:document/id document)}
(body provenance-map)]))
Adjust signature to match the current function; the key points are: include data-document-version and data-document-id on the wrapper, and pass provenance-map into the inner body-rendering function.
Require at the top:
[com.getorcha.app.http.documents.view.provenance]
provenance-map into component calls that render editables.In invoice.clj, locate the calls to invoice-header, line-items-table, line-item-card, party-card, etc. Update the signatures of those components in components.clj to accept a :provenance-map kwarg, and pass it into each edit helper's map like:
(let [edit (fn [k type raw]
{:path [:structured-data k]
:type type
:raw-value raw
:provenance (get provenance-map
(com.getorcha.json-patch.path/clj-path->pointer
[:structured-data k]))})]
...)
The existing labeled-field / value-row / party-card already forward the :editable map as opts. Add :provenance to the merge so it reaches editable-value.
cd orcha && clj-nrepl-eval -p <PORT> "(integrant.repl/reset)"
# In a browser, open a document with edits backfilled (Task 5). Open DevTools → Inspect the field. Confirm class `is-human-edited` and a `title` attr.
clj-kondo --lint src/com/getorcha/app src/com/getorcha/app/http/documents/view
git add src/com/getorcha/app/ui/components.clj src/com/getorcha/app/http/documents/view
git commit -m "feat(ui): thread document version + provenance into detail view"
Files:
Create: src/com/getorcha/app/http/documents/edits.clj.
Modify: src/com/getorcha/app/http/documents.clj — add the sub-route mount.
Create: test/com/getorcha/app/http/documents/edits_test.clj.
Step 1: Scaffold the handler namespace.
(ns com.getorcha.app.http.documents.edits
"Edit endpoints for document structured_data.
All endpoints share one core flow: check version, apply patch, write
document_history row, update document, bump version — all in one
transaction. Responses are HTML fragments; status code selects the
variant and HTMX swaps whatever came back."
(:require [cheshire.core :as json]
[com.getorcha.app.http.documents.shared :as documents.shared]
[com.getorcha.app.http.documents.view.provenance :as provenance]
[com.getorcha.app.http.identity :as app.http.identity]
[com.getorcha.app.ui.components :as ui]
[com.getorcha.db.document-history :as dh]
[com.getorcha.db.sql :as db.sql]
[com.getorcha.json-patch :as jp]
[com.getorcha.json-patch.path :as jp.path]
[hiccup2.core :as hiccup]
[next.jdbc :as jdbc]
[ring.util.http-response :as ring.resp]))
(defn ^:private fetch-document-for-update
[tx document-id le-ids]
(db.sql/execute-one!
tx
{:select [:*]
:from [:document]
:where [:and
[:= :id document-id]
[:in :legal-entity-id le-ids]]
:for :update}
{:builder-fn documents.shared/document-builder-fn}))
(defn ^:private oob-version-swap [new-version]
[:div {:id "document-version-holder"
:hx-swap-oob "outerHTML"
:data-document-version new-version}])
(defn ^:private render-fragment
[status hiccup-body triggers]
(let [body (str (hiccup/html hiccup-body))]
(cond-> {:status status
:headers {"Content-Type" "text/html; charset=utf-8"}
:body body}
(seq triggers) (assoc-in [:headers "HX-Trigger"]
(json/generate-string triggers)))))
(defn ^:private conflict-fragment
[path-pointer current-value current-version]
(render-fragment
409
[:span.editable-value.is-rejected
{:data-field-path path-pointer
:data-document-version current-version}
[:span.editable-value-current current-value]
[:span.edit-error-banner
"Another user modified this field. Refresh to see the latest."]
(oob-version-swap current-version)]
{"editRejected" true}))
(defn ^:private apply-edit-tx
"Core edit flow. Options:
:patch-builder `(fn [structured-data])` returning an RFC 6902 patch vector.
:render-success `(fn [new-sd new-version])` returning hiccup for the
success fragment (before the OOB version swap is appended).
:success-status http status for success (defaults to 200).
:extra-triggers map merged into the HX-Trigger header on success (and
conflict) responses."
[db-pool identity-id document-id expected-version le-ids
{:keys [patch-builder render-success success-status extra-triggers]
:or {success-status 200 extra-triggers {}}}]
(jdbc/with-transaction [tx db-pool]
(if-let [{:document/keys [structured-data version]}
(fetch-document-for-update tx document-id le-ids)]
(cond
(not= version expected-version)
(conflict-fragment "" (pr-str structured-data) version)
:else
(let [patch (patch-builder structured-data)
new-sd (jp/apply-patch structured-data patch)
new-version (inc version)]
(dh/insert! tx {:document-id document-id
:change-type :edit
:edited-by identity-id
:patch patch})
(db.sql/execute-one!
tx
{:update :document
:set {:structured-data [:cast (json/generate-string new-sd) :jsonb]
:version new-version
:updated-at (java.time.Instant/now)}
:where [:= :id document-id]})
(render-fragment
success-status
[:span
(render-success new-sd new-version)
(oob-version-swap new-version)]
(merge {"documentEdited" true} extra-triggers))))
(render-fragment 404
[:div.edit-error-banner "Document not found."]
{"toast" {:level "error" :message "Document not found."}}))))
(defn ^:private scalar-edit-handler
[{:keys [db-pool parameters] :as request}]
(let [{:keys [document-id]} (:path parameters)
{:keys [path value expected-version]} (:form parameters)
identity-id (:identity/id (:identity request))
le-ids (mapv :legal-entity/id
(app.http.identity/legal-entities request))
clj-path (jp.path/pointer->clj-path path)]
(apply-edit-tx
db-pool identity-id document-id
(parse-long expected-version) le-ids
{:patch-builder
(fn [_sd]
[{"op" "replace" "path" path "value" value}])
:render-success
(fn [_new-sd _new-version]
;; MVP: return a bare `editable-value` span with just the new value
;; as text. The swap target is the old `.editable-value` (outerHTML),
;; so the surrounding `.field-group` / `.value-row` markup stays intact.
;; The inner display class (`.field-value`, `.value-amount`, etc.) is
;; dropped on the edited element until page reload — acceptable
;; polish trade-off for MVP. Polish follow-up: render via the original
;; component by threading the render-context key from the client.
(ui/editable-value
clj-path :text
{:provenance {:edited-by identity-id
:edited-at (java.time.Instant/now)}
:class "is-saving"}
(str value)))})))
(defn routes [_config]
["/:document-id"
["/structured-data"
{:name ::scalar-edit
:patch {:summary "Edit a scalar value in structured_data"
:parameters {:path {:document-id :uuid}
:form {:path :string
:value :string
:expected-version :string}}
:handler #'scalar-edit-handler}}]])
In src/com/getorcha/app/http/documents.clj add the require and route:
[com.getorcha.app.http.documents.edits :as edits]
Inside the routes fn's vector, add (edits/routes _config) alongside the existing (management/routes _config) etc.
(ns com.getorcha.app.http.documents.edits-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[com.getorcha.db.sql :as db.sql]
[com.getorcha.test.fixtures :as fixtures]))
(use-fixtures :once fixtures/system-fixture)
(use-fixtures :each fixtures/db-transaction-fixture)
(defn ^:private seed-editable-doc!
"Inserts identity, legal-entity membership, and a document with
version=1 and minimal structured_data. Returns
{:doc-id, :identity}. Use `fixtures/authenticated-request`
with the returned identity to make requests."
[]
(let [identity (:identity (db.sql/execute-one!
fixtures/*db*
{:select [:*] :from [:identity] :limit 1}))
le-id (:legal-entity/id
(db.sql/execute-one! fixtures/*db*
{:select [:id] :from [:legal-entity] :limit 1}))
doc-id (random-uuid)]
(db.sql/execute-one!
fixtures/*db*
{:insert-into :document
:values [{:id doc-id
:legal-entity-id le-id
:content-hash (str "hash-" doc-id)
:file-path "x.pdf"
:version 1
:structured-data [:cast (cheshire.core/generate-string
{:document-type "invoice"
:invoice-number "INV-1"
:line-items []})
:jsonb]}]})
{:doc-id doc-id :identity identity}))
(defn ^:private seed-editable-doc-with-line-items!
"Same as seed-editable-doc!, but seeds with one line item."
[]
(let [{:keys [identity] :as seed} (seed-editable-doc!)]
(db.sql/execute-one!
fixtures/*db*
{:update :document
:set {:structured-data
[:cast (cheshire.core/generate-string
{:document-type "invoice"
:invoice-number "INV-1"
:line-items [{:id (str (random-uuid))
:order 0
:description "first"
:page-location [1 1]}]})
:jsonb]}
:where [:= :id (:doc-id seed)]})
seed))
(deftest scalar-edit-success
(let [{:keys [doc-id identity]} (seed-editable-doc!)
response (fixtures/authenticated-request
identity :patch (str "/documents/" doc-id "/structured-data")
{:form-params {"path" "/invoice-number"
"value" "INV-99"
"expected-version" "1"}})]
(is (= 200 (:status response)))
(is (re-find #"INV-99" (:body response)))
(let [doc (db.sql/execute-one! fixtures/*db*
{:select [:*] :from [:document]
:where [:= :id doc-id]})]
(is (= 2 (:document/version doc))))))
(deftest scalar-edit-conflict-on-stale-version
(let [{:keys [doc-id identity]} (seed-editable-doc!)]
;; First edit — succeeds, bumps version to 2.
(fixtures/authenticated-request
identity :patch (str "/documents/" doc-id "/structured-data")
{:form-params {"path" "/invoice-number" "value" "INV-2" "expected-version" "1"}})
;; Second edit with the old version — rejected.
(let [response (fixtures/authenticated-request
identity :patch (str "/documents/" doc-id "/structured-data")
{:form-params {"path" "/invoice-number"
"value" "INV-3"
"expected-version" "1"}})]
(is (= 409 (:status response)))
(is (re-find #"edit-error-banner" (:body response))))))
(deftest scalar-edit-not-found
(let [{:keys [identity]} (seed-editable-doc!)
response (fixtures/authenticated-request
identity :patch (str "/documents/" (random-uuid) "/structured-data")
{:form-params {"path" "/invoice-number"
"value" "X" "expected-version" "1"}})]
(is (= 404 (:status response)))))
The helper seed-editable-doc! inserts an identity + legal-entity + document with a minimal structured-data (no line items — keeps the fixture small). fixtures/authenticated-request is the existing helper that wraps requests with session auth; check test/com/getorcha/test/fixtures.clj for its current signature and adapt the calls.
cd orcha && clj -X:test:silent :nses '[com.getorcha.app.http.documents.edits-test]' 2>&1 | grep -A 3 -E "(FAIL|ERROR|Ran)"
clj-kondo --lint src/com/getorcha/app/http/documents/edits.clj src/com/getorcha/app/http/documents.clj test/com/getorcha/app/http/documents/edits_test.clj
git add src/com/getorcha/app/http/documents.clj src/com/getorcha/app/http/documents/edits.clj test/com/getorcha/app/http/documents/edits_test.clj
git commit -m "feat: scalar edit endpoint with optimistic locking"
editable-fields.js commit path to htmx.ajaxFiles:
Modify: resources/app/public/js/editable-fields.js.
Step 1: Replace the commit function.
Find the existing commit function (around line 172). Replace its body with:
function commit(input) {
const el = input.closest('.editable-value');
if (!el || !el.classList.contains(EDITING)) return;
if (el.dataset.committing === '1') return;
el.dataset.committing = '1';
input.removeEventListener('keydown', onInputKeydown);
input.removeEventListener('blur', onInputBlur);
const container = document.querySelector('.detail-container');
const documentId = container?.dataset.documentId;
const expectedVersion = container?.dataset.documentVersion;
const path = el.dataset.fieldPath;
const value = readInputValue(input, el);
if (!documentId || !path) {
el.innerHTML = el.dataset.originalHtml;
el.classList.remove(EDITING);
delete el.dataset.committing;
delete el.dataset.originalHtml;
return;
}
window.htmx.ajax('PATCH', `/documents/${documentId}/structured-data`, {
values: { path, value, 'expected-version': expectedVersion },
target: el,
swap: 'outerHTML',
source: el,
}).finally(() => { delete el.dataset.committing; });
}
function readInputValue(input, el) {
// Currency keeps `.amount` span + `.currency-suffix`; only the amount is edited.
return input.value;
}
Remove the now-unused updateDisplayedText function — HTMX owns the DOM after commit.
Keep cancel, startEdit, builders, keyboard handler, and the listener setup unchanged.
Listen for OOB version updates. Add at the top level of the module (after the 'use strict' line):
document.body.addEventListener('htmx:afterOnLoad', (evt) => {
const xhr = evt.detail?.xhr;
if (!xhr) return;
// The server may also return an updated .detail-container wrapper as an OOB swap —
// HTMX handles it automatically. Nothing to do here beyond letting HTMX run.
});
(The OOB swap is handled entirely by HTMX's native OOB processing since the server response includes an element with hx-swap-oob.)
cd orcha && bb dev:start-server &
# In a browser: navigate to a document, click a field, change value, press Enter.
# DevTools Network tab: PATCH /documents/<id>/structured-data fires.
# Reload page: the new value persists.
git add resources/app/public/js/editable-fields.js
git commit -m "feat(ui): editable-fields commits via htmx.ajax"
Files:
Modify: src/com/getorcha/app/http/documents/edits.clj — add the add handler and route.
Modify: src/com/getorcha/app/ui/components.clj — emit an "+ Add line item" button in the line-items section; add a listener for HX-Trigger: focusNewItem in editable-fields.js to open the description field of the new row.
Step 1: Add the handler.
Append to edits.clj:
(defn ^:private add-line-item-handler
[{:keys [db-pool parameters] :as request}]
(let [{:keys [document-id]} (:path parameters)
{:keys [expected-version]} (:form parameters)
identity-id (:identity/id (:identity request))
le-ids (mapv :legal-entity/id
(app.http.identity/legal-entities request))
new-id (str (random-uuid))]
(apply-edit-tx
db-pool identity-id document-id
(parse-long expected-version) le-ids
{:patch-builder
(fn [sd]
(let [next-order (->> sd :line-items (map :order) (reduce max -1) inc)]
[{"op" "add"
"path" "/line-items/-"
"value" {"id" new-id
"order" next-order
"description" "New line item"
"page-location" [0 0]}}]))
:render-success
(fn [new-sd _new-version]
;; Re-render only the new row. Requires extracting a `line-item-row`
;; helper from the existing `line-items-table` — see Step 1a below.
(ui/line-item-row
(->> new-sd :line-items (filter #(= new-id (:id %))) first)
(:currency new-sd)))
:success-status 201
:extra-triggers {"focusNewItem" {"item-id" new-id}}})))
Extend the routes vector:
["/line-items"
{:name ::line-items-collection
:post {:parameters {:path {:document-id :uuid}
:form {:expected-version :string}}
:handler #'add-line-item-handler}}]
line-item-row and line-items-tbody helpers.In src/com/getorcha/app/ui/components.clj, factor the existing line-items-table body into:
(defn line-item-row
"Renders one `<tr>` for a line item. Used both by `line-items-tbody`
and by `add-line-item-handler` to re-render the newly added row.
The body is the existing `for`-loop body from `line-items-table`
(the hiccup vector starting at `[:tr ...]`) with `idx` replaced by
`(:id _item)` in the `edit` helper, and `^{:key idx}` replaced by
`^{:key (:id _item)}`. Copy it verbatim from the current file into
this function, then delete the original body from `line-items-table`."
[{:keys [id] :as _item} currency]
;; <<copied tr hiccup goes here — see docstring>>
)
(defn line-items-tbody
"Renders the `<tbody>` for the sortable line-items list. Used by the
reorder handler to return an authoritative, sorted tbody."
[items currency {:keys [document-id]}]
[:tbody.line-items-sortable
{:hx-patch (str "/documents/" document-id "/line-items")
:hx-trigger "end"
:hx-include "closest tbody"
:hx-vals "js:{'expected-version': document.querySelector('.detail-container').dataset.documentVersion}"
:hx-target "this"
:hx-swap "outerHTML"}
(for [item (sort-by :order items)]
(line-item-row item currency))])
Then update line-items-table to delegate to line-items-tbody. Run the test suite to ensure no regression in how documents currently render.
components.clj.Near the line-items section, add a button with:
[:button.add-line-item
{:hx-post (str "/documents/" document-id "/line-items")
:hx-vals "js:{'expected-version': document.querySelector('.detail-container').dataset.documentVersion}"
:hx-target "closest .line-items-tbody"
:hx-swap "beforeend"}
[:iconify-icon {:icon "lucide:plus"}]
" Add line item"]
focusNewItem in editable-fields.js.Append to the top-level init section:
document.body.addEventListener('focusNewItem', (evt) => {
const newRow = document.querySelector('.line-items-tbody > tr:last-child');
const desc = newRow?.querySelector('.editable-value[data-field-path*="/description"]');
desc?.click();
});
Append to edits_test.clj:
(deftest add-line-item-success
(let [{:keys [doc-id identity]} (seed-editable-doc-with-line-items!)
response (fixtures/authenticated-request
identity :post (str "/documents/" doc-id "/line-items")
{:form-params {"expected-version" "1"}})]
(is (= 200 (:status response)))
(let [doc (db.sql/execute-one! fixtures/*db*
{:select [:*] :from [:document]
:where [:= :id doc-id]})]
(is (= 2 (:document/version doc)))
(is (= 2 (count (-> doc :document/structured-data :line-items)))))))
Open a document, click "+ Add line item". New row appears at the end; description is in edit mode.
clj-kondo --lint src/com/getorcha/app/http/documents/edits.clj src/com/getorcha/app/ui/components.clj
git add src/com/getorcha/app/http/documents/edits.clj src/com/getorcha/app/ui/components.clj resources/app/public/js/editable-fields.js
git commit -m "feat: add line-item endpoint and + Add button"
Files:
Modify: src/com/getorcha/app/http/documents/edits.clj.
Modify: src/com/getorcha/app/ui/components.clj — delete button in line-items table/card.
Step 1: Handler.
Append to edits.clj:
(defn ^:private remove-line-item-handler
[{:keys [db-pool parameters] :as request}]
(let [{:keys [document-id item-id]} (:path parameters)
expected-version (parse-long (get-in parameters [:query :expected-version]))
identity-id (:identity/id (:identity request))
le-ids (mapv :legal-entity/id
(app.http.identity/legal-entities request))]
(apply-edit-tx
db-pool identity-id document-id expected-version le-ids
{:patch-builder
(fn [_sd]
[{"op" "remove"
"path" (str "/line-items[id=" item-id "]")}])
:render-success
(fn [_new-sd _new-version]
;; Empty success body: HTMX swaps the row target with nothing.
"")})))
Route addition:
["/line-items/:item-id"
{:name ::line-item-by-id
:delete {:parameters {:path {:document-id :uuid :item-id :string}
:query {:expected-version :string}}
:handler #'remove-line-item-handler}}]
components.clj — inside each line-item row.[:button.line-item-delete
{:hx-delete (str "/documents/" document-id "/line-items/" (:id item)
"?expected-version="
"${document.querySelector(\".detail-container\").dataset.documentVersion}")
:hx-target "closest tr"
:hx-swap "outerHTML"
:hx-confirm "Delete this line item?"}
[:iconify-icon {:icon "lucide:trash-2"}]]
(If the string-interpolation approach feels brittle, use hx-vals on a POST and map it to DELETE server-side. Simpler for Reitit: keep as query param.)
(deftest delete-line-item-success
(let [{:keys [doc-id identity]} (seed-editable-doc-with-line-items!)
item-id (-> (db.sql/execute-one! fixtures/*db*
{:select [:*] :from [:document]
:where [:= :id doc-id]})
:document/structured-data :line-items first :id)
response (fixtures/authenticated-request
identity :delete
(str "/documents/" doc-id "/line-items/" item-id
"?expected-version=1"))]
(is (= 200 (:status response)))))
clj-kondo --lint src/com/getorcha/app/http/documents/edits.clj src/com/getorcha/app/ui/components.clj
git add src/com/getorcha/app/http/documents/edits.clj src/com/getorcha/app/ui/components.clj test/com/getorcha/app/http/documents/edits_test.clj
git commit -m "feat: delete line-item endpoint and per-row button"
Files:
Modify: src/com/getorcha/app/http/documents/edits.clj.
Modify: src/com/getorcha/app/ui/components.clj — add .line-items-sortable class, hidden item-id inputs, drag handles, hx-patch on <tbody>.
Create: resources/app/public/js/vendor/Sortable.min.js — vendored SortableJS (download from https://github.com/SortableJS/Sortable/releases).
Create: resources/app/public/js/sortable-glue.js.
Modify: whatever layout loads per-page JS for the detail view — include both the Sortable vendor file and the glue file.
Step 1: Vendor SortableJS.
Download and commit Sortable.min.js to resources/app/public/js/vendor/. Source: https://github.com/SortableJS/Sortable/releases (latest stable 1.x).
resources/app/public/js/sortable-glue.js:
(function () {
'use strict';
if (!window.htmx || !window.Sortable) return;
window.htmx.onLoad((content) => {
content.querySelectorAll('.line-items-sortable').forEach((el) => {
if (el.__sortableInit) return;
el.__sortableInit = true;
new window.Sortable(el, {
animation: 150,
handle: '.drag-handle',
});
});
});
})();
In whatever hiccup builder wraps the detail view's <head> or end-of-body:
[:script {:src (str "/public/js/vendor/Sortable.min.js?v=" assets-version)}]
[:script {:src (str "/public/js/sortable-glue.js?v=" assets-version)}]
(These go alongside the existing editable-fields.js include.)
.line-items-sortable, hidden item-id inputs, drag handles to the table.In line-items-table:
[:tbody.line-items-sortable
{:hx-patch (str "/documents/" document-id "/line-items")
:hx-trigger "end"
:hx-include "closest tbody"
:hx-vals "js:{'expected-version': document.querySelector('.detail-container').dataset.documentVersion}"
:hx-target "this"
:hx-swap "outerHTML"}
(for [{:keys [id] :as item} items]
[:tr {:data-item-id id}
[:td.drag-handle [:iconify-icon {:icon "lucide:grip-vertical"}]]
;; existing cells here
[:input {:type "hidden" :name "item-id" :value id}]])]
Append to edits.clj:
(defn ^:private reorder-line-items-handler
[{:keys [db-pool parameters] :as request}]
(let [{:keys [document-id]} (:path parameters)
{:keys [expected-version]} (:form parameters)
;; `item-id` appears multiple times in the form — Reitit puts it in a vector.
item-ids (get-in parameters [:form :item-id])
item-ids-vec (if (vector? item-ids) item-ids [item-ids])
identity-id (:identity/id (:identity request))
le-ids (mapv :legal-entity/id
(app.http.identity/legal-entities request))]
(apply-edit-tx
db-pool identity-id document-id
(parse-long expected-version) le-ids
{:patch-builder
(fn [_sd]
(vec (map-indexed
(fn [idx id]
{"op" "replace"
"path" (str "/line-items[id=" id "]/order")
"value" idx})
item-ids-vec)))
:render-success
(fn [new-sd _new-version]
;; Re-render the full tbody — call the hiccup component.
(ui/line-items-tbody (:line-items new-sd)
(:currency new-sd)
{:document-id document-id}))})))
Route addition:
["/line-items"
{:patch {:parameters {:path {:document-id :uuid}
:form {:expected-version :string
:item-id [:vector :string]}}
:handler #'reorder-line-items-handler}}]
(deftest reorder-line-items-success
(let [{:keys [doc-id identity]} (seed-editable-doc-with-line-items!)
items (-> (db.sql/execute-one! fixtures/*db*
{:select [:*] :from [:document]
:where [:= :id doc-id]})
:document/structured-data :line-items)
reversed-ids (reverse (mapv :id items))
response (fixtures/authenticated-request
identity :patch (str "/documents/" doc-id "/line-items")
{:form-params (into [["expected-version" "1"]]
(map vector (repeat "item-id") reversed-ids))})]
(is (= 200 (:status response)))
(let [fresh (db.sql/execute-one! fixtures/*db*
{:select [:*] :from [:document]
:where [:= :id doc-id]})
orders-by-id (into {} (map (juxt :id :order)
(-> fresh :document/structured-data :line-items)))]
(doseq [[expected-order id] (map-indexed vector reversed-ids)]
(is (= expected-order (orders-by-id id)))))))
Open a document with 3+ line items. Drag to reorder. Refresh — order persists.
clj-kondo --lint src/com/getorcha/app/http/documents/edits.clj src/com/getorcha/app/ui/components.clj
git add src/com/getorcha/app/http/documents/edits.clj src/com/getorcha/app/ui/components.clj resources/app/public/js/ test/com/getorcha/app/http/documents/edits_test.clj
git commit -m "feat: line-item reorder with SortableJS"
scripts/debug_fetch_document.clj and debug_common.cljFiles:
Modify: scripts/debug_fetch_document.clj.
Modify: scripts/debug_common.clj.
Step 1: Add a document_history JSONB-keys constant in debug_common.clj.
(def document-history-jsonb-keys #{:patch})
And an enum-keys map:
(def document-history-enum-keys {:change-type :document_history_change_type})
Use cast-special-fields as it's used for other tables.
debug_fetch_document.clj, extend the prod query to also pull document_history.Add to the {:document, :ingestions, :export-audits} map construction:
:document-history (mapv stringify-pgobjects
(db.sql/execute!
db-pool
{:select [:*]
:from [:document-history]
:where [:= :document-id document-id]
:order-by [[:created-at :asc]]}))
Find where the script inserts ingestions locally. After that loop, insert history rows, using the new document-history-jsonb-keys + document-history-enum-keys for casting. Note: don't preserve the original history id (set DEFAULT uuidv7 on insert) unless the script cares — simpler to re-generate.
cd orcha && bb scripts:debug-fetch-document <some-prod-doc-id>
psql -h localhost -U postgres -d orcha -c "SELECT count(*) FROM document_history WHERE document_id = '<doc-id>'"
git add scripts/debug_common.clj scripts/debug_fetch_document.clj
git commit -m "scripts: debug-fetch-document copies document_history"
debug-doc and ingestion-regression-test skillsFiles:
Modify: .claude/skills/debug-doc/SKILL.md.
Modify: .claude/skills/ingestion-regression-test/inspector-prompt.md.
Step 1: debug-doc — Step 5 query rewrite.
Find in SKILL.md line ~115-124:
psql -h localhost -U postgres -d orcha -c "SELECT structured_data FROM ap_ingestion WHERE id = '<ingestion-id>'" -x
Replace with:
# Per-ingestion extracted state (was ingestion.structured_data):
psql -h localhost -U postgres -d orcha -c \
"SELECT patch FROM document_history WHERE ingestion_id = '<ingestion-id>' AND change_type = 'ingestion' LIMIT 1" -x
# Current materialized state (unchanged):
psql -h localhost -U postgres -d orcha -c \
"SELECT structured_data FROM document WHERE id = '<doc-id>'" -x
# All edits since the last ingestion:
psql -h localhost -U postgres -d orcha -c \
"WITH latest AS (SELECT MAX(created_at) AS ts FROM document_history WHERE document_id = '<doc-id>' AND change_type = 'ingestion')
SELECT edited_by, created_at, patch FROM document_history, latest
WHERE document_id = '<doc-id>' AND change_type = 'edit' AND created_at > latest.ts
ORDER BY created_at" -x
Also check ap_ingestion — the current table name is ingestion per init.sql:204. If ap_ingestion is an old alias, update all references in the skill to ingestion.
ingestion-regression-test/inspector-prompt.md — rewrite lines 38/82/89.Replace each of these queries:
psql -h localhost -U postgres -d orcha -c "SELECT structured_data, commit_sha FROM ingestion WHERE …"
with:
psql -h localhost -U postgres -d orcha -c \
"SELECT (patch->0->>'value')::jsonb AS structured_data,
(SELECT commit_sha FROM ingestion i WHERE i.id = dh.ingestion_id) AS commit_sha
FROM document_history dh
WHERE dh.document_id = '{{DOC_ID}}' AND dh.change_type = 'ingestion'
ORDER BY dh.created_at DESC LIMIT 1"
(Adapt each of the three sites similarly — the baseline, the post-change state, and the comparison query all follow the same substitution pattern.)
git add .claude/skills/debug-doc/SKILL.md .claude/skills/ingestion-regression-test/inspector-prompt.md
git commit -m "skills: update debug-doc and ingestion-regression-test to read from document_history"
cd orcha && clj -X:test:silent 2>&1 | grep -E "FAIL|ERROR|Ran .* tests"
Expected: 0 failures, 0 errors.
cd orcha && clj-kondo --lint src test dev 2>&1 | tail -3
Expected: errors: 0, warnings: 0.
Against the running dev server, on a real document:
document_history got a new edit row with [id=X] in the path.document.version bumped.After Step 3, inspect an edited field via DevTools → confirm class="editable-value is-human-edited" and a title="Edited at …" attribute.
CLAUDE.md conventions — kebab-case, ^:private over defn-, destructured :as even for unused, requires alphabetized, two blank lines between top-level forms, etc. The formatter hook will enforce paren balance but not style.validation-results to be fresh, update the test; don't add recomputation.documents.clj use deep nesting. The add/edit/remove/reorder routes live under /:document-id — tag the existing sub-routes (upload, management, view) at the same level to avoid conflicts.apply-edit-tx is the choke point. If you need to change error handling or add auditing, change it there, not in each endpoint.