diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index 5e5e0921..dc704baa 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -16,10 +16,12 @@ PRELOADS=( lib/mod/lifecycle.sx lib/mod/audit.sx lib/mod/api.sx + lib/mod/fed.sx ) SUITES=( "decide:lib/mod/tests/decide.sx:(mod-decide-tests-run!)" "audit:lib/mod/tests/audit.sx:(mod-audit-tests-run!)" "escalation:lib/mod/tests/escalation.sx:(mod-escalation-tests-run!)" + "fed:lib/mod/tests/fed.sx:(mod-fed-tests-run!)" ) diff --git a/lib/mod/fed.sx b/lib/mod/fed.sx new file mode 100644 index 00000000..855f71f8 --- /dev/null +++ b/lib/mod/fed.sx @@ -0,0 +1,145 @@ +;; lib/mod/fed.sx — federation: cross-instance reports, decision sharing, trust, +;; revocation. fed-sx itself is mocked here (an in-memory outbox); the real wire +;; transport would replace mod/fed-send!. +;; +;; Trust is advisory by default (the hard rule): a peer's decision only binds +;; locally when (mod/trusted? peer :mod) holds. An untrusted peer's decision is +;; recorded as a suggestion in the advisory log and is NOT applied. Local +;; decisions propagate outward via the outbox. Revocation undoes a locally +;; applied action when its proof is invalidated, notifying the origin peer. + +(define mod/*fed-trust* (list)) ;; {:peer :scope} +(define mod/*fed-outbox* (list)) ;; {:to :type :payload} +(define mod/*fed-advisory* (list)) ;; {:peer :decision} — received, not applied +(define mod/*fed-applied* (list)) ;; {:report-id :action :origin :revoked} +(define mod/*fed-origins* (list)) ;; {:id :origin} + +(define + mod/fed-reset! + (fn + () + (begin + (set! mod/*fed-trust* (list)) + (set! mod/*fed-outbox* (list)) + (set! mod/*fed-advisory* (list)) + (set! mod/*fed-applied* (list)) + (set! mod/*fed-origins* (list))))) + +;; ── trust model ── + +(define + mod/trust-match? + (fn + (t peer scope) + (if (= (get t :peer) peer) (= (get t :scope) scope) false))) + +(define + mod/grant-trust + (fn (peer scope) (begin (append! mod/*fed-trust* {:scope scope :peer peer}) true))) + +(define + mod/revoke-trust + (fn + (peer scope) + (set! + mod/*fed-trust* + (reduce + (fn + (acc t) + (if (mod/trust-match? t peer scope) acc (append acc (list t)))) + (list) + mod/*fed-trust*)))) + +(define + mod/trusted? + (fn + (peer scope) + (mod/any? (fn (t) (mod/trust-match? t peer scope)) mod/*fed-trust*))) + +;; ── cross-instance reports ── + +(define + mod/fed-receive-report + (fn + (peer by about reason) + (let + ((r (mod/report by about reason))) + (begin (append! mod/*fed-origins* {:id (mod/report-id r) :origin peer}) r)))) + +(define + mod/report-origin + (fn + (id) + (reduce + (fn (acc o) (if (= (get o :id) id) (get o :origin) acc)) + "local" + mod/*fed-origins*))) + +;; ── decision sharing (mock fed-sx send) ── + +(define + mod/fed-send! + (fn (to type payload) (begin (append! mod/*fed-outbox* {:type type :to to :payload payload}) true))) + +(define mod/fed-outbox (fn () mod/*fed-outbox*)) + +(define + mod/fed-share-decision + (fn + (decision peers) + (reduce + (fn + (acc p) + (begin (mod/fed-send! p "decision" decision) (append acc (list p)))) + (list) + peers))) + +;; ── receiving a peer's decision (advisory unless trusted) ── + +(define + mod/fed-applied-action + (fn + (report-id) + (reduce + (fn (acc a) (if (= (get a :report-id) report-id) a acc)) + nil + mod/*fed-applied*))) + +(define + mod/fed-receive-decision + (fn + (peer decision) + (if + (mod/trusted? peer :mod) + (begin (append! mod/*fed-applied* {:revoked false :action (get decision :action) :report-id (get decision :report-id) :origin peer}) {:advisory false :peer peer :applied true :decision decision}) + (begin (append! mod/*fed-advisory* {:peer peer :decision decision}) {:advisory true :peer peer :applied false :decision decision})))) + +;; ── revocation ── + +(define + mod/fed-revoke! + (fn + (report-id reason) + (begin + (set! + mod/*fed-applied* + (map + (fn (a) (if (= (get a :report-id) report-id) {:revoked true :action (get a :action) :report-id (get a :report-id) :origin (get a :origin)} a)) + mod/*fed-applied*)) + (mod/fed-send! (mod/report-origin report-id) "revocation" {:report-id report-id :reason reason}) + report-id))) + +;; re-run the engine; if the action no longer holds, the prior decision's proof +;; is invalidated — revoke the applied moderation. +(define + mod/fed-revoke-if-invalidated + (fn + (report decision reports rules) + (let + ((d2 (mod/decide-report report reports rules))) + (if + (= (get d2 :action) (get decision :action)) + {:revoked false :decision d2} + (begin + (mod/fed-revoke! (get decision :report-id) "proof invalidated") + {:revoked true :decision d2}))))) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index 50d894b5..3ebb8e38 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,12 +1,13 @@ { "lang": "mod", - "total_passed": 106, + "total_passed": 132, "total_failed": 0, - "total": 106, + "total": 132, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, - {"name":"escalation","passed":46,"failed":0,"total":46} + {"name":"escalation","passed":46,"failed":0,"total":46}, + {"name":"fed","passed":26,"failed":0,"total":26} ], - "generated": "2026-06-06T17:49:32+00:00" + "generated": "2026-06-06T17:54:02+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index c063d904..108e0273 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,9 +1,10 @@ # mod scoreboard -**106 / 106 passing** (0 failure(s)). +**132 / 132 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| | decide | 31 | 31 | ok | | audit | 29 | 29 | ok | | escalation | 46 | 46 | ok | +| fed | 26 | 26 | ok | diff --git a/lib/mod/tests/fed.sx b/lib/mod/tests/fed.sx new file mode 100644 index 00000000..0dc73bd2 --- /dev/null +++ b/lib/mod/tests/fed.sx @@ -0,0 +1,154 @@ +;; lib/mod/tests/fed.sx — Phase 4: federation (mock fed-sx). + +(define mod-fed-count 0) +(define mod-fed-pass 0) +(define mod-fed-fail 0) +(define mod-fed-failures (list)) + +(define + mod-fed-test! + (fn + (name got expected) + (begin + (set! mod-fed-count (+ mod-fed-count 1)) + (if + (= got expected) + (set! mod-fed-pass (+ mod-fed-pass 1)) + (begin + (set! mod-fed-fail (+ mod-fed-fail 1)) + (append! + mod-fed-failures + (str name "\n expected: " expected "\n got: " got))))))) + +(mod/reset!) +(mod/fed-reset!) + +;; ── trust model (advisory by default) ── + +(mod-fed-test! "trust initially false" (mod/trusted? "peerA" :mod) false) +(mod/grant-trust "peerA" :mod) +(mod-fed-test! "trust after grant" (mod/trusted? "peerA" :mod) true) +(mod-fed-test! "trust wrong scope" (mod/trusted? "peerA" :other) false) +(mod-fed-test! "trust other peer" (mod/trusted? "peerB" :mod) false) +(mod/revoke-trust "peerA" :mod) +(mod-fed-test! "trust after revoke" (mod/trusted? "peerA" :mod) false) + +;; ── cross-instance reports ── + +(define + mod-fed-fr + (mod/fed-receive-report "peerB" "alice" "bob" "this is spam")) +(mod-fed-test! "fed report assigned id r1" (mod/report-id mod-fed-fr) "r1") +(mod-fed-test! "fed report origin is peer" (mod/report-origin "r1") "peerB") +(define mod-fed-local (mod/report "carol" "dave" "fine post")) +(mod-fed-test! + "local report origin is local" + (mod/report-origin (mod/report-id mod-fed-local)) + "local") +(mod-fed-test! + "engine decides fed report (spam → hide)" + (get + (mod/decide-report mod-fed-fr (list mod-fed-fr) mod/default-rules) + :action) + "hide") + +;; ── decision sharing (outbox) ── + +(define mod-fed-dec {:action "hide" :rule "spam-hide" :report-id "r1"}) +(define + mod-fed-shared + (mod/fed-share-decision mod-fed-dec (list "peerB" "peerC"))) +(mod-fed-test! "share returns notified peers" (len mod-fed-shared) 2) +(mod-fed-test! "outbox has two messages" (len (mod/fed-outbox)) 2) +(mod-fed-test! + "outbox message type decision" + (get (first (mod/fed-outbox)) :type) + "decision") +(mod-fed-test! + "outbox message addressed to peer" + (get (first (mod/fed-outbox)) :to) + "peerB") + +;; ── receiving a peer decision: advisory unless trusted ── + +(define mod-fed-untrusted (mod/fed-receive-decision "peerZ" {:action "remove" :rule "reviewer-remove" :report-id "rx"})) +(mod-fed-test! + "untrusted decision not applied" + (get mod-fed-untrusted :applied) + false) +(mod-fed-test! + "untrusted decision advisory" + (get mod-fed-untrusted :advisory) + true) +(mod-fed-test! + "untrusted decision absent from applied log" + (mod/fed-applied-action "rx") + nil) +(mod-fed-test! + "advisory log records suggestion" + (len mod/*fed-advisory*) + 1) + +(mod/grant-trust "peerT" :mod) +(define mod-fed-trusted (mod/fed-receive-decision "peerT" {:action "hide" :rule "spam-hide" :report-id "ry"})) +(mod-fed-test! "trusted decision applied" (get mod-fed-trusted :applied) true) +(mod-fed-test! + "trusted decision binds locally" + (get (mod/fed-applied-action "ry") :action) + "hide") + +;; ── revocation ── + +(mod-fed-test! + "applied action not yet revoked" + (get (mod/fed-applied-action "ry") :revoked) + false) +(mod/fed-revoke! "ry" "manual") +(mod-fed-test! + "revoke marks applied action revoked" + (get (mod/fed-applied-action "ry") :revoked) + true) +(mod-fed-test! + "revoke emits a revocation message" + (mod/any? (fn (m) (= (get m :type) "revocation")) (mod/fed-outbox)) + true) + +;; revoke-if-invalidated: proof still holds → no revocation +(define mod-fed-spam-r (mod/mk-report "rs" "a" "b" "this is spam")) +(define + mod-fed-spam-d + (mod/decide-report mod-fed-spam-r (list mod-fed-spam-r) mod/default-rules)) +(mod-fed-test! "spam decision is hide" (get mod-fed-spam-d :action) "hide") +(define + mod-fed-rev-same + (mod/fed-revoke-if-invalidated + mod-fed-spam-r + mod-fed-spam-d + (list mod-fed-spam-r) + mod/default-rules)) +(mod-fed-test! + "valid proof → not revoked" + (get mod-fed-rev-same :revoked) + false) + +;; exoneration invalidates the proof → revocation +(define + mod-fed-exon-r + (mod/attach-evidence mod-fed-spam-r (mod/mk-evidence "exonerated" "mod"))) +(define + mod-fed-rev-inv + (mod/fed-revoke-if-invalidated + mod-fed-exon-r + mod-fed-spam-d + (list mod-fed-exon-r) + mod/default-rules)) +(mod-fed-test! + "invalidated proof → revoked" + (get mod-fed-rev-inv :revoked) + true) +(mod-fed-test! + "re-decision after exoneration is keep" + (get (get mod-fed-rev-inv :decision) :action) + "keep") + +(define mod-fed-tests-run! (fn () {:failures mod-fed-failures :total mod-fed-count :passed mod-fed-pass :failed mod-fed-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 01821c18..8634b8d6 100644 --- a/plans/mod-on-sx.md +++ b/plans/mod-on-sx.md @@ -16,7 +16,7 @@ federation extension. ## Status (rolling) -`bash lib/mod/conformance.sh` → **106/106** (Phases 1–3 complete) +`bash lib/mod/conformance.sh` → **132/132** (Phases 1–4 complete — roadmap done) ## Ground rules @@ -113,14 +113,37 @@ lib/mod/fed.sx ## 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) ## 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