Files
rose-ash/plans/mod-on-sx.md
giles 2913cdc3a8
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 57s
plans: correct extraction note — declined after reading both impls
Reading lib/mod (Prolog) and lib/acl (Datalog) side by side shows the convergence
is in module names only. Federation: opposite trust models (SX registry + decision
sharing vs in-engine Datalog trust facts + fact replication), zero shared code.
Audit: only a ~5-fn core overlaps and it diverges (entry shapes, seq base 0 vs 1,
op sets, mutation idiom) — not worth a shared module under two restricted envs.
Outcome: keep them parallel; revisit only on a third same-model consumer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:10:57 +00:00

30 KiB

mod-on-sx: Moderation on Prolog

rose-ash needs moderation infrastructure: reports flagged by users, automated classifications (spam, abuse), tiered escalation (auto → human → appeal), audit trails. Each decision is the conclusion of a backtracking search over evidence and policy rules — exactly what Prolog does.

Where acl-sx says "may this happen?", mod-sx says "should this stay?" The former is a positive decision (proof of grant); the latter often a negative one (proof of violation), and policy chains naturally backtrack: if the first rule doesn't apply, try the next.

End-state: a Prolog-on-SX layer for moderation policy declaration and evaluation, with persistent report lifecycle, audit log, escalation state machine, and federation extension.

Status (rolling)

bash lib/mod/conformance.sh390/390 (roadmap + 19 extensions complete)

