diff --git a/lib/persist/conformance.sh b/lib/persist/conformance.sh index 6d100448..14de2028 100755 --- a/lib/persist/conformance.sh +++ b/lib/persist/conformance.sh @@ -13,7 +13,7 @@ if [ ! -x "$SX_SERVER" ]; then exit 1 fi -SUITES=(event log kv project subscribe concurrency snapshot compaction durable blob view cas catalog query batch upcast idempotency global recovery) +SUITES=(event log kv project subscribe concurrency snapshot compaction durable blob view cas catalog query batch upcast idempotency global example-acl recovery) OUT_JSON="lib/persist/scoreboard.json" OUT_MD="lib/persist/scoreboard.md" @@ -44,6 +44,7 @@ run_suite() { (load "lib/persist/upcast.sx") (load "lib/persist/idempotency.sx") (load "lib/persist/global.sx") +(load "lib/persist/examples/acl.sx") (load "lib/persist/subscribe.sx") (load "lib/persist/api.sx") (epoch 2) diff --git a/lib/persist/examples/acl.sx b/lib/persist/examples/acl.sx new file mode 100644 index 00000000..f35cac81 --- /dev/null +++ b/lib/persist/examples/acl.sx @@ -0,0 +1,79 @@ +; persist/examples/acl — a WORKED MIGRATION REFERENCE. A subsystem (acl grants: +; who may access what) currently hand-rolls an in-memory mutable map that loses +; every grant on restart and keeps no audit trail. This shows the same subsystem +; rebuilt on persist. It is the template other subsystem loops copy; it does NOT +; touch the real lib/acl (out of this loop's scope). +; +; BEFORE — hand-rolled, ephemeral, no history, no concurrency safety: +; (define acl-grants {}) ; resource -> principal list (mutable) +; (define acl-grant! (fn (r p) (set! acl-grants (assoc acl-grants r (cons p (get acl-grants r)))))) +; (define acl-revoke! (fn (r p) (set! acl-grants (assoc acl-grants r (remove p ...))))) +; (define acl-can? (fn (r p) (contains? (get acl-grants r) p))) +; ;; vanishes on restart; "when/why was X granted?" is unanswerable. +; +; AFTER — on persist. Grants/revokes are EVENTS (history matters), the current +; grant set is a PROJECTION, checks read a materialized VIEW, and the audit trail +; is a time-windowed query. Every fn takes a backend `b`, so the same code runs +; on the in-memory backend today and the durable backend unchanged. +; Requires: lib/persist/log.sx, lib/persist/project.sx, lib/persist/view.sx, +; lib/persist/query.sx. + +(define acl/stream (fn (resource) (str "acl/" resource))) + +; write side — grant/revoke append events (the history is the source of truth) +(define + acl/grant + (fn + (b resource principal at) + (persist/append b (acl/stream resource) "granted" at {:principal principal}))) +(define + acl/revoke + (fn + (b resource principal at) + (persist/append b (acl/stream resource) "revoked" at {:principal principal}))) + +; fold step: grant adds a principal (once), revoke removes it +(define + acl/step + (fn + (set e) + (let + ((p (get (persist/event-data e) :principal))) + (if + (equal? (persist/event-type e) "granted") + (if (contains? set p) set (append set p)) + (filter (fn (x) (not (equal? x p))) set))))) + +; read side — current grant set + membership check (replays the log) +(define + acl/grants + (fn + (b resource) + (persist/project-fold b (acl/stream resource) acl/step (list)))) +(define + acl/can? + (fn (b resource principal) (contains? (acl/grants b resource) principal))) + +; materialized view — attach to a hub for O(1) checks that stay current on write +(define + acl/view + (fn + (resource) + (persist/view + (str "acl-current/" resource) + (acl/stream resource) + acl/step + (list)))) +(define + acl/can-fast? + (fn + (b resource principal) + (contains? (persist/view-peek b (acl/view resource)) principal))) + +; audit — grants/revokes for a resource in a time window (the new capability the +; hand-rolled version could never answer) +(define + acl/audit-window + (fn + (b resource from to) + (persist/read-window b (acl/stream resource) from to))) diff --git a/lib/persist/scoreboard.json b/lib/persist/scoreboard.json index ac995fa1..57235fdd 100644 --- a/lib/persist/scoreboard.json +++ b/lib/persist/scoreboard.json @@ -18,9 +18,10 @@ "upcast": {"pass": 9, "fail": 0}, "idempotency": {"pass": 9, "fail": 0}, "global": {"pass": 11, "fail": 0}, + "example-acl": {"pass": 10, "fail": 0}, "recovery": {"pass": 6, "fail": 0} }, - "total_pass": 191, + "total_pass": 201, "total_fail": 0, - "total": 191 + "total": 201 } diff --git a/lib/persist/scoreboard.md b/lib/persist/scoreboard.md index ada4507f..e21eab7f 100644 --- a/lib/persist/scoreboard.md +++ b/lib/persist/scoreboard.md @@ -22,5 +22,6 @@ _Generated by `lib/persist/conformance.sh`_ | upcast | 9 | 0 | 9 | | idempotency | 9 | 0 | 9 | | global | 11 | 0 | 11 | +| example-acl | 10 | 0 | 10 | | recovery | 6 | 0 | 6 | -| **Total** | **191** | **0** | **191** | +| **Total** | **201** | **0** | **201** | diff --git a/lib/persist/tests/example-acl.sx b/lib/persist/tests/example-acl.sx new file mode 100644 index 00000000..b9f01f59 --- /dev/null +++ b/lib/persist/tests/example-acl.sx @@ -0,0 +1,104 @@ +; Reference migration — acl grants on persist. Proves the AFTER behaviour, +; including the capabilities the hand-rolled BEFORE version could not provide +; (durability across restart + an audit trail). + +(persist-test + "grant then can?" + (let + ((b (persist/open))) + (begin + (acl/grant b "doc-1" "alice" 0) + (acl/can? b "doc-1" "alice"))) + true) +(persist-test + "no grant means no access" + (acl/can? (persist/open) "doc-1" "alice") + false) +(persist-test + "revoke removes access" + (let + ((b (persist/open))) + (begin + (acl/grant b "doc-1" "alice" 0) + (acl/revoke b "doc-1" "alice" 1) + (acl/can? b "doc-1" "alice"))) + false) +(persist-test + "multiple principals tracked independently" + (let + ((b (persist/open))) + (begin + (acl/grant b "doc-1" "alice" 0) + (acl/grant b "doc-1" "bob" 1) + (acl/revoke b "doc-1" "alice" 2) + (list (acl/can? b "doc-1" "alice") (acl/can? b "doc-1" "bob")))) + (list false true)) +(persist-test + "granting twice is idempotent in the set" + (let + ((b (persist/open))) + (begin + (acl/grant b "doc-1" "alice" 0) + (acl/grant b "doc-1" "alice" 1) + (len (acl/grants b "doc-1")))) + 1) +(persist-test + "grants on different resources are isolated" + (let + ((b (persist/open))) + (begin + (acl/grant b "doc-1" "alice" 0) + (acl/grant b "doc-2" "bob" 0) + (list (acl/can? b "doc-1" "bob") (acl/can? b "doc-2" "bob")))) + (list false true)) +(persist-test + "audit window answers when-was-it-granted (new capability)" + (let + ((b (persist/open))) + (begin + (acl/grant b "doc-1" "alice" 100) + (acl/revoke b "doc-1" "alice" 200) + (acl/grant b "doc-1" "bob" 300) + (len (acl/audit-window b "doc-1" 150 300)))) + 2) +(persist-test + "materialized view stays current on publish" + (let + ((b (persist/open))) + (let + ((h (persist/view-attach (persist/hub b) (acl/view "doc-1")))) + (begin + (persist/publish + h + (acl/stream "doc-1") + "granted" + 0 + {:principal "alice"}) + (acl/can-fast? b "doc-1" "alice")))) + true) +(persist-test + "grants survive restart on the durable backend (the headline win)" + (let + ((disk (persist/mem-backend))) + (begin + (let + ((db (persist/mock-durable disk))) + (begin + (acl/grant db "doc-1" "alice" 0) + (acl/grant db "doc-1" "bob" 1))) + (let + ((db2 (persist/mock-durable disk))) + (list (acl/can? db2 "doc-1" "alice") (acl/can? db2 "doc-1" "bob"))))) + (list true true)) +(persist-test + "revoke before restart is still revoked after" + (let + ((disk (persist/mem-backend))) + (begin + (let + ((db (persist/mock-durable disk))) + (begin + (acl/grant db "doc-1" "alice" 0) + (acl/revoke db "doc-1" "alice" 1))) + (acl/can? (persist/mock-durable disk) "doc-1" "alice"))) + false) diff --git a/plans/persist-on-sx.md b/plans/persist-on-sx.md index 25b6ba0a..17b70e2b 100644 --- a/plans/persist-on-sx.md +++ b/plans/persist-on-sx.md @@ -42,7 +42,7 @@ read models (feeds, indices, audit logs) update incrementally. ## Status (rolling) -`bash lib/persist/conformance.sh` → **191/191** (Phases 1–4 complete + extensions) +`bash lib/persist/conformance.sh` → **201/201** (Phases 1–4 complete + extensions + a reference migration) ## Ground rules @@ -188,7 +188,22 @@ over an in-process disk (the mock-IO harness). feed/-log, flow store, mod/audit, search index, acl grants, identity sessions all become `persist` log or kv. Track each migration in that subsystem's plan. +**Reference migration:** `lib/persist/examples/acl.sx` is a worked, tested +template — an ACL-grants store rebuilt on persist (grants/revokes as events, +current set as a projection, O(1) checks via a materialized view, an audit-window +query). It carries an explicit BEFORE (hand-rolled ephemeral map) → AFTER +diff in its header and proves the headline win (grants survive restart) on the +durable backend. Other subsystem loops copy this pattern; it does not touch the +real `lib/acl`. + ## Progress log +- **Reference migration: acl grants (201/201).** `lib/persist/examples/acl.sx` — + a worked, in-scope template migrating an ACL-grants store from a hand-rolled + ephemeral map to persist: grants/revokes as events, current set as a + projection, O(1) checks via a materialized view, audit via `read-window`. + Header carries the BEFORE→AFTER diff. 10 tests, incl. grants surviving restart + on the durable backend (the capability the BEFORE version lacked). The pattern + other subsystem loops copy. - **Ext: global commit ordering (191/191).** `global.sx` — `persist/gappend` records a pointer in a reserved `$global` index (its seq = global commit position); `read-global`/`project-global` resolve pointers to events in commit