Merge loops/mod into architecture: mod-on-sx moderation engine on Prolog
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 49s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 49s
Moderation-on-Prolog layer in lib/mod: report schema, policy DSL (boolean algebra + count/score/reporters/burst conditions), proof-carrying engine, append-only audit, lifecycle state machine + escalation/appeal, federation (advisory trust, wire format, ActivityPub export), plus repeat-offender, quorum, temporal burst, analytics (trace/whatif/lint/batch/explain/linking), domain policies, and an end-to-end triage pipeline. Roadmap (4 phases) + 19 extensions, 390/390. Imports lib/prolog only; Prolog unmodified.
This commit is contained in:
136
plans/agent-briefings/mod-loop.md
Normal file
136
plans/agent-briefings/mod-loop.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# mod-on-sx loop agent (single agent, queue-driven)
|
||||
|
||||
Role: iterates `plans/mod-on-sx.md` forever. **Moderation on Prolog** — reports,
|
||||
policy rules, decisions as backtracking proof search, audit trails, escalation
|
||||
state machine, federation. Where acl-sx asks "may this happen?", mod-sx asks
|
||||
"should this stay?" Sits on `lib/prolog/` (its test suite already green); adds a
|
||||
moderation-shaped vocabulary on top.
|
||||
|
||||
```
|
||||
description: mod-on-sx queue loop
|
||||
subagent_type: general-purpose
|
||||
run_in_background: true
|
||||
isolation: worktree
|
||||
```
|
||||
|
||||
## Prompt
|
||||
|
||||
You are the sole background agent working `plans/mod-on-sx.md`. Isolated worktree
|
||||
`/root/rose-ash-loops/mod` on branch `loops/mod`, forever, one commit per feature.
|
||||
Push to `origin/loops/mod` after every commit. Never touch `main` or `architecture`.
|
||||
|
||||
## Restart baseline — check before iterating
|
||||
|
||||
1. Read `plans/mod-on-sx.md` — roadmap + Progress log.
|
||||
2. `ls lib/mod/` — pick up from the most advanced file.
|
||||
3. If `lib/mod/tests/*.sx` exist, run them via `bash lib/mod/conformance.sh`. Green
|
||||
before new work.
|
||||
4. If `lib/mod/scoreboard.md` exists, that's your baseline.
|
||||
5. Read the `lib/prolog/` public API once — that's your substrate. The plan cites
|
||||
`lib/prolog/prolog.sx` but that file does **not** exist; the real entry points
|
||||
are `lib/prolog/runtime.sx`, `query.sx`, `compiler.sx`, `parser.sx`. Investigate
|
||||
them (sx_find_all / grep for `(define ` heads) to learn how to assert facts and
|
||||
run queries before writing any policy code.
|
||||
|
||||
## The queue
|
||||
|
||||
Phase order per `plans/mod-on-sx.md`:
|
||||
|
||||
- **Phase 1** — report representation + simple policy (schema, defrule→clause,
|
||||
`(decide id)` query, api). Tests: spam keyword → hide, repeated reports →
|
||||
escalate, no rule → keep.
|
||||
- **Phase 2** — evidence accumulation + audit trail (proof tree from derivation,
|
||||
append-only decision log, retrieval).
|
||||
- **Phase 3** — escalation + lifecycle state machine
|
||||
(`:open → :triaged → :decided → :appealed → :final`), auto/human tiers, appeal.
|
||||
- **Phase 4** — federation (cross-instance reports, decision sharing, trust model,
|
||||
revocation; mock fed-sx in tests).
|
||||
|
||||
Within a phase, pick the checkbox that unlocks the most tests per effort.
|
||||
|
||||
Every iteration: implement → test → commit → tick `[ ]` → Progress log → next.
|
||||
|
||||
## Ground rules (hard)
|
||||
|
||||
- **Scope:** only `lib/mod/**` and `plans/mod-on-sx.md`. Do **not** edit `spec/`,
|
||||
`hosts/`, `shared/`, other `lib/<lang>/` dirs, `lib/stdlib.sx`, or `lib/` root.
|
||||
May **import** from `lib/prolog/` only (its public API). Do **not** modify Prolog.
|
||||
- **NEVER call `sx_build`.** 600s watchdog. If the sx_server binary is broken →
|
||||
Blockers entry, stop. Run tests by invoking the sx_server binary directly from a
|
||||
conformance.sh (see how `lib/prolog/conformance.sh` drives it), pointing
|
||||
`SX_SERVER` at `/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe`
|
||||
(fresh worktrees have no `_build/`).
|
||||
- **Shared-file issues** → plan's Blockers with minimal repro; don't fix here.
|
||||
- **SX files:** `sx-tree` MCP tools ONLY. **They take `file:` not `path:`** — a
|
||||
wrong key yields `Yojson Type_error("Expected string, got null")`, which looks
|
||||
like a broken binary but is just a param mismatch. `sx_validate` after edits.
|
||||
Path-based edits (`sx_replace_node`) count comment headers in their indices and
|
||||
can clobber the wrong node — re-read after, or prefer `sx_write_file` for small
|
||||
files. **Default to `sx_write_file` (rewrite the whole file) over path/pattern
|
||||
edits** — these are small files and the rewrite always parses-before-writing.
|
||||
`sx_insert_near` inserts only the FIRST top-level form of a multi-form source
|
||||
(it silently drops the rest; byte count barely moves) — never use it to add a
|
||||
block of forms; rewrite the file instead. `sx_replace_by_pattern` is fiddly to
|
||||
match — don't fight it, just rewrite.
|
||||
- **Unicode in `.sx`:** raw UTF-8 only, never `\uXXXX` escapes.
|
||||
- **Commit granularity:** one feature per commit. Short factual messages
|
||||
(`mod: spam-keyword policy rule → :hide + 6 tests`). Push to `origin/loops/mod`.
|
||||
- **Plan file:** update Progress log (newest first) + tick boxes every commit.
|
||||
|
||||
## mod-specific gotchas
|
||||
|
||||
- **Decisions are proofs, not booleans.** A decision should carry *why* — the
|
||||
matching rule / derivation — so Phase 2's audit trail can persist it. Design the
|
||||
Phase-1 `decide` return shape with that in mind (don't return a bare keyword you
|
||||
later have to retrofit).
|
||||
- **Policy chains backtrack.** Order matters: first matching rule wins. Make rule
|
||||
precedence explicit and deterministic (tests will depend on it). A "no rule
|
||||
matched" outcome must be a real, testable result (`:keep`), not a query failure
|
||||
you forget to handle.
|
||||
- **You may lean on backtracking and cut.** The substrate is full Prolog —
|
||||
`pl-query-all` gives every proven clause (use it for "strictest-wins" or
|
||||
multi-match analysis), `pl-query-one` gives the first (clause order = precedence).
|
||||
Cut (`!`) and the other control constructs are available if you need to prune
|
||||
alternatives inside a body, but for rule precedence prefer plain clause ordering
|
||||
resolved by `pl-query-one` — it's the clean, testable default. Don't hand-roll
|
||||
precedence in SX when the engine's backtracking already gives it to you.
|
||||
- **Negative decisions need closed-world care.** "No evidence of violation" vs
|
||||
"evidence absent" differ. Be explicit about negation-as-failure where you use it.
|
||||
In this substrate, negation is the **functor** `not(Goal)` / `\+(Goal)` — the
|
||||
prefix `\+ Goal` operator does **not** parse. Unknown predicates *fail* (no
|
||||
existence error), so a report lacking some fact safely falls through a rule that
|
||||
references it. Quote user-data atoms (`'foo-bar'`) — a bare hyphen is the minus
|
||||
operator and will misparse.
|
||||
- **Loaded-env strips the high-level string prims.** After the prolog preloads are
|
||||
loaded, the eval env loses `includes?`, `chars`, `str-join`, `keyword` and
|
||||
friends — they are **undefined** (a function calling one fails only when called,
|
||||
often mid-test-load, looking like a mystery crash). Only the set the Prolog
|
||||
tokenizer itself uses survives: `slice`, `len`, `nth`, `=`, `join` (sep first:
|
||||
`(join sep list)`), `downcase`, `map`, `reduce`, `append`/`append!`, `when`,
|
||||
`cond`, `if`, `let`, `begin`, `get`, `dict-get`, `keys`, `empty?`, `first`,
|
||||
`reverse`, `+`, `-`, `<`, `<=`. Build substring search yourself over `slice`/
|
||||
`len` (see `mod/str-contains?`). Treat `not`, `and`, `or`, `>` as suspect in
|
||||
guest code unless you've confirmed them — nest `if`/`when` and use `(< a b)`.
|
||||
- **Lifecycle state is separate from policy.** Keep the state machine (Phase 3) as
|
||||
an SX module over the engine, not tangled into Prolog rules.
|
||||
- **Federation trust is advisory by default.** A peer's decision only binds locally
|
||||
when `(trust peer :mod)` holds; otherwise it's a suggestion. Don't auto-apply.
|
||||
|
||||
## General gotchas (all loops)
|
||||
|
||||
- SX `do` = R7RS iteration. Use `begin` for multi-expr sequences.
|
||||
- `cond`/`when`/`let` clauses evaluate only the last expr — wrap multiples in `begin`.
|
||||
- `let` is parallel, not sequential — nest `let`s when a binding references an earlier one.
|
||||
- `env-bind!` creates a binding; `env-set!` mutates an existing one (walks scope chain).
|
||||
- `sx_validate` after every structural edit.
|
||||
- Namespace-prefix all guest helpers (`mod/...`) — short/host-colliding names
|
||||
(`bind`, `conj`, `name`) get silently shadowed or hang the runtime.
|
||||
|
||||
## Style
|
||||
|
||||
- No comments in `.sx` unless non-obvious.
|
||||
- No new planning docs — update `plans/mod-on-sx.md` inline.
|
||||
- Short, factual commit messages.
|
||||
- One feature per iteration. Commit. Log. Push. Next.
|
||||
|
||||
Go. Start by reading the plan; find the first unchecked `[ ]`; implement it.
|
||||
@@ -16,7 +16,7 @@ federation extension.
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/mod/conformance.sh` → **0/0** (not yet started)
|
||||
`bash lib/mod/conformance.sh` → **390/390** (roadmap + 19 extensions complete)
|
||||
|
||||
## Ground rules
|
||||
|
||||
@@ -66,47 +66,373 @@ lib/mod/fed.sx
|
||||
|
||||
## Phase 1 — Report representation + simple policy
|
||||
|
||||
- [ ] `lib/mod/schema.sx` — `report(id, by, about, reason)`, `evidence(id, kind, val)`,
|
||||
`policy-action(report, action)` predicates as Prolog facts/rules
|
||||
- [ ] `lib/mod/policy.sx` — rule declarations: `(defrule action :when conditions)`
|
||||
desugars to Prolog clause
|
||||
- [ ] `lib/mod/engine.sx` — `(decide report-id)` runs Prolog query, returns first
|
||||
matching action
|
||||
- [ ] `lib/mod/api.sx` — `(mod/report by about reason)`, `(mod/decide id)`
|
||||
- [ ] `lib/mod/tests/decide.sx` — 15+ cases: spam keyword → hide, repeated reports →
|
||||
escalate, no rule matches → keep
|
||||
- [ ] `lib/mod/scoreboard.{json,md}`
|
||||
- [ ] `lib/mod/conformance.sh`
|
||||
- [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
|
||||
|
||||
- [ ] evidence accumulation — additional facts asserted before query
|
||||
- [ ] proof tree from Prolog derivation tree
|
||||
- [ ] `lib/mod/audit.sx` — append-only log (decision + proof + evidence snapshot)
|
||||
- [ ] `(mod/audit id)` retrieval
|
||||
- [ ] `lib/mod/tests/audit.sx` — proof correctness, trail completeness
|
||||
- [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
|
||||
|
||||
- [ ] state machine: `:open → :triaged → :decided → :appealed → :final`
|
||||
- [ ] auto-tier: first-pass rules decide quick cases
|
||||
- [ ] human-tier: rules that emit `:escalate` move to next state
|
||||
- [ ] appeal: re-runs with appeal evidence, may override prior decision
|
||||
- [ ] `(mod/appeal id new-evidence)` API
|
||||
- [ ] `lib/mod/tests/escalation.sx` — full lifecycle traversal cases
|
||||
- [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
|
||||
|
||||
- [ ] cross-instance reports — peer raises report about local content (or vice versa)
|
||||
- [ ] decision sharing — actions taken locally propagate to peers via fed-sx
|
||||
- [ ] trust model — peer's decision is advisory unless `(trust peer :mod)` is granted
|
||||
- [ ] revocation — undo applied moderation if proof was invalidated
|
||||
- [ ] `lib/mod/tests/fed.sx` — federated decision chains (mock fed-sx in tests)
|
||||
- [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 — post-merge integration note
|
||||
|
||||
mod-sx (Prolog) and acl-sx (Datalog, `lib/acl/`, 120/120) independently converged
|
||||
on the same module shape: `schema / engine / audit / explain / federation / api`.
|
||||
That parallel is the signal both plans flagged. **Recommendation: do NOT extract
|
||||
from a loop branch — extract at the architecture-merge integration point, after
|
||||
both `lib/mod` and `lib/acl` have landed, refactoring both consumers in one change.**
|
||||
|
||||
- **Different engines.** acl = Datalog bottom-up (native derivation trees); mod =
|
||||
Prolog backtracking (proof via per-goal `pl-query-all`). The engine and most of
|
||||
`explain` are NOT shared — same intent, different mechanism. Don't try to unify them.
|
||||
- **Genuinely convergent shapes (the only real candidates):**
|
||||
- **Append-only audit log** — `{seq, payload, retrieve-by-id}`; both have it (~40
|
||||
lines). Lift to e.g. `lib/guest/audit-log.sx` parameterized by the entry payload.
|
||||
- **Federation trust/outbox** — advisory-unless-`(trust peer :scope)` + a send
|
||||
seam; both have it. Lift the trust registry + outbox; keep `:scope` a parameter
|
||||
(`:mod` vs `:acl`).
|
||||
- **Trivia not worth a module:** `join-with`, `any?`, `str-contains?`, `distinct`.
|
||||
- **Why not now:** the branches merge independently; lifting from one leaves the
|
||||
other's copy un-refactored → duplication, not sharing. Real extraction must touch
|
||||
both consumers atomically, which only the post-merge integrator can do. Designing
|
||||
the abstraction also needs both payload shapes in view (only mod's is visible here).
|
||||
|
||||
## Progress log
|
||||
|
||||
(loop fills this in)
|
||||
- **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
|
||||
|
||||
(loop fills this in)
|
||||
(none)
|
||||
|
||||
Reference in New Issue
Block a user