identity: membership state machine + per-app grant projection (17 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 28s

membership.sx — coop membership as a guarded state machine
(none→pending→active→lapsed⇄active, any→revoked terminal); invalid
transitions return explicit {error, CurrentStatus}, never silent no-ops.
project(Subject, App) renders the one canonical state into a per-app claim
({member,Tier,App} / {pending,App} / {lapsed,App} / {denied,App} /
{non_member,App}) — identity reports what the membership is; acl decides
whether the app should honour it. New tests/membership.sx. 92/92.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 00:54:51 +00:00
parent 56cf920041
commit dc00ed9786
6 changed files with 206 additions and 6 deletions

View File

@@ -34,6 +34,7 @@ SUITES=(
"api|id-api-test-pass|id-api-test-count"
"oauth|id-oauth-test-pass|id-oauth-test-count"
"sso|id-sso-test-pass|id-sso-test-count"
"membership|id-membership-test-pass|id-membership-test-count"
)
cat > "$TMPFILE" << 'EPOCHS'
@@ -50,12 +51,14 @@ cat > "$TMPFILE" << 'EPOCHS'
(load "lib/identity/registry.sx")
(load "lib/identity/api.sx")
(load "lib/identity/oauth.sx")
(load "lib/identity/membership.sx")
(load "lib/identity/tests/session.sx")
(load "lib/identity/tests/token.sx")
(load "lib/identity/tests/registry.sx")
(load "lib/identity/tests/api.sx")
(load "lib/identity/tests/oauth.sx")
(load "lib/identity/tests/sso.sx")
(load "lib/identity/tests/membership.sx")
(epoch 100)
(eval "(list id-session-test-pass id-session-test-count)")
(epoch 101)
@@ -68,6 +71,8 @@ cat > "$TMPFILE" << 'EPOCHS'
(eval "(list id-oauth-test-pass id-oauth-test-count)")
(epoch 105)
(eval "(list id-sso-test-pass id-sso-test-count)")
(epoch 106)
(eval "(list id-membership-test-pass id-membership-test-count)")
EPOCHS
timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1

View File

@@ -0,0 +1,31 @@
;; identity/membership.sx — coop membership state + per-app projection.
;;
;; Membership is canonical subject state held by one process, a guarded
;; state machine (invalid transitions are explicit errors, never silent
;; no-ops):
;;
;; none --request--> pending --approve--> active
;; active --lapse--> lapsed --reinstate--> active
;; {pending|active|lapsed} --revoke--> revoked (terminal)
;;
;; A per-app GRANT PROJECTION renders that one canonical state into the
;; view a given client app consumes — mirroring rose-ash's per-app grant
;; verification. The projection is pure identity: it reports WHAT the
;; subject's membership is for that app; it does NOT decide whether the
;; app should let them in. That permission question is acl's, keyed off
;; this projection.
;;
;; project(Subject, App) ->
;; active -> {member, Tier, App}
;; pending -> {pending, App}
;; lapsed -> {lapsed, App}
;; revoked -> {denied, App}
;; none -> {non_member, App}
(define
identity-membership-source
"-module(identity_membership).\n\n start() ->\n spawn(fun () -> loop([]) end).\n\n request(M, Subject, Tier) ->\n M ! {request, Subject, Tier, self()},\n receive {membership_reply, R} -> R end.\n\n approve(M, Subject) ->\n M ! {approve, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n lapse(M, Subject) ->\n M ! {lapse, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n reinstate(M, Subject) ->\n M ! {reinstate, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n revoke(M, Subject) ->\n M ! {revoke, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n status(M, Subject) ->\n M ! {status, Subject, self()},\n receive {membership_reply, R} -> R end.\n\n project(M, Subject, App) ->\n M ! {project, Subject, App, self()},\n receive {membership_reply, R} -> R end.\n\n loop(Members) ->\n receive\n {request, Subject, Tier, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, ok},\n loop([{Subject, {pending, Tier}} | Members]);\n {ok, _} ->\n From ! {membership_reply, {error, exists}},\n loop(Members)\n end;\n {approve, Subject, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, {error, not_found}},\n loop(Members);\n {ok, {pending, Tier}} ->\n From ! {membership_reply, ok},\n loop(set_record(Subject, {active, Tier}, Members));\n {ok, {St, _}} ->\n From ! {membership_reply, {error, St}},\n loop(Members)\n end;\n {lapse, Subject, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, {error, not_found}},\n loop(Members);\n {ok, {active, Tier}} ->\n From ! {membership_reply, ok},\n loop(set_record(Subject, {lapsed, Tier}, Members));\n {ok, {St, _}} ->\n From ! {membership_reply, {error, St}},\n loop(Members)\n end;\n {reinstate, Subject, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, {error, not_found}},\n loop(Members);\n {ok, {lapsed, Tier}} ->\n From ! {membership_reply, ok},\n loop(set_record(Subject, {active, Tier}, Members));\n {ok, {St, _}} ->\n From ! {membership_reply, {error, St}},\n loop(Members)\n end;\n {revoke, Subject, From} ->\n case find(Subject, Members) of\n none ->\n From ! {membership_reply, {error, not_found}},\n loop(Members);\n {ok, {_, Tier}} ->\n From ! {membership_reply, ok},\n loop(set_record(Subject, {revoked, Tier}, Members))\n end;\n {status, Subject, From} ->\n case find(Subject, Members) of\n none -> From ! {membership_reply, {none}};\n {ok, {St, Tier}} -> From ! {membership_reply, {ok, St, Tier}}\n end,\n loop(Members);\n {project, Subject, App, From} ->\n From ! {membership_reply, project_view(Subject, App, Members)},\n loop(Members);\n {stop, From} ->\n From ! {membership_reply, ok}\n end.\n\n project_view(Subject, App, Members) ->\n case find(Subject, Members) of\n none -> {non_member, App};\n {ok, {active, Tier}} -> {member, Tier, App};\n {ok, {pending, _}} -> {pending, App};\n {ok, {lapsed, _}} -> {lapsed, App};\n {ok, {revoked, _}} -> {denied, App}\n end.\n\n set_record(_, _, []) -> [];\n set_record(Subject, Rec, [{S, Old} | Rest]) ->\n case S =:= Subject of\n true -> [{S, Rec} | Rest];\n false -> [{S, Old} | set_record(Subject, Rec, 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-membership!
(fn () (erlang-load-module identity-membership-source)))

View File

@@ -1,13 +1,14 @@
{
"language": "identity",
"total_pass": 75,
"total": 75,
"total_pass": 92,
"total": 92,
"suites": [
{"name":"session","pass":11,"total":11,"status":"ok"},
{"name":"token","pass":18,"total":18,"status":"ok"},
{"name":"registry","pass":9,"total":9,"status":"ok"},
{"name":"api","pass":10,"total":10,"status":"ok"},
{"name":"oauth","pass":17,"total":17,"status":"ok"},
{"name":"sso","pass":10,"total":10,"status":"ok"}
{"name":"sso","pass":10,"total":10,"status":"ok"},
{"name":"membership","pass":17,"total":17,"status":"ok"}
]
}

View File

@@ -1,6 +1,6 @@
# identity-on-sx Scoreboard
**Total: 75 / 75 tests passing**
**Total: 92 / 92 tests passing**
| | Suite | Pass | Total |
|---|---|---|---|
@@ -10,6 +10,7 @@
| ✅ | api | 10 | 10 |
| ✅ | oauth | 17 | 17 |
| ✅ | sso | 10 | 10 |
| ✅ | membership | 17 | 17 |
Generated by `lib/identity/conformance.sh`.

View File

@@ -0,0 +1,155 @@
;; identity/tests/membership.sx — membership state machine + per-app
;; grant projection. Valid transitions advance state; invalid ones are
;; explicit errors. The projection renders one canonical state per app.
(define id-membership-test-count 0)
(define id-membership-test-pass 0)
(define id-membership-test-fails (list))
(define
id-membership-test
(fn
(name actual expected)
(set! id-membership-test-count (+ id-membership-test-count 1))
(if
(= actual expected)
(set! id-membership-test-pass (+ id-membership-test-pass 1))
(append! id-membership-test-fails {:name name :expected expected :actual actual}))))
(define idm-ev erlang-eval-ast)
(define idmnm (fn (v) (get v :name)))
(identity-load-membership!)
;; ── request → pending → approve → active ─────────────────────────
(id-membership-test
"request leaves the subject pending"
(idmnm
(idm-ev
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end"))
"pending")
(id-membership-test
"approve activates a pending membership"
(idmnm
(idm-ev
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end"))
"active")
(id-membership-test
"status keeps the requested tier"
(idmnm
(idm-ev
"M = identity_membership:start(),\n identity_membership:request(M, alice, supporter),\n identity_membership:approve(M, alice),\n case identity_membership:status(M, alice) of\n {ok, _, Tier} -> Tier\n end"))
"supporter")
;; ── guarded transitions: invalid moves are explicit errors ───────
(id-membership-test
"requesting twice is an error"
(idmnm
(idm-ev
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n case identity_membership:request(M, alice, basic) of\n ok -> ok;\n {error, Why} -> Why\n end"))
"exists")
(id-membership-test
"approving an unknown subject is not_found"
(idmnm
(idm-ev
"M = identity_membership:start(),\n case identity_membership:approve(M, ghost) of\n ok -> ok;\n {error, Why} -> Why\n end"))
"not_found")
(id-membership-test
"approving an already-active membership is an error"
(idmnm
(idm-ev
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:approve(M, alice) of\n ok -> ok;\n {error, Why} -> Why\n end"))
"active")
;; ── lapse / reinstate ────────────────────────────────────────────
(id-membership-test
"active member can lapse"
(idmnm
(idm-ev
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:lapse(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St\n end"))
"lapsed")
(id-membership-test
"lapsing a pending membership is an error"
(idmnm
(idm-ev
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n case identity_membership:lapse(M, alice) of\n ok -> ok;\n {error, Why} -> Why\n end"))
"pending")
(id-membership-test
"lapsed member can reinstate to active"
(idmnm
(idm-ev
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:lapse(M, alice),\n identity_membership:reinstate(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St\n end"))
"active")
;; ── revoke is terminal ───────────────────────────────────────────
(id-membership-test
"any member can be revoked"
(idmnm
(idm-ev
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:revoke(M, alice),\n case identity_membership:status(M, alice) of\n {ok, St, _} -> St\n end"))
"revoked")
(id-membership-test
"a revoked membership cannot be reinstated"
(idmnm
(idm-ev
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:revoke(M, alice),\n case identity_membership:reinstate(M, alice) of\n ok -> ok;\n {error, Why} -> Why\n end"))
"revoked")
;; ── per-app grant projection ─────────────────────────────────────
(id-membership-test
"active member projects as member"
(idmnm
(idm-ev
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:project(M, alice, blog) of\n {member, _, _} -> member;\n {Tag, _} -> Tag\n end"))
"member")
(id-membership-test
"projection carries the requesting app"
(idmnm
(idm-ev
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n case identity_membership:project(M, alice, market) of\n {member, _, App} -> App\n end"))
"market")
(id-membership-test
"the same subject projects consistently across apps"
(idmnm
(idm-ev
"M = identity_membership:start(),\n identity_membership:request(M, alice, supporter),\n identity_membership:approve(M, alice),\n {member, T1, blog} = identity_membership:project(M, alice, blog),\n {member, T2, events} = identity_membership:project(M, alice, events),\n case T1 =:= T2 of\n true -> T1;\n false -> mismatch\n end"))
"supporter")
(id-membership-test
"unknown subject projects as non_member"
(idmnm
(idm-ev
"M = identity_membership:start(),\n case identity_membership:project(M, ghost, blog) of\n {Tag, _} -> Tag;\n {Tag, _, _} -> Tag\n end"))
"non_member")
(id-membership-test
"lapsed member projects as lapsed"
(idmnm
(idm-ev
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:lapse(M, alice),\n case identity_membership:project(M, alice, blog) of\n {Tag, _} -> Tag;\n {Tag, _, _} -> Tag\n end"))
"lapsed")
(id-membership-test
"revoked member projects as denied"
(idmnm
(idm-ev
"M = identity_membership:start(),\n identity_membership:request(M, alice, basic),\n identity_membership:approve(M, alice),\n identity_membership:revoke(M, alice),\n case identity_membership:project(M, alice, blog) of\n {Tag, _} -> Tag;\n {Tag, _, _} -> Tag\n end"))
"denied")
(define
id-membership-test-summary
(str "membership " id-membership-test-pass "/" id-membership-test-count))

View File

@@ -19,7 +19,7 @@ through the event log, all authorization questions delegated to `acl-on-sx`.
## Status (rolling)
`bash lib/identity/conformance.sh`**75/75** (Phases 12 + silent SSO)
`bash lib/identity/conformance.sh`**92/92** (Phases 12 + SSO + membership)
## Ground rules
@@ -69,7 +69,7 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke)
## Phase 3 — Silent SSO + membership
- [x] `prompt=none` cross-app login (one session, many clients)
- [ ] membership state + per-app grant projection
- [x] membership state + per-app grant projection
- [ ] grant verification delegated cache (mirror Redis-cache pattern)
## Phase 4 — Audit + federation
@@ -78,6 +78,13 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke)
- [ ] tests: audit completeness, cross-instance subject mapping
## Progress log
- 2026-06-07 — `membership.sx`: coop membership as a guarded state machine
(none→pending→active→lapsed⇄active, any→revoked terminal); invalid
transitions are explicit `{error, CurrentStatus}`. `project(Subject, App)`
renders the one canonical state into a per-app claim
({member,Tier,App}/{pending,App}/{lapsed,App}/{denied,App}/{non_member,App})
— identity reports what; acl decides whether. New tests/membership.sx (17).
+17 → 92/92.
- 2026-06-07 — silent SSO (`prompt=none`, OIDC §3.1.2.1): `oauth.sx` now owns
a session registry; `establish` creates a subject session, `silent_authorize`
asks "does this subject have a live session?" → mints a code (skipping