mod/defrule collects trailing conditions via &rest; mod/ruleset assembles rules. No macro needed — conditions are plain data, fn supports &rest here. Produces structurally identical rules to mk-rule (asserted) and works in the engine unchanged. Closes the roadmap's original defrule surface. +11 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
27 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.sh → 375/375 (roadmap + 18 extensions complete)
Ground rules
- Scope: only touch
lib/mod/**andplans/mod-on-sx.md. Do not editspec/,hosts/,shared/,lib/prolog/**, or otherlib/<lang>/. You may import fromlib/prolog/(public API inlib/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-treeMCP tools only. - Architecture: policies are Prolog rules over
report(...)andevidence(...)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.sx—report(id, by, about),classification(id, kind),report_count(subject, n)Prolog facts; keyword classifier derives evidencelib/mod/policy.sx—mod/mk-rule+ orderedmod/default-rules; conditions (:classification,:count-at-least) compile to Prolog goals;policy_action/3clauses, last clausetrueso every report yields at least:keeplib/mod/engine.sx—(mod/decide-report r reports rules)queriespolicy_action(Id, Action, Rule)withpl-query-one(clause order = precedence); returns a decision dict{:action :rule :report-id :proof}carrying the whylib/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 idslib/mod/scoreboard.{json,md}lib/mod/conformance.sh
Phase 2 — Evidence + audit trail
- evidence accumulation —
report :evidencelist;mod/attach-evidence+ apimod/add-evidence; asserted asevidence(Id, 'kind', 'val')facts; new:evidencecondition +reviewer-removerule consume it - proof tree from Prolog derivation —
mod/proof-goalsre-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 → :finalas a pure SX module over the engine; transition table guards illegal moves (sets:error, leaves state); immutable cases with:history - auto-tier:
mod/case-triageruns the engine; terminal action (hide/remove/ keep) → tierauto,mod/case-resolveadvances to:decided - human-tier:
:escalateaction → tierhuman;mod/case-resolveis blocked (sets:error);mod/case-reviewattaches evidence, re-decides, advances - appeal:
mod/case-appealattaches appeal evidence + re-runs the engine; newexonerated-keeprule (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 traillib/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-originresolves it (local reports default to"local"); the engine decides federated reports unchanged - decision sharing —
mod/fed-share-decision decision peerspushes messages to the mock outbox (mod/fed-send!is the seam the real fed-sx transport replaces) - trust model —
mod/fed-receive-decisionapplies a peer's decision locally ONLY when(mod/trusted? peer :mod); otherwise it lands in the advisory log, unapplied.mod/grant-trust/mod/revoke-trustmanage the trust registry - revocation —
mod/fed-revoke!marks the applied action revoked + emits a revocation message to the origin;mod/fed-revoke-if-invalidatedre-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 supportsnot/1and\+/1as 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 tosignal(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/explainrenders 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 18 — ergonomic defrule / ruleset (
lib/mod/defrule.sx, +11). The roadmap's(defrule …)surface, done with&restvariadics (no macro needed — conditions are already plain data):mod/defrulecollects trailing conditions,mod/rulesetassembles rules. Produces structurally identical rules tomk-ruleand 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 reportsgive 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->activitymaps 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->activitiesbatch-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 —:anyover: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 crossfed/fed-send!:mod/decision->wireemits a versioned pipe-delimited line (MOD1|r1|hide|spam-hide),mod/wire->decisionparses it back (mod/wire-valid?guards). Builtmod/split-charoverslice/len(loaded env has no split). Integration test exercises the full path: serialize → wire → deserialize →fed-receive-decisiontrust-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-sweepreturns 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:attick (deterministic, supplied — never clock-read).mod/decide-temporal now windowcounts reports about the subject within[now-window, now], assertsburst_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-batchtriages a queue;mod/action-histogramsummarizes outcomes by action;mod/rule-coverage/mod/never-firedmeasure 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-diffcompares one report's action under two rule sets;mod/policy-impactruns a batch and returns only the reports whose decision flips;mod/impact-count/mod/impact-reportsummarize. 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-rulesevaluates 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-rulesthe full firing set,mod/trace-reporta[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 tosetof(Br, report(_, Br, Sr), Bsr), length(Bsr, Nr), Nr >= N— distinct reporters, not raw report count.mod/decide-quorumasserts every report'sreport/3fact (the base engine only asserts the decided one) so Prolog can aggregate reporters. Verified one user filing 3 reports stays:keepunder 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-sanctionscounts prior hide/remove/ban decisions about a subject;mod/decide-escalating id kdecides normally then upgrades a sanction to:banwhen 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-strictestcollects every proven rule (pl-query-all) and picks the highest-mod/action-severityaction (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-rulesflags 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-namesmod/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-idsandmod/reporters-offind reports about a subject via a Prolog relational query (report(Id, _, 'subject')) — the policy substrate reused for retrieval.mod/dedup-reportscollapses identical reports (reporter|subject|reason key, case-insensitive);mod/distinct-reporters-ofcounts unique reporters.
Progress log
- Ext 18 — ergonomic defrule / ruleset, 375/375 (+11). Closes the roadmap's
original
defrulesurface.fnsupports&resthere, and conditions evaluate to plain data, so no macro is needed — variadic functions give the ergonomics safely. Equivalence tomk-ruleis 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
rulesas 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 —:anyof:nots, an:anyANDed 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) — overslice/len, same toolkit asstr-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-firedpairs 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-reportby 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-allfor 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 growingextensions.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 greenlib/prologsubstrate. - Ext 3 — proof explanation, 164/164 (+10).
mod/explainturns 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).
:attrand:notconditions 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-invalidatedre-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 forlib/guest/if acl-sx grows a federation phase.
- Liftable (acl-sx watch): the trust registry (
- 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:errorrather than throwing (the env's error handling is untested, so this keeps tests deterministic). Tier logic: triage runs the engine, an:escalateaction parks the case at the human tier wheremod/case-resolveis blocked untilmod/case-reviewsupplies evidence. Appeal-override works because the newexonerated-keeprule 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_nearinserts 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 withsx_write_file.
- Gotcha:
- Phase 2 complete — 60/60 (+29 audit). Evidence accumulation, constructive
proof trees, append-only audit log. A decision's
:proof :goalsis 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 carriesN=3, S=<subject>straight from unification — not a reconstruction. Evidence is asserted asevidence(Id, 'kind', 'val'); the newreviewer-removerule (placed first = highest precedence) lets human review override automated classification.mod/decidenow 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 liftingproof-goals+ the audit-log primitives intolib/guest/.
- Liftable (acl-sx watch): the proof-tree builder (
- 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 bypl-query-one; a trailingtrue-bodied default rule makes "no rule matched" a real:keep, not a query failure. Evidence (spam/abuse classification) derived in SX and asserted asclassification/2facts; 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!). Implementedmod/str-contains?overslice/lenrather than relying onincludes?. 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 tolib/guest/until both mod-sx and acl-sx are past Phase 2.
- Gotcha (env): loading the prolog libs strips
Blockers
(none)