diff --git a/lib/identity/audit.sx b/lib/identity/audit.sx new file mode 100644 index 00000000..2e0f25b5 --- /dev/null +++ b/lib/identity/audit.sx @@ -0,0 +1,27 @@ +;; identity/audit.sx — the grant audit ledger. +;; +;; Every transition that changes a grant — issue, refresh, revoke (and, +;; wired from oauth, consent) — appends an immutable event to this +;; append-only process. The ledger is queryable by subject, which is what +;; `(identity/audit subject)` answers. This is the in-memory realisation +;; of the event stream; a persist-backed stream is a later substrate +;; concern (Erlang↔persist bridge), kept out of scope here per the loop's +;; \"in-memory log until persist lands\" allowance — the queryable +;; semantics are identical. +;; +;; Events are {Seq, Subject, Action}; Seq is a monotonic sequence number. +;; Reads return chronological (oldest-first) order: +;; +;; record(A, Subject, Action) -> ok (one-way; FIFO-ordered) +;; audit(A, Subject) -> [{Seq, Subject, Action}, ...] +;; actions(A, Subject) -> [Action, ...] +;; count(A, Subject) -> N +;; all(A) -> [{Seq, Subject, Action}, ...] + +(define + identity-audit-source + "-module(identity_audit).\n\n start() ->\n spawn(fun () -> loop([], 0) end).\n\n record(A, Subject, Action) ->\n A ! {event, Subject, Action},\n ok.\n\n audit(A, Subject) ->\n A ! {audit, Subject, self()},\n receive {audit_reply, R} -> R end.\n\n actions(A, Subject) ->\n A ! {actions, Subject, self()},\n receive {audit_reply, R} -> R end.\n\n count(A, Subject) ->\n A ! {count, Subject, self()},\n receive {audit_reply, R} -> R end.\n\n all(A) ->\n A ! {all, self()},\n receive {audit_reply, R} -> R end.\n\n loop(Events, Seq) ->\n receive\n {event, Subject, Action} ->\n loop([{Seq, Subject, Action} | Events], Seq + 1);\n {audit, Subject, From} ->\n From ! {audit_reply, collect(Subject, Events, [])},\n loop(Events, Seq);\n {actions, Subject, From} ->\n From ! {audit_reply, action_list(Subject, Events, [])},\n loop(Events, Seq);\n {count, Subject, From} ->\n From ! {audit_reply, count_subj(Subject, Events, 0)},\n loop(Events, Seq);\n {all, From} ->\n From ! {audit_reply, reverse(Events, [])},\n loop(Events, Seq);\n {stop, From} ->\n From ! {audit_reply, ok}\n end.\n\n collect(_, [], Acc) -> Acc;\n collect(Subject, [{Seq, S, A} | Rest], Acc) ->\n case S =:= Subject of\n true -> collect(Subject, Rest, [{Seq, S, A} | Acc]);\n false -> collect(Subject, Rest, Acc)\n end.\n\n action_list(_, [], Acc) -> Acc;\n action_list(Subject, [{_, S, A} | Rest], Acc) ->\n case S =:= Subject of\n true -> action_list(Subject, Rest, [A | Acc]);\n false -> action_list(Subject, Rest, Acc)\n end.\n\n count_subj(_, [], N) -> N;\n count_subj(Subject, [{_, S, _} | Rest], N) ->\n case S =:= Subject of\n true -> count_subj(Subject, Rest, N + 1);\n false -> count_subj(Subject, Rest, N)\n end.\n\n reverse([], Acc) -> Acc;\n reverse([H | T], Acc) -> reverse(T, [H | Acc]).") + +(define + identity-load-audit! + (fn () (erlang-load-module identity-audit-source))) diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh index db90c03e..c8610af2 100755 --- a/lib/identity/conformance.sh +++ b/lib/identity/conformance.sh @@ -36,6 +36,7 @@ SUITES=( "sso|id-sso-test-pass|id-sso-test-count" "membership|id-membership-test-pass|id-membership-test-count" "cache|id-cache-test-pass|id-cache-test-count" + "audit|id-audit-test-pass|id-audit-test-count" ) cat > "$TMPFILE" << 'EPOCHS' @@ -54,6 +55,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/oauth.sx") (load "lib/identity/membership.sx") (load "lib/identity/cache.sx") +(load "lib/identity/audit.sx") (load "lib/identity/tests/session.sx") (load "lib/identity/tests/token.sx") (load "lib/identity/tests/registry.sx") @@ -62,6 +64,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/tests/sso.sx") (load "lib/identity/tests/membership.sx") (load "lib/identity/tests/cache.sx") +(load "lib/identity/tests/audit.sx") (epoch 100) (eval "(list id-session-test-pass id-session-test-count)") (epoch 101) @@ -78,6 +81,8 @@ cat > "$TMPFILE" << 'EPOCHS' (eval "(list id-membership-test-pass id-membership-test-count)") (epoch 107) (eval "(list id-cache-test-pass id-cache-test-count)") +(epoch 108) +(eval "(list id-audit-test-pass id-audit-test-count)") EPOCHS timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 diff --git a/lib/identity/scoreboard.json b/lib/identity/scoreboard.json index 750ae4b8..ff952f8b 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -1,7 +1,7 @@ { "language": "identity", - "total_pass": 101, - "total": 101, + "total_pass": 111, + "total": 111, "suites": [ {"name":"session","pass":11,"total":11,"status":"ok"}, {"name":"token","pass":18,"total":18,"status":"ok"}, @@ -10,6 +10,7 @@ {"name":"oauth","pass":17,"total":17,"status":"ok"}, {"name":"sso","pass":10,"total":10,"status":"ok"}, {"name":"membership","pass":17,"total":17,"status":"ok"}, - {"name":"cache","pass":9,"total":9,"status":"ok"} + {"name":"cache","pass":9,"total":9,"status":"ok"}, + {"name":"audit","pass":10,"total":10,"status":"ok"} ] } diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index b5be1538..8206db67 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -1,6 +1,6 @@ # identity-on-sx Scoreboard -**Total: 101 / 101 tests passing** +**Total: 111 / 111 tests passing** | | Suite | Pass | Total | |---|---|---|---| @@ -12,6 +12,7 @@ | ✅ | sso | 10 | 10 | | ✅ | membership | 17 | 17 | | ✅ | cache | 9 | 9 | +| ✅ | audit | 10 | 10 | Generated by `lib/identity/conformance.sh`. diff --git a/lib/identity/tests/audit.sx b/lib/identity/tests/audit.sx new file mode 100644 index 00000000..4d8652a4 --- /dev/null +++ b/lib/identity/tests/audit.sx @@ -0,0 +1,109 @@ +;; identity/tests/audit.sx — the grant audit ledger. Every grant +;; transition is recorded; the ledger is queryable per subject and +;; chronological. Covers issue/refresh/revoke wiring through the token +;; registry, reuse-triggered revoke, per-subject isolation, and direct +;; ledger use. + +(define id-audit-test-count 0) +(define id-audit-test-pass 0) +(define id-audit-test-fails (list)) + +(define + id-audit-test + (fn + (name actual expected) + (set! id-audit-test-count (+ id-audit-test-count 1)) + (if + (= actual expected) + (set! id-audit-test-pass (+ id-audit-test-pass 1)) + (append! id-audit-test-fails {:name name :expected expected :actual actual})))) + +(define ida-ev erlang-eval-ast) +(define idanm (fn (v) (get v :name))) + +(identity-load-audit!) +(identity-load-token!) + +;; ── issue is audited ───────────────────────────────────────────── + +(id-audit-test + "issue records one event for the subject" + (ida-ev + "A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n identity_audit:count(A, alice)") + 1) + +(id-audit-test + "the recorded action is issue" + (idanm + (ida-ev + "A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n case identity_audit:actions(A, alice) of\n [issue] -> matched;\n _ -> nomatch\n end")) + "matched") + +;; ── full grant lifecycle is audited in order ───────────────────── + +(id-audit-test + "issue, refresh, revoke are recorded in order" + (idanm + (ida-ev + "A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n {ok, G, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n identity_tokens:refresh(Reg, R),\n identity_tokens:revoke(Reg, G),\n case identity_audit:actions(A, alice) of\n [issue, refresh, revoke] -> matched;\n _ -> nomatch\n end")) + "matched") + +;; ── reuse-triggered revoke is audited ──────────────────────────── + +(id-audit-test + "a refresh-reuse cascade records a revoke event" + (idanm + (ida-ev + "A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n {ok, _G, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n identity_tokens:refresh(Reg, R),\n identity_tokens:refresh(Reg, R),\n case identity_audit:actions(A, alice) of\n [issue, refresh, revoke] -> matched;\n _ -> nomatch\n end")) + "matched") + +;; ── per-subject isolation ──────────────────────────────────────── + +(id-audit-test + "the ledger separates subjects" + (ida-ev + "A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n identity_tokens:issue(Reg, bob, cli, write),\n identity_tokens:issue(Reg, alice, mobile, read),\n identity_audit:count(A, alice)") + 2) + +(id-audit-test + "an unaudited subject has zero events" + (ida-ev + "A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n identity_audit:count(A, ghost)") + 0) + +;; ── the full log accumulates across subjects ───────────────────── + +(id-audit-test + "all events accumulate in the ledger" + (ida-ev + "A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n identity_tokens:issue(Reg, bob, cli, write),\n length(identity_audit:all(A))") + 2) + +;; ── start/0 stays unaudited (no regression) ────────────────────── + +(id-audit-test + "an unaudited registry still issues working tokens" + (idanm + (ida-ev + "Reg = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(Reg, alice, web, read),\n case identity_tokens:introspect(Reg, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) + "active") + +;; ── direct ledger use (e.g. login/consent events) ──────────────── + +(id-audit-test + "events can be recorded directly on the ledger" + (idanm + (ida-ev + "A = identity_audit:start(),\n identity_audit:record(A, alice, login),\n identity_audit:record(A, alice, consent),\n case identity_audit:actions(A, alice) of\n [login, consent] -> matched;\n _ -> nomatch\n end")) + "matched") + +(id-audit-test + "an audit entry carries its subject" + (idanm + (ida-ev + "A = identity_audit:start(),\n identity_audit:record(A, alice, login),\n case identity_audit:audit(A, alice) of\n [{_, Subject, _}] -> Subject;\n _ -> nomatch\n end")) + "alice") + +(define + id-audit-test-summary + (str "audit " id-audit-test-pass "/" id-audit-test-count)) diff --git a/lib/identity/token.sx b/lib/identity/token.sx index 9c4cb58c..f41f5dd3 100644 --- a/lib/identity/token.sx +++ b/lib/identity/token.sx @@ -15,17 +15,23 @@ ;; superseded refresh token is treated as token theft (RFC 6819 §5.2.2.3): ;; the entire grant is revoked, killing the legitimate descendant too. ;; +;; Auditing: start/1 takes an audit ledger (identity_audit); every grant +;; transition (issue, refresh, revoke — including a reuse-triggered +;; revoke) appends an event. start/0 passes `none` and audits nothing, so +;; standalone use is unchanged. +;; ;; introspect reply shapes (RFC 7662 §2.2): ;; {active, Subject, Client, Scope} | {inactive} ;; -;; State threaded through loop/4: +;; State threaded through loop/5: ;; Grants : [{Gid, {Subject, Client, Scope, active|revoked}}] ;; Access : [{AccessTok, Gid}] ;; Refresh : [{RefreshTok, {Gid, current|superseded}}] +;; Audit : an identity_audit pid, or the atom none (define identity-token-source - "-module(identity_tokens).\n\n start() ->\n spawn(fun () -> loop([], [], [], 1) end).\n\n issue(Reg, Subject, Client, Scope) ->\n Reg ! {issue, Subject, Client, Scope, self()},\n receive {token_reply, R} -> R end.\n\n issue_grant(Reg, Subject, Client, Scope) ->\n Reg ! {issue_grant, Subject, Client, Scope, self()},\n receive {token_reply, R} -> R end.\n\n refresh(Reg, RefreshTok) ->\n Reg ! {refresh, RefreshTok, self()},\n receive {token_reply, R} -> R end.\n\n introspect(Reg, Token) ->\n Reg ! {introspect, Token, self()},\n receive {token_reply, R} -> R end.\n\n revoke(Reg, Token) ->\n Reg ! {revoke, Token, self()},\n receive {token_reply, R} -> R end.\n\n stop(Reg) ->\n Reg ! {stop, self()},\n receive {token_reply, R} -> R end.\n\n loop(Grants, Access, Refresh, NextGid) ->\n receive\n {issue, Subject, Client, Scope, From} ->\n Gid = NextGid,\n Tok = make_ref(),\n From ! {token_reply, {ok, Tok}},\n loop([{Gid, {Subject, Client, Scope, active}} | Grants],\n [{Tok, Gid} | Access], Refresh, NextGid + 1);\n {issue_grant, Subject, Client, Scope, From} ->\n Gid = NextGid,\n A = make_ref(),\n R = make_ref(),\n From ! {token_reply, {ok, A, R}},\n loop([{Gid, {Subject, Client, Scope, active}} | Grants],\n [{A, Gid} | Access],\n [{R, {Gid, current}} | Refresh],\n NextGid + 1);\n {refresh, RTok, From} ->\n case find(RTok, Refresh) of\n none ->\n From ! {token_reply, {error, invalid_grant}},\n loop(Grants, Access, Refresh, NextGid);\n {ok, {Gid, superseded}} ->\n From ! {token_reply, {error, invalid_grant}},\n loop(set_status(Gid, revoked, Grants), Access, Refresh, NextGid);\n {ok, {Gid, current}} ->\n case grant_active(Gid, Grants) of\n false ->\n From ! {token_reply, {error, invalid_grant}},\n loop(Grants, Access, Refresh, NextGid);\n true ->\n {Su, Cl, Sc} = grant_info(Gid, Grants),\n A2 = make_ref(),\n R2 = make_ref(),\n From ! {token_reply, {ok, A2, R2}},\n loop(Grants,\n [{A2, Gid} | Access],\n [{R2, {Gid, current}} | supersede(RTok, Refresh)],\n NextGid)\n end\n end;\n {introspect, Tok, From} ->\n From ! {token_reply, introspect_access(Tok, Access, Grants)},\n loop(Grants, Access, Refresh, NextGid);\n {revoke, Tok, From} ->\n From ! {token_reply, ok},\n case find_gid(Tok, Access, Refresh) of\n none -> loop(Grants, Access, Refresh, NextGid);\n {ok, Gid} -> loop(set_status(Gid, revoked, Grants), Access, Refresh, NextGid)\n end;\n {stop, From} ->\n From ! {token_reply, ok}\n end.\n\n introspect_access(Tok, Access, Grants) ->\n case find(Tok, Access) of\n none -> {inactive};\n {ok, Gid} ->\n case find(Gid, Grants) of\n none -> {inactive};\n {ok, {Su, Cl, Sc, active}} -> {active, Su, Cl, Sc};\n {ok, {_, _, _, revoked}} -> {inactive}\n end\n end.\n\n find_gid(Tok, Access, Refresh) ->\n case find(Tok, Access) of\n {ok, Gid} -> {ok, Gid};\n none ->\n case find(Tok, Refresh) of\n {ok, {Gid, _}} -> {ok, Gid};\n none -> none\n end\n end.\n\n grant_active(Gid, Grants) ->\n case find(Gid, Grants) of\n {ok, {_, _, _, active}} -> true;\n Other -> false\n end.\n\n grant_info(Gid, Grants) ->\n case find(Gid, Grants) of\n {ok, {Su, Cl, Sc, _}} -> {Su, Cl, Sc}\n end.\n\n set_status(_, _, []) -> [];\n set_status(Gid, St, [{G, {Su, Cl, Sc, Old}} | Rest]) ->\n case G =:= Gid of\n true -> [{G, {Su, Cl, Sc, St}} | Rest];\n false -> [{G, {Su, Cl, Sc, Old}} | set_status(Gid, St, Rest)]\n end.\n\n supersede(_, []) -> [];\n supersede(RTok, [{T, {Gid, St}} | Rest]) ->\n case T =:= RTok of\n true -> [{T, {Gid, superseded}} | Rest];\n false -> [{T, {Gid, St}} | supersede(RTok, Rest)]\n end.\n\n find(_, []) -> none;\n find(Key, [{K, V} | Rest]) ->\n case K =:= Key of\n true -> {ok, V};\n false -> find(Key, Rest)\n end.") + "-module(identity_tokens).\n\n start() ->\n start(none).\n\n start(Audit) ->\n spawn(fun () -> loop([], [], [], 1, Audit) end).\n\n issue(Reg, Subject, Client, Scope) ->\n Reg ! {issue, Subject, Client, Scope, self()},\n receive {token_reply, R} -> R end.\n\n issue_grant(Reg, Subject, Client, Scope) ->\n Reg ! {issue_grant, Subject, Client, Scope, self()},\n receive {token_reply, R} -> R end.\n\n refresh(Reg, RefreshTok) ->\n Reg ! {refresh, RefreshTok, self()},\n receive {token_reply, R} -> R end.\n\n introspect(Reg, Token) ->\n Reg ! {introspect, Token, self()},\n receive {token_reply, R} -> R end.\n\n revoke(Reg, Token) ->\n Reg ! {revoke, Token, self()},\n receive {token_reply, R} -> R end.\n\n stop(Reg) ->\n Reg ! {stop, self()},\n receive {token_reply, R} -> R end.\n\n loop(Grants, Access, Refresh, NextGid, Audit) ->\n receive\n {issue, Subject, Client, Scope, From} ->\n Gid = NextGid,\n Tok = make_ref(),\n From ! {token_reply, {ok, Tok}},\n audit_event(Audit, Subject, issue),\n loop([{Gid, {Subject, Client, Scope, active}} | Grants],\n [{Tok, Gid} | Access], Refresh, NextGid + 1, Audit);\n {issue_grant, Subject, Client, Scope, From} ->\n Gid = NextGid,\n A = make_ref(),\n R = make_ref(),\n From ! {token_reply, {ok, A, R}},\n audit_event(Audit, Subject, issue),\n loop([{Gid, {Subject, Client, Scope, active}} | Grants],\n [{A, Gid} | Access],\n [{R, {Gid, current}} | Refresh],\n NextGid + 1, Audit);\n {refresh, RTok, From} ->\n case find(RTok, Refresh) of\n none ->\n From ! {token_reply, {error, invalid_grant}},\n loop(Grants, Access, Refresh, NextGid, Audit);\n {ok, {Gid, superseded}} ->\n From ! {token_reply, {error, invalid_grant}},\n audit_grant(Audit, Gid, Grants, revoke),\n loop(set_status(Gid, revoked, Grants), Access, Refresh, NextGid, Audit);\n {ok, {Gid, current}} ->\n case grant_active(Gid, Grants) of\n false ->\n From ! {token_reply, {error, invalid_grant}},\n loop(Grants, Access, Refresh, NextGid, Audit);\n true ->\n {Su, Cl, Sc} = grant_info(Gid, Grants),\n A2 = make_ref(),\n R2 = make_ref(),\n From ! {token_reply, {ok, A2, R2}},\n audit_event(Audit, Su, refresh),\n loop(Grants,\n [{A2, Gid} | Access],\n [{R2, {Gid, current}} | supersede(RTok, Refresh)],\n NextGid, Audit)\n end\n end;\n {introspect, Tok, From} ->\n From ! {token_reply, introspect_access(Tok, Access, Grants)},\n loop(Grants, Access, Refresh, NextGid, Audit);\n {revoke, Tok, From} ->\n From ! {token_reply, ok},\n case find_gid(Tok, Access, Refresh) of\n none -> loop(Grants, Access, Refresh, NextGid, Audit);\n {ok, Gid} ->\n audit_grant(Audit, Gid, Grants, revoke),\n loop(set_status(Gid, revoked, Grants), Access, Refresh, NextGid, Audit)\n end;\n {stop, From} ->\n From ! {token_reply, ok}\n end.\n\n audit_event(none, _, _) -> ok;\n audit_event(Audit, Subject, Action) ->\n Audit ! {event, Subject, Action},\n ok.\n\n audit_grant(none, _, _, _) -> ok;\n audit_grant(Audit, Gid, Grants, Action) ->\n {Su, _, _} = grant_info(Gid, Grants),\n Audit ! {event, Su, Action},\n ok.\n\n introspect_access(Tok, Access, Grants) ->\n case find(Tok, Access) of\n none -> {inactive};\n {ok, Gid} ->\n case find(Gid, Grants) of\n none -> {inactive};\n {ok, {Su, Cl, Sc, active}} -> {active, Su, Cl, Sc};\n {ok, {_, _, _, revoked}} -> {inactive}\n end\n end.\n\n find_gid(Tok, Access, Refresh) ->\n case find(Tok, Access) of\n {ok, Gid} -> {ok, Gid};\n none ->\n case find(Tok, Refresh) of\n {ok, {Gid, _}} -> {ok, Gid};\n none -> none\n end\n end.\n\n grant_active(Gid, Grants) ->\n case find(Gid, Grants) of\n {ok, {_, _, _, active}} -> true;\n Other -> false\n end.\n\n grant_info(Gid, Grants) ->\n case find(Gid, Grants) of\n {ok, {Su, Cl, Sc, _}} -> {Su, Cl, Sc}\n end.\n\n set_status(_, _, []) -> [];\n set_status(Gid, St, [{G, {Su, Cl, Sc, Old}} | Rest]) ->\n case G =:= Gid of\n true -> [{G, {Su, Cl, Sc, St}} | Rest];\n false -> [{G, {Su, Cl, Sc, Old}} | set_status(Gid, St, Rest)]\n end.\n\n supersede(_, []) -> [];\n supersede(RTok, [{T, {Gid, St}} | Rest]) ->\n case T =:= RTok of\n true -> [{T, {Gid, superseded}} | Rest];\n false -> [{T, {Gid, St}} | supersede(RTok, Rest)]\n end.\n\n find(_, []) -> none;\n find(Key, [{K, V} | Rest]) ->\n case K =:= Key of\n true -> {ok, V};\n false -> find(Key, Rest)\n end.") (define identity-load-token! diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index 7ac033e0..dd3ce2fb 100644 --- a/plans/identity-on-sx.md +++ b/plans/identity-on-sx.md @@ -19,7 +19,7 @@ through the event log, all authorization questions delegated to `acl-on-sx`. ## Status (rolling) -`bash lib/identity/conformance.sh` → **101/101** (Phases 1–3 complete) +`bash lib/identity/conformance.sh` → **111/111** (Phases 1–3 + audit ledger) ## Ground rules @@ -73,11 +73,19 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [x] grant verification delegated cache (mirror Redis-cache pattern) ## Phase 4 — Audit + federation -- [ ] every issue/refresh/revoke is a `persist` event; `(identity/audit subject)` +- [x] every issue/refresh/revoke is a `persist` event; `(identity/audit subject)` - [ ] federated identity (peer-asserted subject) — advisory, trust-gated stub - [ ] tests: audit completeness, cross-instance subject mapping ## Progress log +- 2026-06-07 — `audit.sx`: append-only grant audit ledger (an Erlang + process). `token.sx` gains `start/1(Audit)` and emits issue/refresh/revoke + events (incl. reuse-triggered revoke); `start/0` stays unaudited (no + regression — token.sx has no compile-time dep on the audit module, just + sends to a pid). Ledger queryable per subject — `audit`/`actions`/`count`/ + `all`, chronological. In-memory event stream (persist-backing is a future + Erlang↔persist bridge, out of scope per loop allowance). New + tests/audit.sx (10). +10 → 111/111. - 2026-06-07 — `cache.sx`: delegated grant-verification cache (Redis-cache pattern) wrapping the token registry. introspect memoised; generation invalidation keeps revocation real — any revoke/refresh bumps a generation