From 1c6b80404ec867299252bf5e9039ff46b2939c06 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 23:45:50 +0000 Subject: [PATCH] =?UTF-8?q?identity:=20session-as-process=20=E2=80=94=20cr?= =?UTF-8?q?eate/lookup/expire/revoke=20+=20idle=20timeout=20(11=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session is an Erlang process holding {subject, client, status}. lookup/ touch/expire/revoke are messages; expiry is the process's own `receive ... after Ttl` timeout (RFC-agnostic; no global sweep), which notifies the owner and tombstones. Tombstoned sessions answer lookups with an explicit {error, expired|revoked}, never a silent dead mailbox. Adds the conformance harness + scoreboard. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/identity/conformance.sh | 120 ++++++++++++++++++++++++++++++++++ lib/identity/scoreboard.json | 8 +++ lib/identity/scoreboard.md | 10 +++ lib/identity/session.sx | 20 ++++++ lib/identity/tests/session.sx | 118 +++++++++++++++++++++++++++++++++ plans/identity-on-sx.md | 11 +++- 6 files changed, 284 insertions(+), 3 deletions(-) create mode 100755 lib/identity/conformance.sh create mode 100644 lib/identity/scoreboard.json create mode 100644 lib/identity/scoreboard.md create mode 100644 lib/identity/session.sx create mode 100644 lib/identity/tests/session.sx diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh new file mode 100755 index 00000000..6baf4266 --- /dev/null +++ b/lib/identity/conformance.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# identity-on-sx conformance runner. +# +# Loads the Erlang-on-SX substrate, the identity library, and every +# identity test suite via the epoch protocol, collects pass/fail counts, +# and writes lib/identity/scoreboard.json + .md. +# +# Usage: +# bash lib/identity/conformance.sh # run all suites +# bash lib/identity/conformance.sh -v # verbose per-suite + +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" + +SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}" +if [ ! -x "$SX_SERVER" ]; then + SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe" +fi +if [ ! -x "$SX_SERVER" ]; then + echo "ERROR: sx_server.exe not found." >&2 + exit 1 +fi + +VERBOSE="${1:-}" +TMPFILE=$(mktemp) +OUTFILE=$(mktemp) +trap "rm -f $TMPFILE $OUTFILE" EXIT + +# Each suite: name | counter pass | counter total +SUITES=( + "session|id-session-test-pass|id-session-test-count" +) + +cat > "$TMPFILE" << 'EPOCHS' +(epoch 1) +(load "lib/erlang/tokenizer.sx") +(load "lib/erlang/parser.sx") +(load "lib/erlang/parser-core.sx") +(load "lib/erlang/parser-expr.sx") +(load "lib/erlang/parser-module.sx") +(load "lib/erlang/transpile.sx") +(load "lib/erlang/runtime.sx") +(load "lib/identity/session.sx") +(load "lib/identity/tests/session.sx") +(epoch 100) +(eval "(list id-session-test-pass id-session-test-count)") +EPOCHS + +timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 + +parse_pair() { + local epoch="$1" + local line + line=$(grep -A1 "^(ok-len $epoch " "$OUTFILE" | tail -1) + echo "$line" | sed -E 's/[()]//g' +} + +TOTAL_PASS=0 +TOTAL_COUNT=0 +JSON_SUITES="" +MD_ROWS="" + +idx=0 +for entry in "${SUITES[@]}"; do + name="${entry%%|*}" + epoch=$((100 + idx)) + pair=$(parse_pair "$epoch") + pass=$(echo "$pair" | awk '{print $1}') + count=$(echo "$pair" | awk '{print $2}') + if [ -z "$pass" ] || [ -z "$count" ]; then + pass=0 + count=0 + fi + TOTAL_PASS=$((TOTAL_PASS + pass)) + TOTAL_COUNT=$((TOTAL_COUNT + count)) + status="ok" + marker="✅" + if [ "$pass" != "$count" ]; then + status="fail" + marker="❌" + fi + if [ "$VERBOSE" = "-v" ]; then + printf " %-12s %s/%s\n" "$name" "$pass" "$count" + fi + if [ -n "$JSON_SUITES" ]; then JSON_SUITES+=","; fi + JSON_SUITES+=$'\n ' + JSON_SUITES+="{\"name\":\"$name\",\"pass\":$pass,\"total\":$count,\"status\":\"$status\"}" + MD_ROWS+="| $marker | $name | $pass | $count |"$'\n' + idx=$((idx + 1)) +done + +printf '\nidentity-on-sx conformance: %d / %d\n' "$TOTAL_PASS" "$TOTAL_COUNT" + +cat > lib/identity/scoreboard.json < lib/identity/scoreboard.md <\n spawn(fun () -> active(SessionId, Subject, Client, Owner, Ttl) end).\n\n lookup(Pid) ->\n Pid ! {lookup, self()},\n receive {session_reply, R} -> R end.\n\n touch(Pid) ->\n Pid ! {touch, self()},\n receive {session_reply, R} -> R end.\n\n expire(Pid) ->\n Pid ! {expire, self()},\n receive {session_reply, R} -> R end.\n\n revoke(Pid) ->\n Pid ! {revoke, self()},\n receive {session_reply, R} -> R end.\n\n stop(Pid) ->\n Pid ! {stop, self()},\n receive {session_reply, R} -> R end.\n\n active(SessionId, Subject, Client, Owner, Ttl) ->\n receive\n {lookup, From} ->\n From ! {session_reply, {ok, {SessionId, Subject, Client, active}}},\n active(SessionId, Subject, Client, Owner, Ttl);\n {touch, From} ->\n From ! {session_reply, ok},\n active(SessionId, Subject, Client, Owner, Ttl);\n {expire, From} ->\n From ! {session_reply, ok},\n tombstone(SessionId, Subject, Client, expired);\n {revoke, From} ->\n From ! {session_reply, ok},\n tombstone(SessionId, Subject, Client, revoked);\n {stop, From} ->\n From ! {session_reply, ok}\n after Ttl ->\n Owner ! {session_expired, SessionId},\n tombstone(SessionId, Subject, Client, expired)\n end.\n\n tombstone(SessionId, Subject, Client, Status) ->\n receive\n {lookup, From} ->\n From ! {session_reply, {error, Status}},\n tombstone(SessionId, Subject, Client, Status);\n {touch, From} ->\n From ! {session_reply, {error, Status}},\n tombstone(SessionId, Subject, Client, Status);\n {expire, From} ->\n From ! {session_reply, ok},\n tombstone(SessionId, Subject, Client, Status);\n {revoke, From} ->\n From ! {session_reply, ok},\n tombstone(SessionId, Subject, Client, revoked);\n {stop, From} ->\n From ! {session_reply, ok}\n end.") + +(define + identity-load-session! + (fn () (erlang-load-module identity-session-source))) diff --git a/lib/identity/tests/session.sx b/lib/identity/tests/session.sx new file mode 100644 index 00000000..b30bfeb5 --- /dev/null +++ b/lib/identity/tests/session.sx @@ -0,0 +1,118 @@ +;; identity/tests/session.sx — session-as-process: create, lookup, +;; touch, explicit expire, revoke, and idle-timeout self-expiry. +;; Negative paths are tested as first-class: a tombstoned session +;; answers {error, Status}, it does not go silent. + +(define id-session-test-count 0) +(define id-session-test-pass 0) +(define id-session-test-fails (list)) + +(define + id-session-test + (fn + (name actual expected) + (set! id-session-test-count (+ id-session-test-count 1)) + (if + (= actual expected) + (set! id-session-test-pass (+ id-session-test-pass 1)) + (append! id-session-test-fails {:name name :expected expected :actual actual})))) + +(define id-ev erlang-eval-ast) +(define idnm (fn (v) (get v :name))) + +(identity-load-session!) + +;; ── create + lookup ────────────────────────────────────────────── + +(id-session-test + "lookup of live session is active" + (idnm + (id-ev + "Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n case identity_session:lookup(S) of {ok, {_,_,_,St}} -> St end")) + "active") + +(id-session-test + "lookup preserves subject" + (idnm + (id-ev + "Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n case identity_session:lookup(S) of {ok, {_,Subject,_,_}} -> Subject end")) + "alice") + +(id-session-test + "lookup preserves client" + (idnm + (id-ev + "Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n case identity_session:lookup(S) of {ok, {_,_,Client,_}} -> Client end")) + "web") + +;; ── touch keeps a live session ─────────────────────────────────── + +(id-session-test + "touch on live session is ok" + (idnm + (id-ev + "Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_session:touch(S)")) + "ok") + +;; ── explicit expire ────────────────────────────────────────────── + +(id-session-test + "expire then lookup is error expired" + (idnm + (id-ev + "Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_session:expire(S),\n case identity_session:lookup(S) of {error, St} -> St end")) + "expired") + +(id-session-test + "touch on expired session is error" + (idnm + (id-ev + "Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_session:expire(S),\n case identity_session:touch(S) of {error, St} -> St end")) + "expired") + +;; ── revoke is immediate ────────────────────────────────────────── + +(id-session-test + "revoke then lookup is error revoked" + (idnm + (id-ev + "Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_session:revoke(S),\n case identity_session:lookup(S) of {error, St} -> St end")) + "revoked") + +;; ── idle-timeout self-expiry ───────────────────────────────────── + +(id-session-test + "idle timeout notifies owner" + (idnm + (id-ev + "Me = self(),\n S = identity_session:start(s1, alice, web, Me, 50),\n _ = identity_session:lookup(S),\n receive {session_expired, Sid} -> Sid end")) + "s1") + +(id-session-test + "lookup after idle timeout is error expired" + (idnm + (id-ev + "Me = self(),\n S = identity_session:start(s1, alice, web, Me, 50),\n _ = identity_session:lookup(S),\n receive {session_expired, _} -> ok end,\n case identity_session:lookup(S) of {error, St} -> St end")) + "expired") + +;; ── isolation: sessions are independent processes ──────────────── + +(id-session-test + "expiring one session leaves the other active" + (idnm + (id-ev + "Me = self(),\n A = identity_session:start(s1, alice, web, Me, infinity),\n B = identity_session:start(s2, bob, web, Me, infinity),\n identity_session:expire(A),\n case identity_session:lookup(B) of {ok, {_,_,_,St}} -> St end")) + "active") + +;; ── clean stop ─────────────────────────────────────────────────── + +(id-session-test + "stop returns ok" + (idnm + (id-ev + "Me = self(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_session:stop(S)")) + "ok") + +(define + id-session-test-summary + (str "session " id-session-test-pass "/" id-session-test-count)) diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index f28f7095..1095b1cf 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` → **0/0** (not yet started) +`bash lib/identity/conformance.sh` → **11/11** (Phase 1: session) ## Ground rules @@ -57,7 +57,7 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) ``` ## Phase 1 — Sessions + tokens -- [ ] `session.sx` — session process, create/lookup/expire +- [x] `session.sx` — session process, create/lookup/expire - [ ] `token.sx` — issue/introspect/revoke (opaque, grant-backed) - [ ] `registry.sx` — route by subject/client - [ ] `api.sx` + tests + scoreboard + conformance.sh @@ -78,7 +78,12 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [ ] tests: audit completeness, cross-instance subject mapping ## Progress log -(loop fills this in) +- 2026-06-06 — `session.sx`: session-as-Erlang-process. create/lookup/touch/ + explicit-expire/revoke as messages; idle-timeout self-expiry via + `receive ... after Ttl` notifying the owner then tombstoning. Tombstones + answer lookups with `{error, expired|revoked}` — never a silent dead + mailbox. Established the conformance harness (`conformance.sh`, scoreboard, + `tests/session.sx`). 11/11. ## Blockers (loop fills this in)