Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 55s
Cross-instance reports ingest into the local registry with origin tags; the engine decides them unchanged. Decision sharing pushes to a mock fed-sx outbox (mod/fed-send! is the transport seam). Trust is advisory by default: a peer's decision binds locally only under (mod/trusted? peer :mod), else it lands in the advisory log unapplied. Revocation composes with the Phase-2 proof model — fed-revoke-if-invalidated re-runs the engine and undoes moderation only when the action no longer holds (exoneration flips hide→keep → revoked + origin notified). +26 fed tests. Full mod-on-sx roadmap complete. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
199 lines
12 KiB
Markdown
199 lines
12 KiB
Markdown
# 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` → **132/132** (Phases 1–4 complete — roadmap done)
|
||
|
||
## 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)
|
||
|
||
## Progress log
|
||
|
||
- **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)
|