# 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` → **390/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

- [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 <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.
- [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 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.
- [x] **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.
- [x] **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.
- [x] **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.
- [x] **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".
- [x] **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).
- [x] **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.
- [x] **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.
- [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<escalate<hide<remove<ban).
  Diverges from the default engine when rule order and severity disagree. Same
  decision shape + `:strategy`; engine untouched.
- [x] **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.
- [x] **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 `:not`s, 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/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
  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)