Ground rules

  • Scope: only touch lib/mod/** and plans/mod-on-sx.md. Do not edit spec/, hosts/, shared/, lib/prolog/**, or other lib/<lang>/. You may import from lib/prolog/ (public API in lib/prolog/prolog.sx); do not modify Prolog.
  • Shared-file issues go under "Blockers" with a minimal repro; do not fix here.
  • SX files: use sx-tree MCP tools only.
  • Architecture: policies are Prolog rules over report(...) and evidence(...) facts. Decisions are query results. Proof trees become audit records. The state machine for report lifecycle is separate (an SX module on top).
  • Shared with acl-sx: rule-engine plumbing may be liftable into lib/guest/. Watch for it; flag in Progress log but do not extract until both subsystems are past Phase 2.
  • Commits: one feature per commit. Keep Progress log updated and tick boxes.

Architecture sketch

Report                                  Decision
  {:by :about :reason :at}                {:action :proof :next-state}
        │                                       ▲
        ▼                                       │
lib/mod/schema.sx                       lib/mod/engine.sx
  — report/4, evidence/2,                 — query Prolog with report fact
    classification/3 predicates             — extract proof tree
        │                                       ▲
        ▼                                       │
lib/mod/policy.sx                       lib/mod/lifecycle.sx
  — rule syntax → Prolog                  — state machine
  — action heads:                         — open → triaged → decided
    {:keep :hide :remove                  — appeal handling
     :escalate :ban}                            │
        │                                       ▼
        ▼                                lib/mod/audit.sx
lib/mod/api.sx                            — append-only decision log
  — (mod/report ...)                      — proof tree persistence
  — (mod/decide report)                   — query API
  — (mod/appeal id)
        │
        ▼
lib/mod/fed.sx
  — cross-instance reports via fed-sx
  — decision sharing / trust model

Phase 1 — Report representation + simple policy

  • lib/mod/schema.sxreport(id, by, about), classification(id, kind), report_count(subject, n) Prolog facts; keyword classifier derives evidence
  • lib/mod/policy.sxmod/mk-rule + ordered mod/default-rules; conditions (:classification, :count-at-least) compile to Prolog goals; policy_action/3 clauses, last clause true so every report yields at least :keep
  • lib/mod/engine.sx(mod/decide-report r reports rules) queries policy_action(Id, Action, Rule) with pl-query-one (clause order = precedence); returns a decision dict {:action :rule :report-id :proof} carrying the why
  • lib/mod/api.sx — registry + (mod/report by about reason), (mod/decide id)
  • lib/mod/tests/decide.sx — 31 cases: spam/abuse keyword, repeated→escalate, no-rule→keep, precedence (spam beats repeated), proof shape, registry ids
  • lib/mod/scoreboard.{json,md}
  • lib/mod/conformance.sh

Phase 2 — Evidence + audit trail

  • evidence accumulation — report :evidence list; mod/attach-evidence + api mod/add-evidence; asserted as evidence(Id, 'kind', 'val') facts; new :evidence condition + reviewer-remove rule consume it
  • proof tree from Prolog derivation — mod/proof-goals re-queries each body goal (id bound) against the same DB, recording goal text, solved?, and the bindings that satisfied it (e.g. count goal yields N=3, S=subject)
  • lib/mod/audit.sx — append-only log: monotonic :seq, decision + proof + evidence snapshot; never mutates prior entries
  • (mod/audit id) retrieval (+ mod/audit-latest, mod/audit-all, count)
  • lib/mod/tests/audit.sx — 29 cases: proof goal text/bindings, evidence-driven decisions, append-only ordering, per-report retrieval, snapshot-at-decision-time

Phase 3 — Escalation + lifecycle state machine

  • 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
  • auto-tier: mod/case-triage runs the engine; terminal action (hide/remove/ keep) → tier auto, mod/case-resolve advances to :decided
  • human-tier: :escalate action → tier human; mod/case-resolve is blocked (sets :error); mod/case-review attaches evidence, re-decides, advances
  • appeal: mod/case-appeal attaches appeal evidence + re-runs the engine; new exonerated-keep rule (top precedence) lets exoneration override a prior :hide
  • (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
  • 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

  • cross-instance reports — mod/fed-receive-report peer … ingests a peer's report into the local registry, tagging origin; mod/report-origin resolves it (local reports default to "local"); the engine decides federated reports unchanged
  • decision sharing — mod/fed-share-decision decision peers pushes messages to the mock outbox (mod/fed-send! is the seam the real fed-sx transport replaces)
  • trust model — mod/fed-receive-decision applies a peer's decision locally ONLY when (mod/trusted? peer :mod); otherwise it lands in the advisory log, unapplied. mod/grant-trust / mod/revoke-trust manage the trust registry
  • revocation — mod/fed-revoke! marks the applied action revoked + emits a revocation message to the origin; mod/fed-revoke-if-invalidated re-runs the engine and revokes only when the action no longer holds (proof invalidated)
  • lib/mod/tests/fed.sx — 26 cases: trust grant/scope/revoke, cross-instance ingest + origin, outbox sharing, advisory-vs-trusted apply, revocation + invalidation (exoneration flips hide→keep → revoked)

Extensions (post-roadmap)

  • Ext 1 — negation-as-failure (lib/mod/tests/extensions.sx, +14). Report :attrs; policy conditions (:attr "x")attr(Id, x) and (:not <cond>)not(<cond>) (the Prolog supports not/1 and \+/1 as functors, not the prefix \+ operator). Closed-world example: "hide spam UNLESS author verified". Default policy untouched — demonstrated via custom rule sets, so all 132 base tests stay green.
  • Ext 2 — weighted/aggregate scoring (+8). Report :signals ({:kind :weight}) project to signal(Id, 'kind', weight) facts; condition (:score-at-least N)aggregate_all(sum(W), signal(Id, _, W), T), T >= N. Many weak signals accumulate past a threshold — genuine Prolog arithmetic aggregation. Default policy untouched.
  • Ext 3 — proof explanation (lib/mod/explain.sx, +10). mod/explain renders a decision into a readable "why": action + rule, evidence line, and the derivation goal-by-goal with [proved]/[unproved] marks and unification bindings. E.g. Report rc: escalate (rule: repeated-escalate)[proved] report(rc, B, S), report_count(S, N), N >= 3 {B=ann, N=3, S=dave}.
  • Ext 19 — end-to-end triage pipeline (lib/mod/pipeline.sx, +15). mod/triage-pipeline domain r reports actor runs a report through domain-policy decision → explanation → AP activity → wire, returning the full bundle. The test is a genuine integration across 5 modules including a federated handoff (market decision → wire → peer → trust-gated apply). The capstone that proves the independently-built modules compose.
  • Ext 18 — ergonomic defrule / ruleset (lib/mod/defrule.sx, +11). The roadmap's (defrule …) surface, done with &rest variadics (no macro needed — conditions are already plain data): mod/defrule collects trailing conditions, mod/ruleset assembles rules. Produces structurally identical rules to mk-rule and works in the engine unchanged.
  • Ext 17 — per-domain policy registry (lib/mod/policies.sx, +14). mod/register-policy! domain rules + mod/decide-in domain r reports give each rose-ash domain (blog/market/events/…) its own rule set; unregistered domains fall back to default-rules so a new domain is never unmoderated. Same spam report → :remove under a strict market policy, :hide under blog's default.
  • Ext 16 — ActivityPub-shaped export (lib/mod/activity.sx, +17). mod/decision->activity maps a decision to a moderation verb (remove→Delete, ban→Block, hide/escalate→Flag, keep→no activity) shaped like an AP activity ({:type :actor :object :summary}), the precise mod action preserved in :action. mod/decisions->activities batch-exports, dropping keeps — ready for the platform's AP event bus / federated peers.
  • Ext 15 — disjunctive conditions (policy.sx + tests/disjunction.sx, +10). (:any (list c1 c2 …)) compiles to Prolog disjunction (g1 ; g2 ; …), completing the condition boolean algebra (AND via the :when list, :not, :any). Composes recursively — :any over :not/:attr/classification, and ANDs with other conditions in the same rule. One rule now covers "spam OR abuse".
  • Ext 14 — decision wire format (lib/mod/wire.sx, +16). The bytes that cross fed/fed-send!: mod/decision->wire emits a versioned pipe-delimited line (MOD1|r1|hide|spam-hide), mod/wire->decision parses it back (mod/wire-valid? guards). Built mod/split-char over slice/len (loaded env has no split). Integration test exercises the full path: serialize → wire → deserialize → fed-receive-decision trust-gating (untrusted→advisory, trusted→applied).
  • Ext 13 — SLA sweep over pending cases (lib/mod/sla.sx, +15). Composes lifecycle (Phase 3) with time (Ext 12): a timed-case pairs a case with the tick it entered its state; mod/overdue? flags pending cases (open/triaged/appealed) past a deadline; mod/sla-sweep returns the breached report ids. Terminal states never breach. Pure overlay — lifecycle stays timeless, the caller stamps entry.
  • Ext 12 — temporal burst detection (lib/mod/temporal.sx, +15). Reports gain an :at tick (deterministic, supplied — never clock-read). mod/decide-temporal now window counts reports about the subject within [now-window, now], asserts burst_count/2, and a (:burst-at-least K) rule fires only on a real burst. Verified: 3 reports at ticks 10/11/12 → hide; 3 reports at 1/2/12 (window 5) → keep, while the plain count rule escalates both.
  • Ext 11 — batch triage + corpus analytics (lib/mod/batch.sx, +17). mod/decide-batch triages a queue; mod/action-histogram summarizes outcomes by action; mod/rule-coverage / mod/never-fired measure which rules fire across a corpus — the empirical complement to lint's static unreachable check (Ext 5): lint finds rules that can't fire, never-fired finds rules that didn't.
  • Ext 10 — policy what-if / impact (lib/mod/whatif.sx, +13). mod/decision-diff compares one report's action under two rule sets; mod/policy-impact runs a batch and returns only the reports whose decision flips; mod/impact-count / mod/impact-report summarize. Lets a team measure a policy change before shipping it (e.g. "removing spam-hide flips r1 hide→keep").
  • Ext 9 — policy dry-run trace (lib/mod/trace.sx, +15). mod/trace-rules evaluates a report against every rule and returns each rule's proved/unproved status + its goal-by-goal derivation, so an unproved rule shows which goal failed. mod/first-proved = the winner (engine precedence), mod/proved-rules the full firing set, mod/trace-report a [fires]/[ - ] rendering. Answers "why didn't my rule fire?" without instrumenting the engine.
  • Ext 8 — quorum over distinct reporters (lib/mod/quorum.sx, +9). Anti- brigade: (:reporters-at-least N) compiles to setof(Br, report(_, Br, Sr), Bsr), length(Bsr, Nr), Nr >= N — distinct reporters, not raw report count. mod/decide-quorum asserts every report's report/3 fact (the base engine only asserts the decided one) so Prolog can aggregate reporters. Verified one user filing 3 reports stays :keep under quorum while the count rule would escalate. (Substrate note: ^ existential doesn't parse; setof(B, p(_, B, S), …) with _ yields the distinct set in a single solution here.)
  • Ext 7 — repeat-offender escalation (lib/mod/offenders.sx, +19). The audit log as evidence: mod/subject-sanctions counts prior hide/remove/ban decisions about a subject; mod/decide-escalating id k decides normally then upgrades a sanction to :ban when the subject already has ≥k prior sanctions. Non-sanction outcomes (keep/escalate) pass through untouched. First decision whose input spans history beyond the single report — read from the trail, not re-derived.
  • Ext 6 — strictest-wins strategy (lib/mod/severity.sx, +14). Alternative to first-match: mod/decide-strictest collects every proven rule (pl-query-all) and picks the highest-mod/action-severity action (keep<escalate<hide<remove<ban). Diverges from the default engine when rule order and severity disagree. Same decision shape + :strategy; engine untouched.
  • Ext 5 — policy lint (lib/mod/lint.sx, +14). Static analysis of a rule set: mod/unreachable-rules flags rules placed after an unconditional (always- matching) rule — structurally dead under first-match precedence; mod/has-catchall? checks every report gets a decision; mod/duplicate-rule-names
    • mod/rules-ok? give a one-call well-formedness verdict. No engine run needed.
  • Ext 4 — report linking / dedup (lib/mod/link.sx, +12). mod/related-ids and mod/reporters-of find reports about a subject via a Prolog relational query (report(Id, _, 'subject')) — the policy substrate reused for retrieval. mod/dedup-reports collapses identical reports (reporter|subject|reason key, case-insensitive); mod/distinct-reporters-of counts unique reporters.

Shared-plumbing extraction — evaluated post-merge, DECLINED

Both layers now live on architecture; the extraction was evaluated by reading both implementations side by side. Finding: do not extract — the convergence is in module names only, not implementations. The engines and decision models genuinely differ, so a shared module would be premature abstraction that ages badly. (This reverses the pre-read note that listed audit + fed trust/outbox as candidates; reading the code showed they don't actually share.)

  • Federation — zero shared code. mod gates trust in SX (a {:peer :scope} registry + grant/revoke/trusted?) and shares decisions (outbox, advisory/applied logs, receive-decision). acl gates trust inside Datalog (trust(Peer,L) / level_covers facts + an engine rule re-checked per query) and shares facts (fetch/collect/build-db, assert!/retract!). acl has no trust registry, no trusted?, no outbox. Opposite architectures — the only common token is the word "trust."
  • Audit — only a ~5-fn core overlaps, and it diverges. Entry shapes differ entirely (mod {:action :rule :proof :evidence :report-id :seq} vs acl {:allowed? :act :subj :res :seq}); seq base differs (acl 0, mod 1, both test-visible); op sets barely intersect (mod: by-report-id + latest; acl: tail/snapshot/restore/serialize); even the list idiom differs (acl append!+copy vs mod pure append+set!). A shared module would also have to satisfy two different restricted eval envs (prolog- vs datalog-loaded). Cost (shared module + refactor both + rewrite acl's serialize/snapshot onto a foreign core + cross-env risk + coupling two independent loops) far exceeds the benefit (dedup ~5 trivial lines that don't even agree on seq-base or mutation idiom).
  • Engines + explain were never shareable: Datalog yields derivation trees natively; mod reconstructs proofs via per-goal pl-query-all.
  • Trivia (join-with, any?, str-contains?, distinct) is one-liners, not worth a module.

Outcome: keep mod (Prolog) and acl (Datalog) as parallel independent implementations. The parallel structure is correct for two different engines; the shared abstraction is not. Revisit only if a third rule-engine consumer appears with the same trust/audit model (rule of three), not before.

Progress log

  • Ext 19 — end-to-end triage pipeline, 390/390 (+15). Capstone: one orchestration call composes domain policy + decide + explain + activity + wire, and the integration test runs the whole federated path (decide in a domain → wire → peer → trust-gated apply) across 5 modules. Confirms the subsystem — built module-by-module — actually composes end to end. mod-sx now spans schema → policy DSL (boolean algebra + count/score/reporters/burst) → engine + proofs → audit → lifecycle → SLA → federation (trust/wire/AP) → analytics (trace/whatif/lint/batch) → domain policies → pipeline, all on the green lib/prolog substrate, 390 tests.
  • Ext 18 — ergonomic defrule / ruleset, 375/375 (+11). Closes the roadmap's original defrule surface. fn supports &rest here, and conditions evaluate to plain data, so no macro is needed — variadic functions give the ergonomics safely. Equivalence to mk-rule is asserted, so it's pure sugar with no new semantics.
  • Ext 17 — per-domain policy registry, 364/364 (+14). Multi-tenant policy: the engine already took rules as a parameter, so domain-scoping is just a registry + a default fallback — no engine change. Makes the whole policy vocabulary (16 prior features) per-domain configurable. Default fallback means adding a domain can't accidentally leave it unmoderated.
  • Ext 16 — ActivityPub-shaped export, 350/350 (+17). Bridges mod-sx to the wider rose-ash platform, which propagates cross-domain effects as AP-shaped activities. Decisions become Flag/Delete/Block activities (keep = no-op); with the wire format (Ext 14) and fed trust model (Phase 4) the federated moderation path is now end-to-end: decide → activity/wire → peer → trust-gate → apply.
  • Ext 15 — disjunctive conditions, 333/333 (+10). The condition DSL is now a full boolean algebra: AND (the :when list), :not (NAF), :any (Prolog ;). cond->goal recurses, so the combinators nest arbitrarily — :any of :nots, an :any ANDed with a :not, etc. — and the proof tree shows the compiled disjunction verbatim. Maps directly onto Prolog's own control constructs rather than reimplementing boolean logic in SX.
  • Ext 14 — decision wire format, 323/323 (+16). Fills the federation transport seam: decisions now serialize to a portable line and parse back, and the integration test runs the whole federated path end-to-end (serialize on one instance → trust-gated apply on another). Needed a hand-rolled split-char (loaded env has no split) — over slice/len, same toolkit as str-contains?.
  • Ext 13 — SLA sweep, 307/307 (+15). Two subsystems compose cleanly: lifecycle states + temporal ticks → "which pending cases have sat too long". Kept lifecycle pure by having the SLA layer carry entry-time externally (timed-case wrapper) rather than stamping the case — same separation-of-concerns as keeping the state machine out of Prolog.
  • Ext 12 — temporal burst detection, 292/292 (+15). Adds the time dimension: a windowed count distinguishes a burst from slow accumulation, where the plain count rule cannot. Time is a supplied tick (:at), keeping everything deterministic and testable — no clock primitive. Fifth report field (:at) threaded through the rebuild helpers, same non-breaking pattern as evidence/attrs/signals; all 277 prior tests stayed green.
  • Ext 11 — batch triage + corpus analytics, 277/277 (+17). Operational layer: triage a queue, histogram the outcomes, and measure rule coverage over real data. never-fired pairs with lint (Ext 5) — static "can't fire" vs empirical "didn't fire" — giving policy authors both views of dead rules. Histogram avoids dict mutation by counting over a fixed action vocabulary.
  • Ext 10 — policy what-if / impact, 260/260 (+13). Decisions are now comparable across rule sets — diff one report, or batch a whole set and surface only the flips. Pure SX over decide-report, no engine change. Closes the policy-authoring loop alongside lint (Ext 5) and trace (Ext 9): lint checks well-formedness, trace explains one report, what-if measures a change's blast radius before it ships.
  • Ext 9 — policy dry-run trace, 247/247 (+15). Whole-rule-set diagnostics over the proof machinery: every rule's fire/no-fire and the goal that decided it. The winner agrees with decide-report by construction (first proved = pl-query-one), cross-checked in a test. Turns the proof tree from a per-decision artifact into a policy-debugging tool.
  • Ext 8 — quorum over distinct reporters, 232/232 (+9). Distinct-reporter consensus via Prolog setof/length, requiring a second engine variant that asserts all reports (the base engine deliberately scopes facts to the decided report). Demonstrates the substrate handles set-aggregation, and that the brigade case (one actor, many reports) is defeated by counting reporters not reports. ^ existential doesn't parse here — setof(B, p(_,B,S), …) with _ gives the distinct set in one solution.
  • Ext 7 — repeat-offender escalation, 223/223 (+19). Decisions can now depend on history: the append-only audit log is read back as evidence, and a subject with k prior sanctions has its next sanction upgraded to :ban. Closes the loop between audit (Phase 2) and policy — the trail isn't just a record, it feeds future decisions. Non-sanction outcomes never escalate (verified: a clean post that the count rule escalates stays :escalate, never :ban).
  • Ext 6 — strictest-wins strategy, 204/204 (+14). A second decision strategy alongside first-match: collect all proven rules and apply the harshest sanction. Shows the substrate supports more than one precedence policy over the same rule facts — pl-query-all for the full match set, severity ranking in SX. Verified it diverges from first-match exactly when rule order and severity disagree.
  • Ext 5 — policy lint, 190/190 (+14). Static analysis of the rule set itself, catching the failure modes first-match precedence makes easy: dead rules after a catch-all, missing catch-all (undecided reports), duplicate names. mod/rules-ok? is a single well-formedness gate a policy author can assert in their own tests.
  • Ext 4 — report linking / dedup, 176/176 (+12). Relational retrieval (related-ids, reporters-of) reuses the Prolog substrate for querying report clusters, not just deciding them — report(Id, _, 'subject') by unification. Dedup is pure SX over a normalized link key. Own suite (tests/link.sx) — going forward, new extensions get their own test file rather than growing extensions.sx. With roadmap + 4 extensions the subsystem now spans schema → policy DSL (6 condition types) → engine + proofs → audit → lifecycle → federation → explanation → linking, all on the green lib/prolog substrate.
  • Ext 3 — proof explanation, 164/164 (+10). mod/explain turns the Phase-2 proof tree into human-readable text — the audit trail's "why" made legible. Pure SX over existing decision data; no engine change. Renders unification bindings inline ({B=ann, N=3, S=dave}) so a moderator sees exactly which facts proved the decision.
  • Ext 2 — weighted/aggregate scoring, 154/154 (+8). :signals + the (:score-at-least N) condition push aggregation into Prolog (aggregate_all(sum(W), …)), so low-confidence signals can accumulate to a takedown. The schema's report-rebuild helpers (report* / with-*) now thread six fields; each addition stays non-breaking because empty collections project to empty fact blocks. Default policy and its 132 tests untouched (proven via custom rule sets).
  • Ext 1 — negation-as-failure, 146/146 (+14). :attr and :not conditions give the policy closed-world reasoning. The substrate's negation is a functor (not(Goal)), not the ISO prefix \+ operator (that doesn't parse here) — noted for any future negation work. Kept the default rule set and its 132 tests untouched by proving the feature through custom rule sets instead.
  • Phase 4 complete — 132/132 (+26 fed). Full roadmap done. Federation: cross-instance reports, decision sharing, advisory-by-default trust, revocation. fed-sx is mocked behind mod/fed-send! (in-memory outbox) — the only seam a real transport must replace. The hard rule is enforced: a peer's decision binds locally only under (mod/trusted? peer :mod); otherwise it is recorded as a suggestion and never auto-applied. Revocation composes with the proof model from Phase 2 — mod/fed-revoke-if-invalidated re-runs the same engine and undoes a moderation only when the action it once proved no longer holds (an exoneration evidence flips hide→keep, triggering revocation + an origin-bound revocation message).
    • Liftable (acl-sx watch): the trust registry (grant/revoke/trusted? over {:peer :scope}) and the outbox/send! seam are generic federation plumbing; candidates for lib/guest/ if acl-sx grows a federation phase.
  • 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/triagemod/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 report id bound, so the count rule's proof carries N=3, S=<subject> straight from unification — not a reconstruction. Evidence is asserted as evidence(Id, 'kind', 'val'); the new reviewer-remove rule (placed first = highest precedence) lets human review override automated classification. mod/decide now commits each decision to the audit log with the evidence snapshot in force at decision time. Unknown predicates in this Prolog fail gracefully (verified) — so an evidence-less report safely falls through the reviewer rule without an existence error.
    • Liftable (acl-sx watch): the proof-tree builder (mod/proof-goals — re-query-each-goal) and the append-only log shape are both generic. Both subsystems are now past Phase 2; next time either touches plumbing, evaluate lifting proof-goals + the audit-log primitives into lib/guest/.
  • Phase 1 complete — 31/31. Report schema, keyword classifier, policy DSL, engine, registry api, conformance harness. Decisions are proofs: each carries :rule (matching clause), :proof {:rule :conditions :evidence :count}. Precedence is Prolog clause order resolved by pl-query-one; a trailing true-bodied default rule makes "no rule matched" a real :keep, not a query failure. Evidence (spam/abuse classification) derived in SX and asserted as classification/2 facts; repeated-report escalation uses a genuine Prolog join + arithmetic (report(Id,_,S), report_count(S,N), N >= 3).
    • Gotcha (env): loading the prolog libs strips includes? (and other high-level string prims) from the eval env — only the set the prolog tokenizer itself uses survives (slice, len, nth, =, join, downcase, map, reduce, append!). Implemented mod/str-contains? over slice/len rather than relying on includes?. Watch for this in later phases — stick to the blessed primitive set.
    • Liftable (acl-sx watch): mod/join-with, mod/str-contains?, mod/any?, and the rule→clause compilation shape are generic rule-engine plumbing. Do not extract to lib/guest/ until both mod-sx and acl-sx are past Phase 2.

Blockers

(none)