diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index e558811f..ee19cec8 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -22,6 +22,7 @@ PRELOADS=( lib/mod/batch.sx lib/mod/temporal.sx lib/mod/sla.sx + lib/mod/wire.sx lib/mod/lifecycle.sx lib/mod/audit.sx lib/mod/api.sx @@ -46,4 +47,5 @@ SUITES=( "batch:lib/mod/tests/batch.sx:(mod-batch-tests-run!)" "temporal:lib/mod/tests/temporal.sx:(mod-temporal-tests-run!)" "sla:lib/mod/tests/sla.sx:(mod-sla-tests-run!)" + "wire:lib/mod/tests/wire.sx:(mod-wire-tests-run!)" ) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index 97f2beb8..005313c5 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,8 +1,8 @@ { "lang": "mod", - "total_passed": 307, + "total_passed": 323, "total_failed": 0, - "total": 307, + "total": 323, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, @@ -18,7 +18,8 @@ {"name":"whatif","passed":13,"failed":0,"total":13}, {"name":"batch","passed":17,"failed":0,"total":17}, {"name":"temporal","passed":15,"failed":0,"total":15}, - {"name":"sla","passed":15,"failed":0,"total":15} + {"name":"sla","passed":15,"failed":0,"total":15}, + {"name":"wire","passed":16,"failed":0,"total":16} ], - "generated": "2026-06-06T19:08:06+00:00" + "generated": "2026-06-06T19:16:49+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 680ef546..21b21cf7 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**307 / 307 passing** (0 failure(s)). +**323 / 323 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -19,3 +19,4 @@ | batch | 17 | 17 | ok | | temporal | 15 | 15 | ok | | sla | 15 | 15 | ok | +| wire | 16 | 16 | ok | diff --git a/lib/mod/tests/wire.sx b/lib/mod/tests/wire.sx new file mode 100644 index 00000000..fd070a67 --- /dev/null +++ b/lib/mod/tests/wire.sx @@ -0,0 +1,96 @@ +;; lib/mod/tests/wire.sx — Ext 14: decision wire format + federated transport. + +(define mod-w-count 0) +(define mod-w-pass 0) +(define mod-w-fail 0) +(define mod-w-failures (list)) + +(define + mod-w-test! + (fn + (name got expected) + (begin + (set! mod-w-count (+ mod-w-count 1)) + (if + (= got expected) + (set! mod-w-pass (+ mod-w-pass 1)) + (begin + (set! mod-w-fail (+ mod-w-fail 1)) + (append! + mod-w-failures + (str name "\n expected: " expected "\n got: " got))))))) + +;; ── split-char ── + +(mod-w-test! "split on pipe" (mod/split-char "a|b|c" "|") (list "a" "b" "c")) +(mod-w-test! "split single field" (mod/split-char "abc" "|") (list "abc")) +(mod-w-test! + "split four fields" + (len (mod/split-char "MOD1|r1|hide|spam-hide" "|")) + 4) + +;; ── serialize ── + +(define + mod-w-dec + (mod/decide-report + (mod/mk-report "r1" "a" "bob" "this is spam") + (list (mod/mk-report "r1" "a" "bob" "this is spam")) + mod/default-rules)) +(define mod-w-line (mod/decision->wire mod-w-dec)) + +(mod-w-test! + "wire is versioned + delimited" + mod-w-line + "MOD1|r1|hide|spam-hide") +(mod-w-test! + "wire-valid? accepts well-formed" + (mod/wire-valid? mod-w-line) + true) +(mod-w-test! + "wire-valid? rejects junk" + (mod/wire-valid? "not a wire line") + false) +(mod-w-test! + "wire-valid? rejects wrong version" + (mod/wire-valid? "MOD9|r1|hide|x") + false) + +;; ── round-trip ── + +(define mod-w-back (mod/wire->decision mod-w-line)) +(mod-w-test! "round-trip report-id" (get mod-w-back :report-id) "r1") +(mod-w-test! "round-trip action" (get mod-w-back :action) "hide") +(mod-w-test! "round-trip rule" (get mod-w-back :rule) "spam-hide") +(mod-w-test! "round-trip tags :wire" (get mod-w-back :wire) true) +(mod-w-test! "malformed → nil" (mod/wire->decision "garbage") nil) + +;; ── full federated transport: serialize → wire → deserialize → trust-gate ── + +(mod/fed-reset!) +(define mod-w-peer-dec (mod/wire->decision mod-w-line)) + +;; untrusted peer: decision is advisory, not applied +(define mod-w-recv1 (mod/fed-receive-decision "peerX" mod-w-peer-dec)) +(mod-w-test! + "wired decision from untrusted peer → advisory" + (get mod-w-recv1 :applied) + false) +(mod-w-test! + "untrusted wired decision not applied locally" + (mod/fed-applied-action "r1") + nil) + +;; trusted peer: decision binds locally +(mod/grant-trust "peerY" :mod) +(define mod-w-recv2 (mod/fed-receive-decision "peerY" mod-w-peer-dec)) +(mod-w-test! + "wired decision from trusted peer → applied" + (get mod-w-recv2 :applied) + true) +(mod-w-test! + "trusted wired decision binds locally" + (get (mod/fed-applied-action "r1") :action) + "hide") + +(define mod-wire-tests-run! (fn () {:failures mod-w-failures :total mod-w-count :passed mod-w-pass :failed mod-w-fail})) diff --git a/lib/mod/wire.sx b/lib/mod/wire.sx new file mode 100644 index 00000000..6ed0ef85 --- /dev/null +++ b/lib/mod/wire.sx @@ -0,0 +1,55 @@ +;; lib/mod/wire.sx — portable decision wire format for federation transport. +;; +;; fed.sx shares decisions as in-memory dicts and leaves mod/fed-send! as the +;; transport seam. This is the bytes that cross it: a versioned, pipe-delimited +;; line encoding the verdict a peer needs (report id, action, rule) — enough to +;; trust-gate and apply/advise, without shipping the whole proof tree. The +;; loaded env has no string split, so split is built over slice/len. + +(define + mod/split-loop + (fn + (s ch n start pos acc) + (if + (= pos n) + (append acc (list (slice s start n))) + (if + (= (slice s pos (+ pos 1)) ch) + (mod/split-loop + s + ch + n + (+ pos 1) + (+ pos 1) + (append acc (list (slice s start pos)))) + (mod/split-loop s ch n start (+ pos 1) acc))))) + +(define + mod/split-char + (fn (s ch) (mod/split-loop s ch (len s) 0 0 (list)))) + +(define + mod/decision->wire + (fn + (d) + (str "MOD1|" (get d :report-id) "|" (get d :action) "|" (get d :rule)))) + +(define + mod/wire-valid? + (fn + (w) + (let + ((parts (mod/split-char w "|"))) + (if + (= (len parts) 4) + (= (nth parts 0) "MOD1") + false)))) + +(define + mod/wire->decision + (fn + (w) + (if + (mod/wire-valid? w) + (let ((parts (mod/split-char w "|"))) {:action (nth parts 2) :wire true :rule (nth parts 3) :report-id (nth parts 1)}) + nil))) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 742919eb..3595a15d 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` → **307/307** (roadmap + 13 extensions complete) +`bash lib/mod/conformance.sh` → **323/323** (roadmap + 14 extensions complete) ## Ground rules @@ -147,6 +147,12 @@ lib/mod/fed.sx 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 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) @@ -207,6 +213,11 @@ lib/mod/fed.sx ## Progress log +- **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)