# 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.sh` → **277/277** (roadmap + 11 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//`. 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 - [x] `lib/mod/schema.sx` — `report(id, by, about)`, `classification(id, kind)`, `report_count(subject, n)` Prolog facts; keyword classifier derives evidence - [x] `lib/mod/policy.sx` — `mod/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` - [x] `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 - [x] `lib/mod/api.sx` — registry + `(mod/report by about reason)`, `(mod/decide id)` - [x] `lib/mod/tests/decide.sx` — 31 cases: spam/abuse keyword, repeated→escalate, no-rule→keep, precedence (spam beats repeated), proof shape, registry ids - [x] `lib/mod/scoreboard.{json,md}` - [x] `lib/mod/conformance.sh` ## Phase 2 — Evidence + audit trail - [x] 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 - [x] 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) - [x] `lib/mod/audit.sx` — append-only log: monotonic `:seq`, decision + proof + evidence snapshot; never mutates prior entries - [x] `(mod/audit id)` retrieval (+ `mod/audit-latest`, `mod/audit-all`, count) - [x] `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 - [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 - [x] 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 - [x] 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) - [x] 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 - [x] 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) - [x] `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) - [x] **Ext 1 — negation-as-failure** (`lib/mod/tests/extensions.sx`, +14). Report `:attrs`; policy conditions `(:attr "x")` → `attr(Id, x)` and `(:not )` → `not()` (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. - [x] **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. - [x] **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}`. - [x] **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. - [x] **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"). - [x] **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. - [x] **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.) - [x] **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. - [x] **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` 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)