From f4f34c1d331a8d2154bf87eaee86e86e29d963cd Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 17:50:05 +0000 Subject: [PATCH] =?UTF-8?q?mod:=20Phase=203=20=E2=80=94=20lifecycle=20stat?= =?UTF-8?q?e=20machine=20+=20escalation=20+=20appeal,=20106/106?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure SX state machine (lib/mod/lifecycle.sx) over the engine: open→triaged→decided→appealed→final, transition table guards illegal moves. Auto-tier resolves terminal actions; escalate parks at human-tier (resolve blocked until review supplies evidence). Appeal re-runs the engine — new exonerated-keep rule at top precedence lets exoneration override a prior hide. Api façade (mod/triage/resolve/review/appeal/finalize) over a case registry, logging committed decisions to the audit trail. +46 escalation tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/api.sx | 103 ++++++++++++- lib/mod/conformance.conf | 2 + lib/mod/lifecycle.sx | 160 +++++++++++++++++++++ lib/mod/policy.sx | 7 + lib/mod/scoreboard.json | 9 +- lib/mod/scoreboard.md | 3 +- lib/mod/tests/escalation.sx | 279 ++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 38 ++++- 8 files changed, 583 insertions(+), 18 deletions(-) create mode 100644 lib/mod/lifecycle.sx create mode 100644 lib/mod/tests/escalation.sx diff --git a/lib/mod/api.sx b/lib/mod/api.sx index 88204426..245d9803 100644 --- a/lib/mod/api.sx +++ b/lib/mod/api.sx @@ -1,11 +1,13 @@ -;; lib/mod/api.sx — report registry + public entry points. +;; lib/mod/api.sx — report registry + lifecycle façade + public entry points. ;; -;; mod/report files a report (assigning a sequential id) into the in-memory -;; registry; mod/add-evidence accumulates evidence onto a filed report; -;; mod/decide resolves an id, runs the policy engine against the current registry -;; and rule set, and commits the decision to the append-only audit log. +;; mod/report files a report (assigning a sequential id) and opens a lifecycle +;; case for it; mod/add-evidence accumulates evidence; mod/decide runs the engine +;; and commits to the audit log. The lifecycle façade (mod/triage, mod/resolve, +;; mod/review, mod/appeal, mod/finalize) drives the per-report case through its +;; states, logging each committed decision to the audit trail. (define mod/*reports* (list)) +(define mod/*cases* (list)) (define mod/*counter* 0) (define mod/*rules* mod/default-rules) @@ -15,6 +17,7 @@ () (begin (set! mod/*reports* (list)) + (set! mod/*cases* (list)) (set! mod/*counter* 0) (mod/audit-reset!)))) @@ -28,7 +31,10 @@ ((id (str "r" mod/*counter*))) (let ((r (mod/mk-report id by about reason))) - (begin (append! mod/*reports* r) r)))))) + (begin + (append! mod/*reports* r) + (append! mod/*cases* {:id id :case (mod/mk-case r)}) + r)))))) (define mod/get-report @@ -70,3 +76,88 @@ (let ((d (mod/decide-report r mod/*reports* mod/*rules*))) (begin (mod/log-decision! d (mod/report-evidence r)) d)))))) + +;; ── lifecycle façade over the case registry ── + +(define + mod/case-of + (fn + (id) + (reduce + (fn (acc rec) (if (= (get rec :id) id) (get rec :case) acc)) + nil + mod/*cases*))) + +(define + mod/case-store! + (fn + (id c) + (set! + mod/*cases* + (map + (fn (rec) (if (= (get rec :id) id) {:id id :case c} rec)) + mod/*cases*)))) + +;; apply a lifecycle op to the stored case, persist it, and (when a decision was +;; committed cleanly) append it to the audit log; returns the updated case +(define + mod/case-apply! + (fn + (id op log?) + (let + ((c (mod/case-of id))) + (if + (nil? c) + nil + (let + ((c2 (op c))) + (begin + (mod/case-store! id c2) + (when + log? + (when + (nil? (mod/case-error c2)) + (let + ((d (mod/case-decision c2))) + (if + (nil? d) + nil + (mod/log-decision! + d + (mod/report-evidence (mod/case-report c2))))))) + c2)))))) + +(define + mod/triage + (fn + (id) + (mod/case-apply! + id + (fn (c) (mod/case-triage c mod/*reports* mod/*rules*)) + false))) + +(define + mod/resolve + (fn (id) (mod/case-apply! id (fn (c) (mod/case-resolve c)) true))) + +(define + mod/review + (fn + (id kind val) + (mod/case-apply! + id + (fn (c) (mod/case-review c kind val mod/*reports* mod/*rules*)) + true))) + +(define + mod/appeal + (fn + (id kind val) + (mod/case-apply! + id + (fn (c) (mod/case-appeal c kind val mod/*reports* mod/*rules*)) + true))) + +(define + mod/finalize + (fn (id) (mod/case-apply! id (fn (c) (mod/case-finalize c)) false))) diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index 7752fb8e..5e5e0921 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -13,6 +13,7 @@ PRELOADS=( lib/mod/schema.sx lib/mod/policy.sx lib/mod/engine.sx + lib/mod/lifecycle.sx lib/mod/audit.sx lib/mod/api.sx ) @@ -20,4 +21,5 @@ PRELOADS=( SUITES=( "decide:lib/mod/tests/decide.sx:(mod-decide-tests-run!)" "audit:lib/mod/tests/audit.sx:(mod-audit-tests-run!)" + "escalation:lib/mod/tests/escalation.sx:(mod-escalation-tests-run!)" ) diff --git a/lib/mod/lifecycle.sx b/lib/mod/lifecycle.sx new file mode 100644 index 00000000..d73601ed --- /dev/null +++ b/lib/mod/lifecycle.sx @@ -0,0 +1,160 @@ +;; lib/mod/lifecycle.sx — report lifecycle state machine (pure SX over the engine). +;; +;; Lifecycle state is deliberately separate from policy: the Prolog rules answer +;; "what action?", this module answers "where in the process is this report?". +;; +;; :open ──triage──▶ :triaged ──resolve/review──▶ :decided ──appeal──▶ :appealed +;; │ │ +;; └────finalize───▶ :final ◀┘ +;; +;; A case is an immutable value {:report :state :decision :tier :error :history}. +;; Every transition returns a NEW case; illegal transitions return the case +;; unchanged with :error set. Tiers: triage runs the engine (auto-tier); a +;; terminal action (hide/remove/keep) resolves immediately, an :escalate action +;; flags the case for human review (human-tier) before it can be resolved. + +(define mod/case* (fn (report state decision tier err history) {:history history :state state :report report :error err :tier tier :decision decision})) + +(define + mod/mk-case + (fn (report) (mod/case* report "open" nil nil nil (list)))) + +(define mod/case-report (fn (c) (get c :report))) +(define mod/case-state (fn (c) (get c :state))) +(define mod/case-decision (fn (c) (get c :decision))) +(define mod/case-tier (fn (c) (get c :tier))) +(define mod/case-error (fn (c) (get c :error))) +(define mod/case-history (fn (c) (get c :history))) + +;; ── transition table ── + +(define mod/lc-transitions {:final (list) :appealed (list "final") :decided (list "appealed" "final") :open (list "triaged") :triaged (list "decided")}) + +(define mod/member? (fn (x lst) (mod/any? (fn (y) (= y x)) lst))) + +(define + mod/lc-can-transition? + (fn + (from to) + (let + ((outs (get mod/lc-transitions from))) + (if (nil? outs) false (mod/member? to outs))))) + +;; ── core transition: validate, record history, or flag :error ── + +(define + mod/case-goto + (fn + (c to note report decision tier) + (let + ((from (mod/case-state c))) + (if + (mod/lc-can-transition? from to) + (mod/case* + report + to + decision + tier + nil + (append (mod/case-history c) (list {:note note :to to :from from}))) + (mod/case* + (mod/case-report c) + from + (mod/case-decision c) + (mod/case-tier c) + (str "illegal transition: " from " -> " to) + (mod/case-history c)))))) + +(define + mod/case-error-set + (fn + (c msg) + (mod/case* + (mod/case-report c) + (mod/case-state c) + (mod/case-decision c) + (mod/case-tier c) + msg + (mod/case-history c)))) + +;; ── lifecycle operations ── + +;; :open → :triaged — run the auto-tier first pass. +(define + mod/case-triage + (fn + (c reports rules) + (let + ((d (mod/decide-report (mod/case-report c) reports rules))) + (let + ((tier (if (= (get d :action) "escalate") "human" "auto"))) + (mod/case-goto + c + "triaged" + "auto-tier first pass" + (mod/case-report c) + d + tier))))) + +;; :triaged → :decided — auto-tier resolves; human-tier is blocked until review. +(define + mod/case-resolve + (fn + (c) + (if + (= (mod/case-tier c) "human") + (mod/case-error-set c "awaiting human review (escalated)") + (mod/case-goto + c + "decided" + "auto-tier resolved" + (mod/case-report c) + (mod/case-decision c) + (mod/case-tier c))))) + +;; :triaged → :decided — human review: attach evidence, re-decide, resolve. +(define + mod/case-review + (fn + (c kind val reports rules) + (let + ((nr (mod/attach-evidence (mod/case-report c) (mod/mk-evidence kind val)))) + (let + ((d (mod/decide-report nr reports rules))) + (mod/case-goto c "decided" (str "human review: " kind) nr d "human"))))) + +;; :decided → :appealed — appeal: attach evidence, re-decide (may override). +(define + mod/case-appeal + (fn + (c kind val reports rules) + (let + ((nr (mod/attach-evidence (mod/case-report c) (mod/mk-evidence kind val)))) + (let + ((d (mod/decide-report nr reports rules))) + (mod/case-goto + c + "appealed" + (str "appeal: " kind) + nr + d + (mod/case-tier c)))))) + +;; :decided | :appealed → :final +(define + mod/case-finalize + (fn + (c) + (mod/case-goto + c + "final" + "finalized" + (mod/case-report c) + (mod/case-decision c) + (mod/case-tier c)))) + +(define + mod/case-action + (fn + (c) + (let ((d (mod/case-decision c))) (if (nil? d) nil (get d :action))))) diff --git a/lib/mod/policy.sx b/lib/mod/policy.sx index 4c3ab90c..804e59f8 100644 --- a/lib/mod/policy.sx +++ b/lib/mod/policy.sx @@ -9,6 +9,10 @@ ;; cond->goal takes an id-term so the same condition can be compiled with the ;; head variable "Id" (for clause bodies) or a concrete report id (for proof-tree ;; goal-by-goal re-querying in the engine). +;; +;; Precedence (top wins): exoneration evidence (appeal override) > confirmed-abuse +;; evidence (human review) > spam/abuse classification > repeated-report count > +;; default keep. (define mod/mk-rule (fn (name action conds) {:when conds :name name :action action})) @@ -19,6 +23,9 @@ (define mod/default-rules (list + (mod/mk-rule + "exonerated-keep" + :keep (list (list :evidence "exonerated"))) (mod/mk-rule "reviewer-remove" :remove (list (list :evidence "confirmed-abuse"))) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index ce3a15b8..50d894b5 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,11 +1,12 @@ { "lang": "mod", - "total_passed": 60, + "total_passed": 106, "total_failed": 0, - "total": 60, + "total": 106, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, - {"name":"audit","passed":29,"failed":0,"total":29} + {"name":"audit","passed":29,"failed":0,"total":29}, + {"name":"escalation","passed":46,"failed":0,"total":46} ], - "generated": "2026-06-06T17:36:32+00:00" + "generated": "2026-06-06T17:49:32+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 83cd88b1..c063d904 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,8 +1,9 @@ # mod scoreboard -**60 / 60 passing** (0 failure(s)). +**106 / 106 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| | decide | 31 | 31 | ok | | audit | 29 | 29 | ok | +| escalation | 46 | 46 | ok | diff --git a/lib/mod/tests/escalation.sx b/lib/mod/tests/escalation.sx new file mode 100644 index 00000000..5d6e442e --- /dev/null +++ b/lib/mod/tests/escalation.sx @@ -0,0 +1,279 @@ +;; lib/mod/tests/escalation.sx — Phase 3: lifecycle state machine + escalation. + +(define mod-esc-count 0) +(define mod-esc-pass 0) +(define mod-esc-fail 0) +(define mod-esc-failures (list)) + +(define + mod-esc-test! + (fn + (name got expected) + (begin + (set! mod-esc-count (+ mod-esc-count 1)) + (if + (= got expected) + (set! mod-esc-pass (+ mod-esc-pass 1)) + (begin + (set! mod-esc-fail (+ mod-esc-fail 1)) + (append! + mod-esc-failures + (str name "\n expected: " expected "\n got: " got))))))) + +;; ── transition table guard ── + +(mod-esc-test! + "open → triaged allowed" + (mod/lc-can-transition? "open" "triaged") + true) +(mod-esc-test! + "triaged → decided allowed" + (mod/lc-can-transition? "triaged" "decided") + true) +(mod-esc-test! + "decided → appealed allowed" + (mod/lc-can-transition? "decided" "appealed") + true) +(mod-esc-test! + "appealed → final allowed" + (mod/lc-can-transition? "appealed" "final") + true) +(mod-esc-test! + "open → decided rejected" + (mod/lc-can-transition? "open" "decided") + false) +(mod-esc-test! + "triaged → final rejected" + (mod/lc-can-transition? "triaged" "final") + false) +(mod-esc-test! + "final is terminal" + (mod/lc-can-transition? "final" "open") + false) + +;; ── initial state ── + +(define + mod-esc-c0 + (mod/mk-case (mod/mk-report "r1" "alice" "bob" "this is spam"))) +(mod-esc-test! "new case is open" (mod/case-state mod-esc-c0) "open") +(mod-esc-test! "new case has no decision" (mod/case-decision mod-esc-c0) nil) + +;; ── auto-tier: spam triages + resolves to decided/hide ── + +(define + mod-esc-spam-rep + (list (mod/mk-report "r1" "alice" "bob" "this is spam"))) +(define + mod-esc-t1 + (mod/case-triage mod-esc-c0 mod-esc-spam-rep mod/default-rules)) +(mod-esc-test! "spam triaged" (mod/case-state mod-esc-t1) "triaged") +(mod-esc-test! "spam triage tier auto" (mod/case-tier mod-esc-t1) "auto") +(mod-esc-test! "spam triage action hide" (mod/case-action mod-esc-t1) "hide") + +(define mod-esc-r1 (mod/case-resolve mod-esc-t1)) +(mod-esc-test! + "auto resolve → decided" + (mod/case-state mod-esc-r1) + "decided") +(mod-esc-test! + "decision preserved through resolve" + (mod/case-action mod-esc-r1) + "hide") + +;; ── illegal transition flags :error, leaves state ── + +(define mod-esc-bad (mod/case-finalize mod-esc-c0)) +(mod-esc-test! + "finalize from open is illegal" + (mod/case-state mod-esc-bad) + "open") +(mod-esc-test! + "illegal transition sets error" + (nil? (mod/case-error mod-esc-bad)) + false) + +;; ── human-tier: repeated report escalates, resolve blocked, review decides ── + +(define mod-esc-rep-r (mod/mk-report "r3" "ann" "dave" "off-topic")) +(define mod-esc-rep-reports (list mod-esc-rep-r mod-esc-rep-r mod-esc-rep-r)) +(define mod-esc-rep-c0 (mod/mk-case mod-esc-rep-r)) +(define + mod-esc-rep-t + (mod/case-triage mod-esc-rep-c0 mod-esc-rep-reports mod/default-rules)) + +(mod-esc-test! + "repeated triage action escalate" + (mod/case-action mod-esc-rep-t) + "escalate") +(mod-esc-test! + "repeated triage tier human" + (mod/case-tier mod-esc-rep-t) + "human") +(mod-esc-test! + "repeated still triaged after triage" + (mod/case-state mod-esc-rep-t) + "triaged") + +(define mod-esc-rep-block (mod/case-resolve mod-esc-rep-t)) +(mod-esc-test! + "auto-resolve blocked on human tier (state unchanged)" + (mod/case-state mod-esc-rep-block) + "triaged") +(mod-esc-test! + "blocked resolve sets error" + (nil? (mod/case-error mod-esc-rep-block)) + false) + +(define + mod-esc-rep-rev + (mod/case-review + mod-esc-rep-t + "confirmed-abuse" + "human" + mod-esc-rep-reports + mod/default-rules)) +(mod-esc-test! + "human review → decided" + (mod/case-state mod-esc-rep-rev) + "decided") +(mod-esc-test! + "human review action remove" + (mod/case-action mod-esc-rep-rev) + "remove") +(mod-esc-test! + "review attached evidence to report" + (len (mod/report-evidence (mod/case-report mod-esc-rep-rev))) + 1) + +(define mod-esc-rep-final (mod/case-finalize mod-esc-rep-rev)) +(mod-esc-test! + "review case finalizes" + (mod/case-state mod-esc-rep-final) + "final") + +;; ── appeal overrides a prior decision ── + +(define + mod-esc-ap-c0 + (mod/mk-case (mod/mk-report "r5" "u" "v" "buy now spam"))) +(define mod-esc-ap-rep (list (mod/mk-report "r5" "u" "v" "buy now spam"))) +(define + mod-esc-ap-t + (mod/case-triage mod-esc-ap-c0 mod-esc-ap-rep mod/default-rules)) +(define mod-esc-ap-d (mod/case-resolve mod-esc-ap-t)) + +(mod-esc-test! + "appeal precondition decided/hide" + (mod/case-action mod-esc-ap-d) + "hide") + +(define + mod-esc-ap-appealed + (mod/case-appeal + mod-esc-ap-d + "exonerated" + "moderator" + mod-esc-ap-rep + mod/default-rules)) +(mod-esc-test! + "appeal → appealed state" + (mod/case-state mod-esc-ap-appealed) + "appealed") +(mod-esc-test! + "appeal overrides hide → keep" + (mod/case-action mod-esc-ap-appealed) + "keep") +(mod-esc-test! + "appeal recorded via exonerated-keep rule" + (get (mod/case-decision mod-esc-ap-appealed) :rule) + "exonerated-keep") + +(define mod-esc-ap-final (mod/case-finalize mod-esc-ap-appealed)) +(mod-esc-test! "appealed → final" (mod/case-state mod-esc-ap-final) "final") + +;; ── history records the full traversal ── + +(mod-esc-test! + "full lifecycle history length 4 (triage,resolve,appeal,finalize)" + (len (mod/case-history mod-esc-ap-final)) + 4) +(mod-esc-test! + "first history step open→triaged" + (get (first (mod/case-history mod-esc-ap-final)) :to) + "triaged") +(mod-esc-test! + "last history step → final" + (get (nth (mod/case-history mod-esc-ap-final) 3) :to) + "final") + +;; ── api-level lifecycle façade ── + +(mod/reset!) +(mod/report "alice" "bob" "this is spam") +(mod/report "carol" "dave" "off-topic") +(mod/report "carol" "dave" "off-topic") +(mod/report "carol" "dave" "off-topic") + +(mod-esc-test! + "api: case opens at open" + (mod/case-state (mod/case-of "r1")) + "open") + +(define mod-esc-api-t1 (mod/triage "r1")) +(mod-esc-test! + "api: triage spam → triaged" + (mod/case-state mod-esc-api-t1) + "triaged") +(mod-esc-test! + "api: triage spam action hide" + (mod/case-action mod-esc-api-t1) + "hide") + +(define mod-esc-api-r1 (mod/resolve "r1")) +(mod-esc-test! + "api: resolve → decided" + (mod/case-state mod-esc-api-r1) + "decided") +(mod-esc-test! + "api: resolve logged decision" + (len (mod/audit "r1")) + 1) + +(define mod-esc-api-app (mod/appeal "r1" "exonerated" "mod")) +(mod-esc-test! + "api: appeal → appealed" + (mod/case-state mod-esc-api-app) + "appealed") +(mod-esc-test! + "api: appeal overrides → keep" + (mod/case-action mod-esc-api-app) + "keep") +(mod-esc-test! + "api: appeal logged second decision" + (len (mod/audit "r1")) + 2) +(mod-esc-test! + "api: finalize → final" + (mod/case-state (mod/finalize "r1")) + "final") + +;; r4 is the 3rd report about dave → escalates via the human tier +(define mod-esc-api-t4 (mod/triage "r4")) +(mod-esc-test! + "api: repeated triage escalates (human tier)" + (mod/case-tier mod-esc-api-t4) + "human") +(define mod-esc-api-blk (mod/resolve "r4")) +(mod-esc-test! + "api: escalated resolve blocked" + (mod/case-state mod-esc-api-blk) + "triaged") +(define mod-esc-api-rev (mod/review "r4" "confirmed-abuse" "human")) +(mod-esc-test! + "api: review → decided/remove" + (mod/case-action mod-esc-api-rev) + "remove") +(mod-esc-test! "api: unknown id → nil" (mod/triage "r99") nil) + +(define mod-escalation-tests-run! (fn () {:failures mod-esc-failures :total mod-esc-count :passed mod-esc-pass :failed mod-esc-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 2128b4c2..01821c18 100644 --- a/plans/mod-on-sx.md +++ b/plans/mod-on-sx.md @@ -16,7 +16,7 @@ federation extension. ## Status (rolling) -`bash lib/mod/conformance.sh` → **60/60** (Phases 1–2 complete) +`bash lib/mod/conformance.sh` → **106/106** (Phases 1–3 complete) ## Ground rules @@ -96,12 +96,20 @@ lib/mod/fed.sx ## Phase 3 — Escalation + lifecycle state machine -- [ ] state machine: `:open → :triaged → :decided → :appealed → :final` -- [ ] auto-tier: first-pass rules decide quick cases -- [ ] human-tier: rules that emit `:escalate` move to next state -- [ ] appeal: re-runs with appeal evidence, may override prior decision -- [ ] `(mod/appeal id new-evidence)` API -- [ ] `lib/mod/tests/escalation.sx` — full lifecycle traversal cases +- [x] state machine: `lib/mod/lifecycle.sx` — `:open → :triaged → :decided → + :appealed → :final` as a pure SX module over the engine; transition table guards + illegal moves (sets `:error`, leaves state); immutable cases with `:history` +- [x] auto-tier: `mod/case-triage` runs the engine; terminal action (hide/remove/ + keep) → tier `auto`, `mod/case-resolve` advances to `:decided` +- [x] human-tier: `:escalate` action → tier `human`; `mod/case-resolve` is blocked + (sets `:error`); `mod/case-review` attaches evidence, re-decides, advances +- [x] appeal: `mod/case-appeal` attaches appeal evidence + re-runs the engine; new + `exonerated-keep` rule (top precedence) lets exoneration override a prior `:hide` +- [x] `(mod/appeal id new-evidence)` API — lifecycle façade over a case registry in + api.sx (`mod/triage` / `resolve` / `review` / `appeal` / `finalize`), logging + each committed decision to the audit trail +- [x] `lib/mod/tests/escalation.sx` — 46 cases: transition guards, auto/human tiers, + blocked resolve, full appeal-override traversal, history, api façade ## Phase 4 — Federation @@ -113,6 +121,22 @@ lib/mod/fed.sx ## Progress log +- **Phase 3 complete — 106/106** (+46 escalation). Lifecycle state machine, + auto/human tiers, appeal-override, and an api façade. The state machine is a + pure SX module (`lib/mod/lifecycle.sx`) over the engine — policy stays in + Prolog, lifecycle stays out of it, per the design constraint. Cases are + immutable values threaded through transitions; illegal moves set `:error` + rather than throwing (the env's error handling is untested, so this keeps tests + deterministic). Tier logic: triage runs the engine, an `:escalate` action parks + the case at the human tier where `mod/case-resolve` is blocked until + `mod/case-review` supplies evidence. Appeal-override works because the new + `exonerated-keep` rule sits at top precedence — appeal evidence re-runs the same + engine and a higher-precedence clause wins. The api façade (`mod/triage` … + `mod/finalize`) keeps a per-report case registry and logs each committed + decision to the Phase-2 audit trail, so lifecycle + audit compose. + - **Gotcha:** `sx_insert_near` inserts only the FIRST top-level form of a + multi-form source — silently drops the rest (byte count barely changes). For + multi-form additions, rewrite the file with `sx_write_file`. - **Phase 2 complete — 60/60** (+29 audit). Evidence accumulation, constructive proof trees, append-only audit log. A decision's `:proof :goals` is a real derivation: each body goal is re-queried against the same Prolog DB with the