Compare commits

..

14 Commits

Author SHA1 Message Date
29127d8613 events: federated free/busy across trusted peers + 10 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m4s
Peers publish busy intervals per actor (iCal free/busy model — privacy-
preserving, not event details). ev/peer-with-busy, ev/peer-busy;
ev/federated-busy unions local availability-db busy + trusted peers' published
busy (sorted); ev/federated-free? answers cross-instance availability,
half-open, trust-gated (untrusted peers ignored). 219/219 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 05:32:04 +00:00
80174c7197 events: Phase 4 federation — trust-gated peer agenda merge + 13 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 17s
federation.sx: a peer publishes a schedule; ev/federated-agenda merges local
(origin :local) with trusted peers' agendas, sorted by start, tagged with
:origin provenance. Trust is a peer-id set re-checked per merge; untrusted
peers contribute nothing. Real transport slots behind ev/peer-agenda.
209/209 green — all four plan phases implemented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:59:12 +00:00
f6c1d1e9bf events: reminders + digests from the agenda + 14 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
reminders.sx bridges calendar + durable rosters to notify: ev/occurrence-
reminders (one per booked attendee, fires lead before start, idempotency key
occ-key/recipient/lead), ev/agenda-reminders (sorted by fire-at),
ev/due-reminders (fire-at <= now), ev/reminder->msg (notify wire shape),
ev/agenda-digest + ev/agenda-for-p. 196/196 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:34:49 +00:00
e35769411e events: notification delivery flows on lib/flow + 7 tests (Phase 3 start)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
notify.sx: reminders + digests as durable flows over an injected transport.
A flow requests delivery (suspend); the host dispatch sends and resumes with
the outcome. At-least-once + idempotent (transport dedups by msg id; replay
logs outcomes). Retry rides suspend/resume with distinct per-attempt tags,
bounded by maxn. Digest delivers a batch with per-message outcomes.
182/182 green. Delivery core is the delivery-on-sx extraction seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:02:54 +00:00
05d5c46730 events: paid-ticket contract (commerce) over holds + 31 tests (Phase 2 done)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
ticket.sx: checkout-request (events->commerce) + payment-result
(commerce->events) wire shapes — commerce imports the contract. ev/request-
ticket! holds a seat + emits a checkout request; ev/settle-payment! confirms
on :paid, releases on failure/expiry. Idempotent; late paid for a vanished
hold -> :paid-but-no-hold (refund signal). 175/175 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 03:34:15 +00:00
7153e742c8 events: provisional holds (hold/confirm/release) for paid tickets + 24 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 48s
Booking stream gains :hold/:confirm/:release; fold tracks per-actor seat state
(:held/:confirmed). A held seat counts toward capacity so a pending payment
can't be oversold. ev/hold! (capacity-safe), ev/confirm!, ev/release!,
ev/seat-state. Holds race test mirrors the booking race. 144/144 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 03:07:29 +00:00
24d4db3f0d events: wire persist-backed booking into api.sx + 10 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 35s
Durable booking path alongside in-memory: ev/book-occ!, ev/cancel-occ!,
ev/roster-occ, ev/seats-left-occ (capacity from scheduled event); ev/free-p?,
ev/next-free-p, ev/conflicts-p derive availability by replaying persist
booking streams. Reordered conformance preloads. 120/120 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:39:19 +00:00
9adeff1431 events: booking cancellation + seat release + 13 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 24s
Booking stream carries :booking/:cancel events; live roster is the folded
replay so cancelling frees a seat and capacity reopens. ev/cancel! (retrying
append-expect), no-op on unbooked, cancelled actor may re-book. Capacity count
is folded roster size. 110/110 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:09:58 +00:00
80a2dee22f events: capacity-safe transactional booking on persist + 24 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
booking.sx: per-occurrence append-only stream, roster = replay. Booking
decided against an observed (roster, last-seq) snapshot, committed via
persist/append-expect — atomic check+append, no overbooking, no lock.
Explicit last-seat race test: two bookers, one booked, one conflict, roster
capped. Idempotent per actor. 97/97 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:44:43 +00:00
15e9503b05 events: api.sx — public events facade + 14 tests (Phase 1 complete)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
Immutable store ({:events :bookings}) over calendar+availability:
ev/schedule, ev/book, ev/agenda, ev/agenda-for, ev/free?, ev/next-free,
ev/conflicts. Availability queries auto-widen expansion by longest event.
73/73 green. Phase 1 done.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:16:16 +00:00
4674b797cb events: next-free slot search + 6 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 35s
ev-next-free finds the earliest free slot >= after for a duration within a
horizon, probing 'after' + busy-interval ends via the busy_in rule (ev-free?).
Finds gaps, skips too-short gaps, half-open at edges. 59/59 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:49:42 +00:00
540933bfca events: availability.sx — free/busy + conflict detection on Datalog + 16 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m1s
occurrence/booking EDB; rules busy/conflict (canonical pair, half-open
overlap)/busy_in. API ev-busy, ev-conflicts, ev-has-conflict?, ev-free?
(transient qwindow). Integrates with calendar expansion. 53/53 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:23:51 +00:00
70aea21601 events: MONTHLY RRULE expansion (bymonthday + ordinal byday) + 13 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 44s
BYMONTHDAY (negative = from end), ordinal BYDAY ({:ord :wd}, last-weekday),
default day-of-month skipping short months. Weekly+monthly share ev-emit-occs.
37/37 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:52:39 +00:00
797c5f9147 events: Phase 1 calendar — DAILY/WEEKLY RRULE expansion + 24 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 32s
Civil date arithmetic (Hinnant), integer epoch-minute datetimes, bounded
windowed RRULE expansion (DAILY/WEEKLY with INTERVAL/COUNT/UNTIL/BYDAY),
multi-event merge. Conformance harness + scoreboard wired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:48:34 +00:00
60 changed files with 3995 additions and 3326 deletions

View File

@@ -1 +1 @@
{"sessionId":"bf20a443-9df8-4cb9-932e-8c6f4c4625c2","pid":1303602,"procStart":"253831081","acquiredAt":1779865895644} {"sessionId":"c4d97db1-361c-4a04-a99b-c838f9385469","pid":2426590,"procStart":"349789073","acquiredAt":1780789990975}

251
lib/events/api.sx Normal file
View File

@@ -0,0 +1,251 @@
;; lib/events/api.sx — public events surface over calendar + availability.
;;
;; A `store` is an immutable value holding scheduled events and (in-memory)
;; bookings:
;;
;; {:events (event ...) :bookings ((actor key) ...)}
;;
;; The in-memory `:bookings` list supports pure, value-level queries. The
;; DURABLE booking path (ev/*-occ! and ev/*-p) keeps bookings in persist
;; streams via booking.sx — capacity-safe, cancellable, replayable — and
;; derives availability from those streams. Use the persist path for real
;; bookings; the in-memory path for projections and tests.
;;
;; All queries are windowed: agenda/free/next-free expand recurring events into
;; concrete occurrences within an explicit (or derived) window before running
;; the Datalog availability rules.
(define ev/store (fn (events bookings) {:bookings bookings :events events}))
(define ev/empty (fn () (ev/store (list) (list))))
(define ev/events (fn (store) (get store :events)))
(define ev/bookings (fn (store) (get store :bookings)))
;; Add a (constructed) event to the store.
(define
ev/add-event
(fn
(store event)
(ev/store (cons event (ev/events store)) (ev/bookings store))))
;; Schedule a fresh event from parts, returning the updated store. rrule may be
;; nil for a one-off. (Booking is separate — see ev/book.)
(define
ev/schedule
(fn
(store id dtstart duration rrule capacity)
(ev/add-event store (ev-event id dtstart duration rrule capacity))))
;; Record that `actor` holds the occurrence with `key` (in-memory only — see
;; ev/book-occ! for the durable, capacity-safe path).
(define
ev/book
(fn
(store actor key)
(ev/store
(ev/events store)
(cons (list actor key) (ev/bookings store)))))
;; The event with `id`, or nil.
(define
ev/event-by-id
(fn
(store id)
(reduce
(fn
(found ev)
(if (nil? found) (if (= (get ev :id) id) ev found) found))
nil
(ev/events store))))
;; Capacity of the event an occurrence belongs to (0 if unknown).
(define
ev/capacity-of
(fn
(store occ)
(let
((ev (ev/event-by-id store (get occ :id))))
(if (nil? ev) 0 (get ev :capacity)))))
;; The maximum event duration in the store (0 when empty) — used to widen
;; expansion windows so any occurrence overlapping a query is captured.
(define
ev/store-max-duration
(fn
(store)
(reduce
(fn (m ev) (max m (get ev :duration)))
0
(ev/events store))))
;; All occurrences across all events within [ws, we), ascending by start.
(define
ev/agenda
(fn (store ws we) (ev-expand-all (ev/events store) ws we)))
(define
ev-key-member?
(fn
(k keys)
(cond
((empty? keys) false)
((= k (first keys)) true)
(else (ev-key-member? k (rest keys))))))
;; Occurrence keys `actor` has booked (in-memory store).
(define
ev/actor-keys
(fn
(store actor)
(reduce
(fn
(acc b)
(if (= (first b) actor) (cons (first (rest b)) acc) acc))
(list)
(ev/bookings store))))
;; The agenda restricted to occurrences `actor` is booked into (in-memory).
(define
ev/agenda-for
(fn
(store actor ws we)
(let
((keys (ev/actor-keys store actor)))
(filter
(fn (o) (ev-key-member? (ev-occ-key o) keys))
(ev/agenda store ws we)))))
;; Build an availability db over occurrences expanded in [ws, we) using the
;; in-memory bookings.
(define
ev/avail-window-db
(fn
(store ws we)
(ev-avail-db (ev/agenda store ws we) (ev/bookings store))))
;; Is `actor` free across [qs, qe)? Expands a window wide enough (back by the
;; longest event) to capture any occurrence that could overlap.
(define
ev/free?
(fn
(store actor qs qe)
(ev-free?
(ev/avail-window-db store (- qs (ev/store-max-duration store)) qe)
actor
qs
qe)))
;; Earliest free slot of `duration` for `actor` in [after, horizon), or nil.
(define
ev/next-free
(fn
(store actor after duration horizon)
(ev-next-free
(ev/avail-window-db
store
(- after (ev/store-max-duration store))
horizon)
actor
after
duration
horizon)))
;; Overlapping double-bookings for `actor` among occurrences in [ws, we).
(define
ev/conflicts
(fn
(store actor ws we)
(ev-conflicts (ev/avail-window-db store ws we) actor)))
(define
ev/has-conflict?
(fn
(store actor ws we)
(> (len (ev/conflicts store actor ws we)) 0)))
;; ---- durable, persist-backed booking path ----
;; These take a persist backend `b` (persist/open) plus the schedule `store`.
;; Bookings live in per-occurrence streams (booking.sx); availability is derived
;; by replaying those streams for the occurrences in the query window.
;; Durably book `actor` into occurrence `occ` (dict {:id :start :end}),
;; capacity-safe. Returns the booking.sx result (:booked / :full / :already).
(define
ev/book-occ!
(fn
(b store actor occ)
(ev/book! b (ev-occ-key occ) (ev/capacity-of store occ) actor)))
;; Durably cancel `actor`'s seat on `occ`, freeing capacity.
(define
ev/cancel-occ!
(fn (b store actor occ) (ev/cancel! b (ev-occ-key occ) actor)))
;; Live roster / seats-left for a specific occurrence from persist.
(define ev/roster-occ (fn (b occ) (ev/roster b (ev-occ-key occ))))
(define
ev/seats-left-occ
(fn
(b store occ)
(ev/seats-left b (ev-occ-key occ) (ev/capacity-of store occ))))
;; Derive (actor key) booking pairs from the persist rosters of `occs`.
(define
ev/persist-bookings
(fn
(b occs)
(reduce
(fn
(acc occ)
(let
((key (ev-occ-key occ)))
(append
acc
(map (fn (actor) (list actor key)) (ev/roster b key)))))
(list)
occs)))
;; Availability db over [ws, we) with bookings sourced from persist streams.
(define
ev/avail-db-p
(fn
(b store ws we)
(let
((occs (ev/agenda store ws we)))
(ev-avail-db occs (ev/persist-bookings b occs)))))
;; Persist-backed availability queries (mirror the in-memory ev/free? etc).
(define
ev/free-p?
(fn
(b store actor qs qe)
(ev-free?
(ev/avail-db-p b store (- qs (ev/store-max-duration store)) qe)
actor
qs
qe)))
(define
ev/next-free-p
(fn
(b store actor after duration horizon)
(ev-next-free
(ev/avail-db-p b store (- after (ev/store-max-duration store)) horizon)
actor
after
duration
horizon)))
(define
ev/conflicts-p
(fn
(b store actor ws we)
(ev-conflicts (ev/avail-db-p b store ws we) actor)))
(define
ev/has-conflict-p?
(fn
(b store actor ws we)
(> (len (ev/conflicts-p b store actor ws we)) 0)))

177
lib/events/availability.sx Normal file
View File

@@ -0,0 +1,177 @@
;; lib/events/availability.sx — free/busy + conflict detection on Datalog.
;;
;; Availability is per-actor and is forward-chained Datalog over two EDB
;; relations:
;;
;; (occurrence Key EventId Start End) ; an expanded calendar occurrence
;; (booking Actor Key) ; actor attends/holds that occurrence
;;
;; The derived relations are the whole policy:
;;
;; busy(A,S,E) — A is committed for [S,E) (a booked occurrence)
;; conflict(A,O1,O2) — A double-booked into two overlapping occurrences
;; busy_in(A,QS,QE) — A is busy somewhere inside query window [QS,QE)
;;
;; Intervals are half-open [Start,End) in epoch minutes (see calendar.sx), so
;; adjacent slots (E == next start) do NOT conflict. Conflict pairs are
;; canonical (O1 < O2 by key) so each overlap is reported once. The same `busy`
;; rule answers "is A free in [QS,QE)?" (busy_in is empty) and feeds "when is A
;; next free?" (ev-next-free probes candidate slots with the same rule).
;; A stable key for an occurrence dict {:id :start :end}.
(define ev-occ-key (fn (occ) (str (get occ :id) "@" (get occ :start))))
(define
ev-occurrence-fact
(fn
(occ)
(list
(quote occurrence)
(ev-occ-key occ)
(get occ :id)
(get occ :start)
(get occ :end))))
(define ev-occurrence-facts (fn (occs) (map ev-occurrence-fact occs)))
(define ev-booking-fact (fn (actor key) (list (quote booking) actor key)))
(define ev-qwindow-fact (fn (qs qe) (list (quote qwindow) qs qe)))
;; Range restriction: each comparison's variables are bound by an earlier
;; positive literal (qwindow / busy precede the < tests). Conflict uses
;; (< O1 O2) on the keys so each overlapping pair is reported once.
(define
ev-avail-rules
(quote
((busy A S E <- (booking A O) (occurrence O _ S E))
(conflict
A
O1
O2
<-
(booking A O1)
(booking A O2)
(occurrence O1 _ S1 E1)
(occurrence O2 _ S2 E2)
(< O1 O2)
(< S1 E2)
(< S2 E1))
(busy_in A QS QE <- (qwindow QS QE) (busy A S E) (< S QE) (< QS E)))))
;; Build a Datalog db from EDB facts under the availability ruleset.
(define ev-build-avail (fn (facts) (dl-program-data facts ev-avail-rules)))
;; Convenience: build a db from occurrence dicts + booking pairs.
;; bookings is a list of (actor key) pairs.
(define
ev-avail-db
(fn
(occs bookings)
(ev-build-avail
(append
(ev-occurrence-facts occs)
(map
(fn (b) (ev-booking-fact (first b) (first (rest b))))
bookings)))))
;; Helper: insertion sort a list of (S E ...) lists ascending by S then E.
(define
ev-list-before?
(fn
(a b)
(cond
((< (first a) (first b)) true)
((> (first a) (first b)) false)
(else (< (first (rest a)) (first (rest b)))))))
(define
ev-list-insert
(fn
(x sorted)
(cond
((empty? sorted) (list x))
((ev-list-before? x (first sorted)) (cons x sorted))
(else (cons (first sorted) (ev-list-insert x (rest sorted)))))))
(define
ev-sort-lists
(fn (xs) (reduce (fn (acc x) (ev-list-insert x acc)) (list) xs)))
(define
ev-dedup-sorted
(fn
(xs)
(cond
((empty? xs) xs)
((empty? (rest xs)) xs)
((= (first xs) (first (rest xs))) (ev-dedup-sorted (rest xs)))
(else (cons (first xs) (ev-dedup-sorted (rest xs)))))))
;; All busy intervals (list S E) for an actor, ascending by start.
(define
ev-busy
(fn
(db actor)
(let
((rows (dl-query db (list (quote busy) actor (quote S) (quote E)))))
(ev-sort-lists (map (fn (b) (list (get b :S) (get b :E))) rows)))))
;; Distinct conflicting occurrence-key pairs for an actor (each pair once).
(define
ev-conflicts
(fn
(db actor)
(dl-query db (list (quote conflict) actor (quote O1) (quote O2)))))
(define
ev-has-conflict?
(fn (db actor) (> (len (ev-conflicts db actor)) 0)))
;; Is `actor` free across the whole window [qs,qe)? (no booked occurrence
;; overlaps it). Asserts a transient qwindow fact, queries, retracts.
(define
ev-free?
(fn
(db actor qs qe)
(do
(dl-assert! db (ev-qwindow-fact qs qe))
(let
((rows (dl-query db (list (quote busy_in) actor (quote QS) (quote QE)))))
(begin (dl-retract! db (ev-qwindow-fact qs qe)) (empty? rows))))))
;; ---- next-free slot search ----
;; The earliest start s >= `after` such that [s, s+duration) is entirely free
;; for `actor` and ends at or before `horizon`, or nil if none. The earliest
;; such slot must begin either at `after` or immediately after some busy
;; interval ends (classic interval packing), so those are the only candidates
;; we probe — each probe reuses the busy_in rule via ev-free?.
(define
ev-first-free
(fn
(db actor cands duration horizon)
(cond
((empty? cands) nil)
(else
(let
((s (first cands)))
(if
(and
(<= (+ s duration) horizon)
(ev-free? db actor s (+ s duration)))
s
(ev-first-free db actor (rest cands) duration horizon)))))))
(define
ev-next-free
(fn
(db actor after duration horizon)
(let
((ends (filter (fn (e) (>= e after)) (map (fn (iv) (first (rest iv))) (ev-busy db actor)))))
(ev-first-free
db
actor
(ev-dedup-sorted (sort (cons after ends)))
duration
horizon))))

271
lib/events/booking.sx Normal file
View File

@@ -0,0 +1,271 @@
;; lib/events/booking.sx — transactional, capacity-safe booking on persist.
;;
;; Each bookable occurrence has an append-only stream of booking events:
;;
;; :booking free booking — actor immediately holds a confirmed seat
;; :hold provisional hold — seat reserved while payment is pending
;; :confirm a held seat becomes confirmed (payment succeeded)
;; :release a held seat is abandoned (payment failed/expired) — seat freed
;; :cancel a held or confirmed seat is given up — seat freed
;;
;; The live state is the stream FOLDED in order into per-actor seat states
;; (:held / :confirmed); an actor in ANY state occupies a seat, so both held and
;; confirmed seats count toward capacity — a pending payment cannot be
;; oversold. A freed seat (release/cancel) reopens capacity.
;;
;; Capacity safety is the contract: two writers racing for the last seat must
;; NEVER both succeed. Seat-ACQUIRING writes (:booking, :hold) go through
;; persist's optimistic concurrency — `persist/append-expect` appends only if
;; the stream's last-seq still equals what the writer observed; else it returns
;; a conflict the writer retries. Seat-FREEING writes (:cancel, :release) and
;; the state transition (:confirm) never oversell, so they append directly.
(define ev-booking-stream (fn (occ-key) (str "booking:" occ-key)))
(define
ev-bk-member?
(fn
(x xs)
(cond
((empty? xs) false)
((= x (first xs)) true)
(else (ev-bk-member? x (rest xs))))))
(define
ev-bk-index
(fn
(xs x i)
(cond
((empty? xs) -1)
((= (first xs) x) i)
(else (ev-bk-index (rest xs) x (+ i 1))))))
;; ---- per-actor state association list: ((actor state) ...) in join order ----
(define
ev-state-has?
(fn
(states actor)
(cond
((empty? states) false)
((= (first (first states)) actor) true)
(else (ev-state-has? (rest states) actor)))))
(define
ev-state-get
(fn
(states actor)
(cond
((empty? states) :none)
((= (first (first states)) actor) (first (rest (first states))))
(else (ev-state-get (rest states) actor)))))
(define
ev-state-del
(fn (states actor) (filter (fn (p) (not (= (first p) actor))) states)))
(define
ev-state-set
(fn
(states actor st)
(if
(ev-state-has? states actor)
(map (fn (p) (if (= (first p) actor) (list actor st) p)) states)
(append states (list (list actor st))))))
;; Fold the booking stream into per-actor seat states (join order preserved).
(define
ev-fold-states
(fn
(events)
(reduce
(fn
(acc e)
(let
((typ (persist/event-type e))
(actor (get (persist/event-data e) :actor)))
(cond
((= typ :booking) (ev-state-set acc actor :confirmed))
((= typ :hold) (ev-state-set acc actor :held))
((= typ :confirm)
(if
(ev-state-has? acc actor)
(ev-state-set acc actor :confirmed)
acc))
((= typ :cancel) (ev-state-del acc actor))
((= typ :release) (ev-state-del acc actor))
(else acc))))
(list)
events)))
(define
ev-states-of
(fn
(b occ-key)
(ev-fold-states (persist/read b (ev-booking-stream occ-key)))))
;; Live roster (actors holding a seat — held or confirmed), oldest active first.
(define
ev-booked-actors
(fn (b occ-key) (map (fn (p) (first p)) (ev-states-of b occ-key))))
(define
ev-actor-booked?
(fn (b occ-key actor) (ev-bk-member? actor (ev-booked-actors b occ-key))))
;; Live seat count (folded roster size — both held and confirmed seats).
(define
ev-booking-count
(fn (b occ-key) (len (ev-booked-actors b occ-key))))
;; Seat state for an actor: :held / :confirmed / :none.
(define
ev/seat-state
(fn (b occ-key actor) (ev-state-get (ev-states-of b occ-key) actor)))
;; 1-based seat number for an actor on the roster (0 if not booked).
(define
ev-seat-of
(fn
(actors actor)
(let
((i (ev-bk-index actors actor 0)))
(if (< i 0) 0 (+ i 1)))))
;; ---- seat-acquiring writes (capacity-guarded via append-expect) ----
;; One seat-acquiring attempt of `kind` (:booking or :hold) against an OBSERVED
;; snapshot (roster the writer saw + the last-seq). Returns :already / :full /
;; :conflict, or a success dict tagged with `ok-status`. :conflict means a
;; concurrent append landed since the snapshot — the caller must re-observe.
(define
ev-acquire-with-observed
(fn
(b occ-key capacity actor observed-actors expected kind ok-status)
(cond
((ev-bk-member? actor observed-actors) {:seat (ev-seat-of observed-actors actor) :actor actor :status :already})
((>= (len observed-actors) capacity) {:actor actor :capacity capacity :status :full})
(else
(let
((r (persist/append-expect b (ev-booking-stream occ-key) expected kind 0 {:actor actor})))
(if (persist/conflict? r) {:actual (persist/conflict-actual r) :actor actor :status :conflict} {:seat (+ (len observed-actors) 1) :actor actor :status ok-status}))))))
(define
ev-acquire!
(fn
(b occ-key capacity actor kind ok-status)
(let
((res (ev-acquire-with-observed b occ-key capacity actor (ev-booked-actors b occ-key) (persist/last-seq b (ev-booking-stream occ-key)) kind ok-status)))
(if
(= (get res :status) :conflict)
(ev-acquire! b occ-key capacity actor kind ok-status)
res))))
;; Capacity-safe confirmed booking (retrying on conflict).
(define
ev/book!
(fn
(b occ-key capacity actor)
(ev-acquire! b occ-key capacity actor :booking :booked)))
;; Capacity-safe provisional hold (retrying on conflict). The seat is reserved
;; (counts toward capacity) until confirmed or released.
(define
ev/hold!
(fn
(b occ-key capacity actor)
(ev-acquire! b occ-key capacity actor :hold :held)))
;; Test seam: one attempt against a caller-supplied snapshot (book or hold).
(define
ev/book-with-observed
(fn
(b occ-key capacity actor observed-actors expected)
(ev-acquire-with-observed
b
occ-key
capacity
actor
observed-actors
expected
:booking :booked)))
(define
ev/hold-with-observed
(fn
(b occ-key capacity actor observed-actors expected)
(ev-acquire-with-observed
b
occ-key
capacity
actor
observed-actors
expected
:hold :held)))
;; ---- state transitions / seat-freeing writes (no oversell, append direct) ----
;; Confirm a held seat (payment succeeded). :confirmed on success,
;; :already-confirmed if it was confirmed, :not-held otherwise.
(define
ev/confirm!
(fn
(b occ-key actor)
(let
((st (ev/seat-state b occ-key actor)))
(cond
((= st :held)
(begin
(persist/append
b
(ev-booking-stream occ-key)
:confirm 0
{:actor actor})
{:actor actor :status :confirmed}))
((= st :confirmed) {:actor actor :status :already-confirmed})
(else {:actor actor :status :not-held})))))
;; Release a held seat (payment failed/expired), freeing it. Only valid for a
;; held seat — confirmed bookings are given up via ev/cancel!.
(define
ev/release!
(fn
(b occ-key actor)
(let
((st (ev/seat-state b occ-key actor)))
(if
(= st :held)
(begin
(persist/append
b
(ev-booking-stream occ-key)
:release 0
{:actor actor})
{:actor actor :status :released})
{:actor actor :status :not-held}))))
;; Cancel a held or confirmed seat, freeing it. :cancelled or :not-booked.
(define
ev/cancel!
(fn
(b occ-key actor)
(if
(ev-bk-member? actor (ev-booked-actors b occ-key))
(begin
(persist/append
b
(ev-booking-stream occ-key)
:cancel 0
{:actor actor})
{:actor actor :status :cancelled})
{:actor actor :status :not-booked})))
;; The roster as a plain list of actors (oldest active first).
(define ev/roster (fn (b occ-key) (ev-booked-actors b occ-key)))
;; Seats remaining for an occurrence of the given capacity.
(define
ev/seats-left
(fn
(b occ-key capacity)
(max 0 (- capacity (ev-booking-count b occ-key)))))

468
lib/events/calendar.sx Normal file
View File

@@ -0,0 +1,468 @@
;; lib/events/calendar.sx — civil date arithmetic + RRULE expansion in a window.
;;
;; Datetimes are integer "epoch minutes": days-since-1970-01-01 * 1440 plus
;; minute-of-day. Ordering, window bounds, and durations are plain integer
;; arithmetic. Civil <-> day-number conversion uses Howard Hinnant's algorithm
;; (exact, branch-free, correct for the proleptic Gregorian calendar).
;;
;; RRULE expansion is the bridge to Datalog: a recurring event expands to a
;; bounded list of occurrence dicts within an explicit (win-start, win-end)
;; window. Expansion is ALWAYS windowed — an RRULE without a window is an
;; infinite computation and is never permitted. Supported subset (RFC 5545):
;; FREQ=DAILY|WEEKLY|MONTHLY, INTERVAL, COUNT, UNTIL, BYDAY (weekly: weekday
;; numbers; monthly: {:ord N :wd W} ordinal weekdays), BYMONTHDAY (monthly,
;; negative = from month end). YEARLY and the rest are deferred.
;; ---- integer helpers ----
;; Floored integer division (modulo is already floored, so the remainder
;; subtraction makes the quotient exact and floor-correct for any sign).
(define ev-floor-div (fn (a b) (quotient (- a (modulo a b)) b)))
(define ev-or (fn (x d) (if (nil? x) d x)))
(define ev-filter-nil (fn (xs) (filter (fn (x) (not (nil? x))) xs)))
;; ---- civil date core (Hinnant) ----
;; Days since 1970-01-01 for civil (y, m, d). m in [1,12], d in [1,31].
(define
ev-days-from-civil
(fn
(y0 m d)
(let
((y (if (<= m 2) (- y0 1) y0)))
(let
((era (ev-floor-div (if (>= y 0) y (- y 399)) 400)))
(let
((yoe (- y (* era 400)))
(doy
(+
(ev-floor-div
(+
(*
153
(+ m (if (> m 2) -3 9)))
2)
5)
(- d 1))))
(let
((doe (+ (* yoe 365) (ev-floor-div yoe 4) (- (ev-floor-div yoe 100)) doy)))
(+ (* era 146097) doe -719468)))))))
;; Civil (y m d) list from a day-number.
(define
ev-civil-from-days
(fn
(z0)
(let
((z (+ z0 719468)))
(let
((era (ev-floor-div (if (>= z 0) z (- z 146096)) 146097)))
(let
((doe (- z (* era 146097))))
(let
((yoe (ev-floor-div (+ (- doe (ev-floor-div doe 1460)) (ev-floor-div doe 36524) (- (ev-floor-div doe 146096))) 365)))
(let
((y (+ yoe (* era 400)))
(doy
(-
doe
(+
(* 365 yoe)
(ev-floor-div yoe 4)
(- (ev-floor-div yoe 100))))))
(let
((mp (ev-floor-div (+ (* 5 doy) 2) 153)))
(let
((d (+ (- doy (ev-floor-div (+ (* 153 mp) 2) 5)) 1))
(m
(if
(< mp 10)
(+ mp 3)
(- mp 9))))
(list (if (<= m 2) (+ y 1) y) m d))))))))))
;; Weekday of a day-number: 0=Mon .. 6=Sun (1970-01-01 is Thursday = 3).
(define ev-weekday-of-days (fn (z) (modulo (+ z 3) 7)))
(define
ev-days-in-month
(fn
(y m)
(-
(ev-days-from-civil
(if (= m 12) (+ y 1) y)
(if (= m 12) 1 (+ m 1))
1)
(ev-days-from-civil y m 1))))
;; Add k months to (y,m), returning (list y2 m2).
(define
ev-add-months
(fn
(y m k)
(let
((total (+ (* y 12) (- m 1) k)))
(list
(ev-floor-div total 12)
(+ (modulo total 12) 1)))))
;; ---- datetime (epoch minutes) ----
(define
ev-dt
(fn
(y m d hh mm)
(+ (* (ev-days-from-civil y m d) 1440) (* hh 60) mm)))
(define ev-date (fn (y m d) (ev-dt y m d 0 0)))
(define ev-dt->days (fn (t) (ev-floor-div t 1440)))
(define ev-dt->civil (fn (t) (ev-civil-from-days (ev-dt->days t))))
(define ev-dt-weekday (fn (t) (ev-weekday-of-days (ev-dt->days t))))
(define ev-dt-tod (fn (t) (modulo t 1440)))
(define ev-civ-y (fn (c) (first c)))
(define ev-civ-m (fn (c) (first (rest c))))
(define ev-civ-d (fn (c) (first (rest (rest c)))))
;; ---- event + occurrence constructors ----
;; rrule is nil (single event) or a dict:
;; {:freq :daily|:weekly|:monthly :interval N :count N|nil :until DT|nil
;; :byday ...|nil :bymonthday (list 15 -1)|nil}
;; weekly :byday -> (list 0 2 4) weekday numbers, 0=Mon
;; monthly :byday -> (list {:ord 2 :wd 1}) nth weekday (ord<0 from end)
;; monthly :bymonthday -> (list 15 -1) day of month (negative from end)
(define ev-event (fn (id dtstart duration rrule capacity) {:duration duration :id id :dtstart dtstart :capacity capacity :rrule rrule}))
(define ev-occ (fn (id start dur) {:id id :start start :end (+ start dur)}))
;; ---- DAILY expansion ----
;; occ starts at dtstart; n counts every generated occurrence (window-
;; independent, so COUNT/UNTIL bound the rule, not the view). Emits only
;; occurrences inside [win-start, win-end].
(define
ev-daily-loop
(fn
(id occ duration step count until dtstart win-start win-end acc n)
(cond
((> occ win-end) acc)
((and (not (nil? count)) (>= n count)) acc)
((and (not (nil? until)) (> occ until)) acc)
(else
(begin
(when (>= occ win-start) (append! acc (ev-occ id occ duration)))
(ev-daily-loop
id
(+ occ step)
duration
step
count
until
dtstart
win-start
win-end
acc
(+ n 1)))))))
;; ---- shared per-period emit ----
;; Walk a start-ascending list of candidate occurrence datetimes for one
;; period, generating (count toward COUNT) those >= dtstart within UNTIL, and
;; emitting those also inside the window. Returns the updated running n.
(define
ev-emit-occs
(fn
(id occs duration count until dtstart win-start win-end acc n)
(if
(empty? occs)
n
(let
((occ (first occs)))
(let
((generates? (and (>= occ dtstart) (or (nil? until) (<= occ until)) (or (nil? count) (< n count)))))
(begin
(when
(and generates? (>= occ win-start) (<= occ win-end))
(append! acc (ev-occ id occ duration)))
(ev-emit-occs
id
(rest occs)
duration
count
until
dtstart
win-start
win-end
acc
(if generates? (+ n 1) n))))))))
;; ---- WEEKLY expansion ----
;; Iterate week by week from the Monday of dtstart's week; within each active
;; week emit each BYDAY (sorted). n counts every generated occurrence.
(define
ev-week0-days
(fn (dtstart) (- (ev-dt->days dtstart) (ev-dt-weekday dtstart))))
(define
ev-byday-default
(fn
(byday dtstart)
(if (nil? byday) (list (ev-dt-weekday dtstart)) (sort byday))))
(define
ev-weekly-loop
(fn
(id
week-days
tod
duration
week-step
bd
count
until
dtstart
win-start
win-end
acc
n)
(let
((week-start-dt (* week-days 1440)))
(cond
((> week-start-dt win-end) acc)
((and (not (nil? count)) (>= n count)) acc)
(else
(let
((occs (map (fn (wd) (+ (* (+ week-days wd) 1440) tod)) bd)))
(let
((n2 (ev-emit-occs id occs duration count until dtstart win-start win-end acc n)))
(ev-weekly-loop
id
(+ week-days week-step)
tod
duration
week-step
bd
count
until
dtstart
win-start
win-end
acc
n2))))))))
;; ---- MONTHLY expansion ----
;; Iterate month by month from dtstart's month, stepping by INTERVAL months.
;; Candidate days per month come from BYMONTHDAY, then ordinal BYDAY, else the
;; day-of-month of dtstart (skipped in months too short to contain it).
;; Resolve a BYMONTHDAY value to a valid day-of-month, or nil.
(define
ev-resolve-monthday
(fn
(y m bmd)
(let
((dim (ev-days-in-month y m)))
(let
((day (if (< bmd 0) (+ dim 1 bmd) bmd)))
(if (and (>= day 1) (<= day dim)) day nil)))))
;; Resolve an ordinal weekday {:ord :wd} to a day-of-month, or nil.
(define
ev-resolve-nth-weekday
(fn
(y m ord wd)
(let
((dim (ev-days-in-month y m)))
(if
(> ord 0)
(let
((first-wd (ev-weekday-of-days (ev-days-from-civil y m 1))))
(let
((day (+ 1 (modulo (- wd first-wd) 7) (* (- ord 1) 7))))
(if (<= day dim) day nil)))
(let
((last-wd (ev-weekday-of-days (ev-days-from-civil y m dim))))
(let
((day (- dim (modulo (- last-wd wd) 7) (* (- (- ord) 1) 7))))
(if (>= day 1) day nil)))))))
(define
ev-month-candidates
(fn
(y m rrule dtstart)
(let
((bmd (get rrule :bymonthday)) (byday (get rrule :byday)))
(cond
((not (nil? bmd))
(ev-filter-nil (map (fn (d) (ev-resolve-monthday y m d)) bmd)))
((not (nil? byday))
(ev-filter-nil
(map
(fn
(e)
(ev-resolve-nth-weekday y m (get e :ord) (get e :wd)))
byday)))
(else
(ev-filter-nil
(list
(ev-resolve-monthday y m (ev-civ-d (ev-dt->civil dtstart))))))))))
(define
ev-monthly-loop
(fn
(id
y
m
rrule
duration
tod
interval
count
until
dtstart
win-start
win-end
acc
n)
(let
((month-start (ev-dt y m 1 0 0)))
(cond
((> month-start win-end) acc)
((and (not (nil? count)) (>= n count)) acc)
(else
(let
((days (sort (ev-month-candidates y m rrule dtstart))))
(let
((occs (map (fn (d) (+ (* (ev-days-from-civil y m d) 1440) tod)) days)))
(let
((n2 (ev-emit-occs id occs duration count until dtstart win-start win-end acc n))
(nm (ev-add-months y m interval)))
(ev-monthly-loop
id
(ev-civ-y nm)
(ev-civ-m nm)
rrule
duration
tod
interval
count
until
dtstart
win-start
win-end
acc
n2)))))))))
;; ---- top-level expansion ----
;; Returns a list of occurrence dicts {:id :start :end} within the window.
(define
ev-expand
(fn
(event win-start win-end)
(let
((id (get event :id))
(dtstart (get event :dtstart))
(duration (get event :duration))
(rrule (get event :rrule)))
(if
(nil? rrule)
(if
(and (>= dtstart win-start) (<= dtstart win-end))
(list (ev-occ id dtstart duration))
(list))
(let
((freq (get rrule :freq))
(interval (ev-or (get rrule :interval) 1))
(count (get rrule :count))
(until (get rrule :until))
(byday (get rrule :byday))
(acc (list)))
(begin
(cond
((= freq :daily)
(ev-daily-loop
id
dtstart
duration
(* interval 1440)
count
until
dtstart
win-start
win-end
acc
0))
((= freq :weekly)
(ev-weekly-loop
id
(ev-week0-days dtstart)
(ev-dt-tod dtstart)
duration
(* interval 7)
(ev-byday-default byday dtstart)
count
until
dtstart
win-start
win-end
acc
0))
((= freq :monthly)
(let
((civ (ev-dt->civil dtstart)))
(ev-monthly-loop
id
(ev-civ-y civ)
(ev-civ-m civ)
rrule
duration
(ev-dt-tod dtstart)
interval
count
until
dtstart
win-start
win-end
acc
0)))
(else (error (str "ev-expand: unsupported freq: " freq))))
acc))))))
;; ---- multi-event expansion (sorted by start) ----
;; Insertion of one occurrence into a start-ascending list.
(define
ev-occ-insert
(fn
(o sorted)
(cond
((empty? sorted) (list o))
((<= (get o :start) (get (first sorted) :start)) (cons o sorted))
(else (cons (first sorted) (ev-occ-insert o (rest sorted)))))))
(define
ev-sort-occs
(fn (occs) (reduce (fn (acc o) (ev-occ-insert o acc)) (list) occs)))
;; Expand many events into one occurrence list, ascending by start.
(define
ev-expand-all
(fn
(events win-start win-end)
(let
((acc (list)))
(begin
(for-each
(fn
(ev)
(for-each
(fn (o) (append! acc o))
(ev-expand ev win-start win-end)))
events)
(ev-sort-occs acc)))))

View File

@@ -0,0 +1,56 @@
# events-on-sx conformance config — sourced by lib/guest/conformance.sh.
LANG_NAME=events
MODE=dict
SCOREBOARD_DIR=lib/events
PRELOADS=(
spec/stdlib.sx
lib/r7rs.sx
lib/datalog/tokenizer.sx
lib/datalog/parser.sx
lib/datalog/unify.sx
lib/datalog/db.sx
lib/datalog/builtins.sx
lib/datalog/aggregates.sx
lib/datalog/strata.sx
lib/datalog/eval.sx
lib/datalog/api.sx
lib/datalog/magic.sx
lib/events/calendar.sx
lib/events/availability.sx
lib/persist/event.sx
lib/persist/backend.sx
lib/persist/log.sx
lib/persist/kv.sx
lib/persist/concurrency.sx
lib/persist/api.sx
lib/events/booking.sx
lib/events/ticket.sx
lib/guest/lex.sx
lib/guest/reflective/env.sx
lib/guest/reflective/quoting.sx
lib/scheme/parser.sx
lib/scheme/eval.sx
lib/scheme/runtime.sx
lib/flow/spec.sx
lib/flow/store.sx
lib/flow/remote.sx
lib/flow/host.sx
lib/flow/api.sx
lib/events/notify.sx
lib/events/api.sx
lib/events/reminders.sx
lib/events/federation.sx
)
SUITES=(
"calendar:lib/events/tests/calendar.sx:(ev-calendar-tests-run!)"
"availability:lib/events/tests/availability.sx:(ev-availability-tests-run!)"
"api:lib/events/tests/api.sx:(ev-api-tests-run!)"
"booking:lib/events/tests/booking.sx:(ev-booking-tests-run!)"
"ticket:lib/events/tests/ticket.sx:(ev-ticket-tests-run!)"
"notify:lib/events/tests/notify.sx:(ev-notify-tests-run!)"
"reminders:lib/events/tests/reminders.sx:(ev-reminders-tests-run!)"
"federation:lib/events/tests/federation.sx:(ev-federation-tests-run!)"
)

3
lib/events/conformance.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Thin wrapper — see lib/guest/conformance.sh and lib/events/conformance.conf.
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"

165
lib/events/federation.sx Normal file
View File

@@ -0,0 +1,165 @@
;; lib/events/federation.sx — cross-instance calendar federation (trust-gated).
;;
;; A peer is another events instance that publishes a schedule (an events
;; store). We merge a peer's agenda into ours ONLY if we trust it — trust is a
;; set of peer ids, re-checked on every merge, so revoking a peer takes effect
;; immediately. Merged occurrences carry :origin provenance (:local for ours, or
;; the peer id) so a consumer always knows where a slot came from.
;;
;; This is the trust-gated stub: peers publish plain schedules and we fold the
;; trusted ones into a single sorted agenda. Real transport (fed-sx / signed
;; fetch) slots in behind `ev/peer-agenda` without changing the merge.
;;
;; Federated FREE/BUSY follows the iCal model: a peer publishes BUSY intervals
;; for an actor (not event details — privacy-preserving), and we union local +
;; trusted-peer busy to answer "is this actor free?" across instances.
(define ev/peer (fn (id store) {:id id :busy (list) :store store}))
;; A peer that also publishes free/busy: `busy` is a list of
;; (actor ((start end) ...)) pairs.
(define ev/peer-with-busy (fn (id store busy) {:id id :busy busy :store store}))
(define ev/peer-id (fn (p) (get p :id)))
(define ev/peer-store (fn (p) (get p :store)))
(define ev/peer-busy-table (fn (p) (get p :busy)))
(define
ev-fed-member?
(fn
(x xs)
(cond
((empty? xs) false)
((= x (first xs)) true)
(else (ev-fed-member? x (rest xs))))))
;; Do we trust this peer id? (trust is a list of trusted peer ids.)
(define ev/trusts? (fn (trust peer-id) (ev-fed-member? peer-id trust)))
;; The trusted subset of a peer list.
(define
ev/trusted-peers
(fn
(peers trust)
(filter (fn (p) (ev/trusts? trust (ev/peer-id p))) peers)))
;; Tag occurrences with provenance.
(define ev-tag-origin (fn (occs origin) (map (fn (o) {:id (get o :id) :start (get o :start) :end (get o :end) :origin origin}) occs)))
;; A peer's agenda over [ws, we), tagged with the peer's id as :origin.
(define
ev/peer-agenda
(fn
(peer ws we)
(ev-tag-origin (ev/agenda (ev/peer-store peer) ws we) (ev/peer-id peer))))
;; ---- merge (sorted by start, then origin for ties) ----
(define
ev-fed-before?
(fn
(a c)
(cond
((< (get a :start) (get c :start)) true)
((> (get a :start) (get c :start)) false)
(else (< (str (get a :origin)) (str (get c :origin)))))))
(define
ev-fed-insert
(fn
(x sorted)
(cond
((empty? sorted) (list x))
((ev-fed-before? x (first sorted)) (cons x sorted))
(else (cons (first sorted) (ev-fed-insert x (rest sorted)))))))
(define
ev-fed-sort
(fn (xs) (reduce (fn (acc x) (ev-fed-insert x acc)) (list) xs)))
;; Local agenda (origin :local) merged with every TRUSTED peer's agenda,
;; sorted by start. Untrusted peers contribute nothing.
(define
ev/federated-agenda
(fn
(local-store peers trust ws we)
(let
((acc (list)))
(begin
(for-each
(fn (o) (append! acc o))
(ev-tag-origin (ev/agenda local-store ws we) :local))
(for-each
(fn
(peer)
(when
(ev/trusts? trust (ev/peer-id peer))
(for-each
(fn (o) (append! acc o))
(ev/peer-agenda peer ws we))))
peers)
(ev-fed-sort acc)))))
;; Filter a federated agenda to occurrences from one origin.
(define
ev/from-origin
(fn
(agenda origin)
(filter (fn (o) (= (get o :origin) origin)) agenda)))
;; ---- federated free/busy ----
;; A peer's published busy intervals for `actor` ((start end) ...), or empty.
(define
ev/peer-busy
(fn
(peer actor)
(let
((row (ev-fed-assoc actor (ev/peer-busy-table peer))))
(if (nil? row) (list) (first (rest row))))))
(define
ev-fed-assoc
(fn
(k pairs)
(cond
((empty? pairs) nil)
((= (first (first pairs)) k) (first pairs))
(else (ev-fed-assoc k (rest pairs))))))
;; All busy intervals for `actor` across the LOCAL availability db plus every
;; TRUSTED peer's published free/busy, merged and sorted by start.
;; `local-db` is an availability db (see availability.sx ev-build-avail).
(define
ev/federated-busy
(fn
(local-db peers trust actor)
(let
((acc (list)))
(begin
(for-each (fn (iv) (append! acc iv)) (ev-busy local-db actor))
(for-each
(fn
(peer)
(when
(ev/trusts? trust (ev/peer-id peer))
(for-each
(fn (iv) (append! acc iv))
(ev/peer-busy peer actor))))
peers)
(ev-sort-lists acc)))))
;; Half-open overlap of interval (s e) with window [qs, qe).
(define
ev-fed-overlaps?
(fn (iv qs qe) (and (< (first iv) qe) (< qs (first (rest iv))))))
;; Is `actor` free across [qs, qe) considering local + trusted-peer busy?
(define
ev/federated-free?
(fn
(local-db peers trust actor qs qe)
(not
(some
(fn (iv) (ev-fed-overlaps? iv qs qe))
(ev/federated-busy local-db peers trust actor)))))

38
lib/events/notify.sx Normal file
View File

@@ -0,0 +1,38 @@
;; lib/events/notify.sx — durable notification delivery flows over an injected
;; transport (lib/flow).
;;
;; Reminders and digests are durable `flow`s: a flow `request`s delivery (a
;; suspend point), the HOST performs the actual send via an injected `dispatch`
;; (the transport — email/push/etc.), and resumes the flow with the outcome.
;; Because flow uses deterministic replay, a completed delivery is never re-run
;; on recovery; the host owns IO and persistence.
;;
;; Delivery is AT-LEAST-ONCE with idempotency. Each message carries an id (the
;; idempotency key). Two protections stop double-delivery:
;; 1. The transport dedups by id — a re-send of a delivered id is a no-op
;; that still reports ok, so a retry never produces two pings.
;; 2. flow's replay log records each resolved request, so recovery replays the
;; logged outcome instead of re-issuing the send.
;;
;; Retry/backoff rides flow suspend/resume: each attempt issues a request with a
;; DISTINCT tag `(deliver <id> <n>)` — distinct tags keep deterministic replay
;; correct across retries. The dispatch returns (ok info) to finish or
;; (retry reason) to try again, bounded by `maxn` (then (failed id reason)).
;;
;; A message is a 3-element list (id recipient body). The transport is generic
;; and injected — when feed/notify lands, both consumers share one transport,
;; so this delivery core is a candidate for extraction to `delivery-on-sx`.
;;
;; The Scheme flow source below loads into a flow env (see lib/flow/api.sx).
;; `ev/notify-run` prepends it to a caller program and evaluates in the shared
;; flow env.
(define
ev-notify-flows-src
"(define (ev-msg-id m) (car m))\n (define (ev-msg-recipient m) (car (cdr m)))\n (define (ev-msg-body m) (car (cdr (cdr m))))\n (define (ev-mem x xs)\n (if (null? xs) #f (if (equal? x (car xs)) #t (ev-mem x (cdr xs)))))\n (define (ev-notify-attempt m n maxn)\n (let ((r (request (list (quote deliver) (ev-msg-id m) n) m)))\n (if (eq? (car r) (quote ok))\n (list (quote delivered) (ev-msg-id m) n)\n (if (>= n maxn)\n (list (quote failed) (ev-msg-id m) (car (cdr r)))\n (ev-notify-attempt m (+ n 1) maxn)))))\n (define (ev-deliver-reminder maxn)\n (flow-node (lambda (m) (ev-notify-attempt m 1 maxn))))\n (define (ev-digest-step ms maxn)\n (if (null? ms)\n (list)\n (cons (ev-notify-attempt (car ms) 1 maxn)\n (ev-digest-step (cdr ms) maxn))))\n (define (ev-deliver-digest maxn)\n (flow-node (lambda (ms) (ev-digest-step ms maxn))))")
;; Run a Scheme flow program with the notify flows preloaded, in the shared
;; flow env. Returns the program's value (SX-native).
(define
ev/notify-run
(fn (prog) (flow-run (str ev-notify-flows-src "\n" prog))))

96
lib/events/reminders.sx Normal file
View File

@@ -0,0 +1,96 @@
;; lib/events/reminders.sx — derive reminder + digest messages from the agenda.
;;
;; Bridges the schedule (calendar) and the durable roster (booking on persist)
;; to the notification layer (notify.sx). For each booked attendee of each
;; upcoming occurrence we derive a reminder message that fires `lead` minutes
;; before the occurrence starts. Each message has a deterministic idempotency
;; key — occ-key / recipient / lead — so re-deriving over an overlapping window
;; never produces a duplicate ping (the notify transport dedups on this id).
;;
;; A reminder is a dict:
;; {:id :recipient :event :start :fire-at}
;; `ev/reminder->msg` projects it to notify's (id recipient body) wire shape.
;; Reminders for one occurrence: one per booked attendee (durable roster).
(define
ev/occurrence-reminders
(fn
(b occ lead)
(let
((occ-key (ev-occ-key occ))
(start (get occ :start))
(evid (get occ :id)))
(map (fn (actor) {:id (str occ-key "/" actor "/" lead) :event evid :start start :fire-at (- start lead) :recipient actor}) (ev/roster-occ b occ)))))
;; Insertion sort of reminder dicts ascending by :fire-at (then :id for ties).
(define
ev-rem-before?
(fn
(a c)
(cond
((< (get a :fire-at) (get c :fire-at)) true)
((> (get a :fire-at) (get c :fire-at)) false)
(else (< (get a :id) (get c :id))))))
(define
ev-rem-insert
(fn
(r sorted)
(cond
((empty? sorted) (list r))
((ev-rem-before? r (first sorted)) (cons r sorted))
(else (cons (first sorted) (ev-rem-insert r (rest sorted)))))))
(define
ev-rem-sort
(fn (rs) (reduce (fn (acc r) (ev-rem-insert r acc)) (list) rs)))
;; All reminders across the agenda in [ws, we), ascending by fire-at.
(define
ev/agenda-reminders
(fn
(b store ws we lead)
(let
((acc (list)))
(begin
(for-each
(fn
(occ)
(for-each
(fn (r) (append! acc r))
(ev/occurrence-reminders b occ lead)))
(ev/agenda store ws we))
(ev-rem-sort acc)))))
;; Reminders whose fire-at has arrived (fire-at <= now) — what a scheduler
;; should hand to the notify transport at time `now`.
(define
ev/due-reminders
(fn
(reminders now)
(filter (fn (r) (<= (get r :fire-at) now)) reminders)))
;; Project a reminder to notify's (id recipient body) wire shape.
(define
ev/reminder->msg
(fn
(r)
(list
(get r :id)
(get r :recipient)
(list :reminder (get r :event) (get r :start)))))
;; ---- digests ----
;; The occurrences `actor` is booked into (durable roster), within window.
(define
ev/agenda-for-p
(fn
(b store actor ws we)
(filter
(fn (occ) (ev-bk-member? actor (ev/roster-occ b occ)))
(ev/agenda store ws we))))
;; A single digest message summarising an actor's upcoming booked occurrences.
;; :items is ({:event :start} ...); empty when the actor has nothing booked.
(define ev/agenda-digest (fn (b store actor ws we) {:items (map (fn (occ) {:event (get occ :id) :start (get occ :start)}) (ev/agenda-for-p b store actor ws we)) :id (str actor "/digest/" ws "-" we) :recipient actor}))

View File

@@ -0,0 +1,17 @@
{
"lang": "events",
"total_passed": 219,
"total_failed": 0,
"total": 219,
"suites": [
{"name":"calendar","passed":37,"failed":0,"total":37},
{"name":"availability","passed":22,"failed":0,"total":22},
{"name":"api","passed":24,"failed":0,"total":24},
{"name":"booking","passed":61,"failed":0,"total":61},
{"name":"ticket","passed":31,"failed":0,"total":31},
{"name":"notify","passed":7,"failed":0,"total":7},
{"name":"reminders","passed":14,"failed":0,"total":14},
{"name":"federation","passed":23,"failed":0,"total":23}
],
"generated": "2026-06-07T05:31:56+00:00"
}

14
lib/events/scoreboard.md Normal file
View File

@@ -0,0 +1,14 @@
# events scoreboard
**219 / 219 passing** (0 failure(s)).
| Suite | Passed | Total | Status |
|-------|--------|-------|--------|
| calendar | 37 | 37 | ok |
| availability | 22 | 22 | ok |
| api | 24 | 24 | ok |
| booking | 61 | 61 | ok |
| ticket | 31 | 31 | ok |
| notify | 7 | 7 | ok |
| reminders | 14 | 14 | ok |
| federation | 23 | 23 | ok |

271
lib/events/tests/api.sx Normal file
View File

@@ -0,0 +1,271 @@
;; lib/events/tests/api.sx — public events facade (schedule/agenda/free/book).
(define ev-api-pass 0)
(define ev-api-fail 0)
(define ev-api-failures (list))
(define
ev-api-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-api-pass (+ ev-api-pass 1))
(do
(set! ev-api-fail (+ ev-api-fail 1))
(append!
ev-api-failures
(str name "\n expected: " expected "\n got: " got))))))
;; A store with a weekly yoga class (Mon+Wed 18:00, 60m, 4 occurrences).
(define
ev-api-store
(fn
()
(ev/schedule
(ev/empty)
(quote yoga)
(ev-dt 2026 6 1 18 0)
60
{:freq :weekly :count 4 :byday (list 0 2)}
20)))
(define
ev-api-run-all!
(fn
()
(let
((s0 (ev-api-store)))
(let
((occs (ev/agenda s0 (ev-date 2026 6 1) (ev-date 2026 7 1))))
(let
((s1 (ev/book (ev/book s0 (quote nia) (ev-occ-key (first occs))) (quote nia) (ev-occ-key (first (rest occs))))))
(do
(ev-api-check!
"agenda expands weekly class to four occurrences"
(map (fn (o) (ev-dt->civil (get o :start))) occs)
(list
(list 2026 6 1)
(list 2026 6 3)
(list 2026 6 8)
(list 2026 6 10)))
(ev-api-check!
"empty store has empty agenda"
(ev/agenda
(ev/empty)
(ev-date 2026 6 1)
(ev-date 2026 7 1))
(list))
(ev-api-check!
"max duration reflects scheduled events"
(ev/store-max-duration s0)
60)
(ev-api-check!
"max duration of empty store is zero"
(ev/store-max-duration (ev/empty))
0)
(ev-api-check!
"event-by-id finds the scheduled event"
(get (ev/event-by-id s0 (quote yoga)) :capacity)
20)
(ev-api-check!
"event-by-id is nil for unknown id"
(ev/event-by-id s0 (quote nope))
nil)
(ev-api-check!
"agenda-for lists only booked occurrences"
(map
(fn (o) (ev-dt->civil (get o :start)))
(ev/agenda-for
s1
(quote nia)
(ev-date 2026 6 1)
(ev-date 2026 7 1)))
(list
(list 2026 6 1)
(list 2026 6 3)))
(ev-api-check!
"agenda-for empty for unbooked actor"
(ev/agenda-for
s1
(quote zed)
(ev-date 2026 6 1)
(ev-date 2026 7 1))
(list))
(ev-api-check!
"free? false during a booked occurrence"
(ev/free?
s1
(quote nia)
(ev-dt 2026 6 1 18 30)
(ev-dt 2026 6 1 19 0))
false)
(ev-api-check!
"free? true in an open window"
(ev/free?
s1
(quote nia)
(ev-dt 2026 6 1 9 0)
(ev-dt 2026 6 1 10 0))
true)
(ev-api-check!
"free? half-open at occurrence end"
(ev/free?
s1
(quote nia)
(ev-dt 2026 6 1 19 0)
(ev-dt 2026 6 1 20 0))
true)
(ev-api-check!
"free? true for an actor who booked nothing"
(ev/free?
s1
(quote zed)
(ev-dt 2026 6 1 18 0)
(ev-dt 2026 6 1 19 0))
true)
(ev-api-check!
"next-free skips the booked slot to the hour after"
(ev-dt-tod
(ev/next-free
s1
(quote nia)
(ev-dt
2026
6
1
18
0)
60
(ev-dt
2026
6
1
23
0)))
(* 19 60))
(ev-api-check!
"next-free returns `after` when already open"
(ev/next-free
s1
(quote nia)
(ev-dt 2026 6 1 9 0)
60
(ev-dt 2026 6 1 18 0))
(ev-dt 2026 6 1 9 0))
(ev-api-check!
"no conflict among disjoint bookings"
(ev/has-conflict?
s1
(quote nia)
(ev-date 2026 6 1)
(ev-date 2026 7 1))
false)
(let
((sc (ev/book (ev/schedule s1 (quote talk) (ev-dt 2026 6 1 18 30) 60 nil 5) (quote nia) (ev-occ-key (ev-occ (quote talk) (ev-dt 2026 6 1 18 30) 60)))))
(ev-api-check!
"overlapping second booking creates a conflict"
(ev/has-conflict?
sc
(quote nia)
(ev-date 2026 6 1)
(ev-date 2026 7 1))
true))
(let
((b (persist/open)) (occ1 (first occs)))
(do
(let
((sp (ev/schedule (ev/empty) (quote clinic) (ev-dt 2026 6 5 9 0) 30 nil 2)))
(let
((occ (ev-occ (quote clinic) (ev-dt 2026 6 5 9 0) 30)))
(do
(ev-api-check!
"durable book returns booked"
(get (ev/book-occ! b sp (quote a) occ) :status)
:booked)
(ev/book-occ! b sp (quote c) occ)
(ev-api-check!
"durable book past capacity is full"
(get (ev/book-occ! b sp (quote d) occ) :status)
:full)
(ev-api-check!
"durable roster reflects persisted bookings"
(ev/roster-occ b occ)
(list (quote a) (quote c)))
(ev-api-check!
"durable seats-left honours capacity"
(ev/seats-left-occ b sp occ)
0)
(ev-api-check!
"persist free? false during a durable booking"
(ev/free-p?
b
sp
(quote a)
(ev-dt
2026
6
5
9
10)
(ev-dt
2026
6
5
9
20))
false)
(ev-api-check!
"persist free? true in an open window"
(ev/free-p?
b
sp
(quote a)
(ev-dt
2026
6
5
10
0)
(ev-dt
2026
6
5
10
30))
true)
(ev/cancel-occ! b sp (quote a) occ)
(ev-api-check!
"durable cancel frees a seat"
(ev/seats-left-occ b sp occ)
1)
(ev-api-check!
"persist free? true after cancellation"
(ev/free-p?
b
sp
(quote a)
(ev-dt
2026
6
5
9
10)
(ev-dt
2026
6
5
9
20))
true))))))))))))
(define
ev-api-tests-run!
(fn
()
(do
(set! ev-api-pass 0)
(set! ev-api-fail 0)
(set! ev-api-failures (list))
(ev-api-run-all!)
{:failures ev-api-failures :total (+ ev-api-pass ev-api-fail) :passed ev-api-pass :failed ev-api-fail})))

View File

@@ -0,0 +1,331 @@
;; lib/events/tests/availability.sx — free/busy + conflict rules on Datalog.
(define ev-av-pass 0)
(define ev-av-fail 0)
(define ev-av-failures (list))
(define
ev-av-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-av-pass (+ ev-av-pass 1))
(do
(set! ev-av-fail (+ ev-av-fail 1))
(append!
ev-av-failures
(str name "\n expected: " expected "\n got: " got))))))
;; Fixture: three occurrences on 2026-06-01.
;; standup 09:0009:30 review 09:1510:15 (overlaps standup)
;; lunch 12:0013:00
(define
ev-av-occs
(fn
()
(list
(ev-occ
(quote standup)
(ev-dt 2026 6 1 9 0)
30)
(ev-occ
(quote review)
(ev-dt 2026 6 1 9 15)
60)
(ev-occ
(quote lunch)
(ev-dt 2026 6 1 12 0)
60))))
(define ev-av-key (fn (id start) (str id "@" start)))
;; alice: standup + review (overlap → conflict). bob: lunch only.
(define
ev-av-db
(fn
()
(ev-avail-db
(ev-av-occs)
(list
(list
(quote alice)
(ev-av-key
(quote standup)
(ev-dt 2026 6 1 9 0)))
(list
(quote alice)
(ev-av-key
(quote review)
(ev-dt 2026 6 1 9 15)))
(list
(quote bob)
(ev-av-key
(quote lunch)
(ev-dt 2026 6 1 12 0)))))))
;; Disjoint fixture for slot search: 09:0010:00 then 10:3011:30 (a 30m gap).
(define
ev-av-gap-db
(fn
()
(ev-avail-db
(list
(ev-occ
(quote a)
(ev-dt 2026 6 1 9 0)
60)
(ev-occ
(quote b)
(ev-dt 2026 6 1 10 30)
60))
(list
(list
(quote sam)
(ev-av-key
(quote a)
(ev-dt 2026 6 1 9 0)))
(list
(quote sam)
(ev-av-key
(quote b)
(ev-dt 2026 6 1 10 30)))))))
(define
ev-av-run-all!
(fn
()
(let
((db (ev-av-db)))
(do
(ev-av-check!
"busy lists alice committed intervals ascending"
(ev-busy db (quote alice))
(list
(list
(ev-dt 2026 6 1 9 0)
(ev-dt 2026 6 1 9 30))
(list
(ev-dt 2026 6 1 9 15)
(ev-dt 2026 6 1 10 15))))
(ev-av-check!
"busy lists bob single interval"
(ev-busy db (quote bob))
(list
(list
(ev-dt 2026 6 1 12 0)
(ev-dt 2026 6 1 13 0))))
(ev-av-check!
"busy empty for unknown actor"
(ev-busy db (quote carol))
(list))
(ev-av-check!
"alice has an overlap conflict"
(ev-has-conflict? db (quote alice))
true)
(ev-av-check!
"alice conflict reported once (canonical pair)"
(len (ev-conflicts db (quote alice)))
1)
(ev-av-check!
"bob has no conflict"
(ev-has-conflict? db (quote bob))
false)
(ev-av-check!
"non-overlapping bookings do not conflict"
(ev-has-conflict?
(ev-avail-db
(list
(ev-occ
(quote a)
(ev-dt
2026
6
1
9
0)
30)
(ev-occ
(quote b)
(ev-dt
2026
6
1
9
30)
30))
(list
(list
(quote dave)
(ev-av-key
(quote a)
(ev-dt
2026
6
1
9
0)))
(list
(quote dave)
(ev-av-key
(quote b)
(ev-dt
2026
6
1
9
30)))))
(quote dave))
false)
(ev-av-check!
"alice free in an empty window"
(ev-free?
db
(quote alice)
(ev-dt 2026 6 1 13 0)
(ev-dt 2026 6 1 14 0))
true)
(ev-av-check!
"alice not free overlapping a booking"
(ev-free?
db
(quote alice)
(ev-dt 2026 6 1 9 20)
(ev-dt 2026 6 1 9 40))
false)
(ev-av-check!
"free? is half-open at the trailing edge"
(ev-free?
db
(quote alice)
(ev-dt 2026 6 1 10 15)
(ev-dt 2026 6 1 11 0))
true)
(ev-av-check!
"free? is half-open at the leading edge"
(ev-free?
db
(quote bob)
(ev-dt 2026 6 1 11 0)
(ev-dt 2026 6 1 12 0))
true)
(ev-av-check!
"free? false when window straddles a booking edge"
(ev-free?
db
(quote bob)
(ev-dt 2026 6 1 11 0)
(ev-dt 2026 6 1 12 1))
false)
(ev-av-check!
"free? query leaves db reusable (no leaked qwindow)"
(do
(ev-free?
db
(quote alice)
(ev-dt 2026 6 1 9 0)
(ev-dt 2026 6 1 9 30))
(ev-busy db (quote bob)))
(list
(list
(ev-dt 2026 6 1 12 0)
(ev-dt 2026 6 1 13 0))))
(let
((gdb (ev-av-gap-db)))
(do
(ev-av-check!
"next-free finds the gap between bookings"
(ev-next-free
gdb
(quote sam)
(ev-dt 2026 6 1 9 0)
30
(ev-dt 2026 6 1 18 0))
(ev-dt 2026 6 1 10 0))
(ev-av-check!
"next-free skips a gap too short for the duration"
(ev-next-free
gdb
(quote sam)
(ev-dt 2026 6 1 9 0)
60
(ev-dt 2026 6 1 18 0))
(ev-dt 2026 6 1 11 30))
(ev-av-check!
"next-free returns `after` when already free"
(ev-next-free
gdb
(quote sam)
(ev-dt 2026 6 1 14 0)
60
(ev-dt 2026 6 1 18 0))
(ev-dt 2026 6 1 14 0))
(ev-av-check!
"next-free returns nil when nothing fits before horizon"
(ev-next-free
gdb
(quote sam)
(ev-dt 2026 6 1 9 0)
120
(ev-dt 2026 6 1 11 0))
nil)
(ev-av-check!
"next-free for actor with no bookings is `after`"
(ev-next-free
gdb
(quote nobody)
(ev-dt 2026 6 1 9 0)
60
(ev-dt 2026 6 1 18 0))
(ev-dt 2026 6 1 9 0))
(ev-av-check!
"next-free at exact edge of a booking (half-open)"
(ev-next-free
gdb
(quote sam)
(ev-dt 2026 6 1 10 0)
30
(ev-dt 2026 6 1 18 0))
(ev-dt 2026 6 1 10 0))))
(let
((daily (ev-expand (ev-event (quote class) (ev-dt 2026 6 1 9 0) 60 {:freq :daily :count 3} 1) (ev-date 2026 6 1) (ev-date 2026 7 1))))
(let
((db2 (ev-avail-db daily (map (fn (o) (list (quote sam) (ev-occ-key o))) daily))))
(do
(ev-av-check!
"expanded daily occurrences become busy intervals"
(len (ev-busy db2 (quote sam)))
3)
(ev-av-check!
"no conflicts among disjoint daily occurrences"
(ev-has-conflict? db2 (quote sam))
false)
(ev-av-check!
"busy on day two of the series"
(ev-free?
db2
(quote sam)
(ev-dt
2026
6
2
9
30)
(ev-dt
2026
6
2
9
45))
false))))))))
(define
ev-availability-tests-run!
(fn
()
(do
(set! ev-av-pass 0)
(set! ev-av-fail 0)
(set! ev-av-failures (list))
(ev-av-run-all!)
{:failures ev-av-failures :total (+ ev-av-pass ev-av-fail) :passed ev-av-pass :failed ev-av-fail})))

371
lib/events/tests/booking.sx Normal file
View File

@@ -0,0 +1,371 @@
;; lib/events/tests/booking.sx — capacity-safe booking, cancel, and holds.
(define ev-bk-pass 0)
(define ev-bk-fail 0)
(define ev-bk-failures (list))
(define
ev-bk-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-bk-pass (+ ev-bk-pass 1))
(do
(set! ev-bk-fail (+ ev-bk-fail 1))
(append!
ev-bk-failures
(str name "\n expected: " expected "\n got: " got))))))
;; Take a consistent (roster, last-seq) snapshot of an occurrence's stream.
(define ev-bk-snap (fn (b k) (ev-booked-actors b k)))
(define ev-bk-seq (fn (b k) (persist/last-seq b (ev-booking-stream k))))
(define
ev-bk-run-all!
(fn
()
(do
(let
((b (persist/open)))
(do
(ev-bk-check!
"first booking takes seat 1"
(get (ev/book! b "o1" 3 (quote a)) :seat)
1)
(ev-bk-check!
"second booking takes seat 2"
(get (ev/book! b "o1" 3 (quote c)) :seat)
2)
(ev-bk-check!
"booked status reported"
(get (ev/book! b "o1" 3 (quote d)) :status)
:booked)
(ev-bk-check!
"roster is oldest-first"
(ev/roster b "o1")
(list (quote a) (quote c) (quote d)))
(ev-bk-check!
"seats-left is zero when full"
(ev/seats-left b "o1" 3)
0)
(ev-bk-check!
"free booking is confirmed state"
(ev/seat-state b "o1" (quote a))
:confirmed)))
(let
((b (persist/open)))
(do
(ev/book! b "o2" 1 (quote a))
(ev-bk-check!
"booking past capacity is refused"
(get (ev/book! b "o2" 1 (quote c)) :status)
:full)
(ev-bk-check!
"full does not grow the roster"
(ev/roster b "o2")
(list (quote a)))
(ev-bk-check!
"seats-left zero at capacity"
(ev/seats-left b "o2" 1)
0)))
(let
((b (persist/open)))
(do
(ev/book! b "o3" 5 (quote a))
(ev-bk-check!
"re-booking the same actor is idempotent"
(get (ev/book! b "o3" 5 (quote a)) :status)
:already)
(ev-bk-check!
"idempotent re-book reports existing seat"
(get (ev/book! b "o3" 5 (quote a)) :seat)
1)
(ev-bk-check!
"roster unchanged after re-book"
(ev/roster b "o3")
(list (quote a)))
(ev-bk-check!
"count unchanged after re-book"
(ev-booking-count b "o3")
1)))
(let
((b (persist/open)))
(do
(ev/book! b "last" 2 (quote x))
(let
((snap (ev-bk-snap b "last")) (exp (ev-bk-seq b "last")))
(let
((ra (ev/book-with-observed b "last" 2 (quote a) snap exp))
(rb
(ev/book-with-observed
b
"last"
2
(quote bee)
snap
exp)))
(do
(ev-bk-check!
"race winner is booked"
(get ra :status)
:booked)
(ev-bk-check!
"race winner takes the last seat"
(get ra :seat)
2)
(ev-bk-check!
"race loser is rejected with a conflict"
(get rb :status)
:conflict)
(ev-bk-check!
"conflict reports the advanced seq"
(get rb :actual)
(+ exp 1))
(ev-bk-check!
"no overbooking: exactly two on roster"
(ev-booking-count b "last")
2)
(ev-bk-check!
"race loser is NOT on the roster"
(ev-bk-member? (quote bee) (ev/roster b "last"))
false)
(ev-bk-check!
"race loser retrying gets full"
(get (ev/book! b "last" 2 (quote bee)) :status)
:full))))))
(let
((b (persist/open)))
(do
(ev/book! b "room" 3 (quote x))
(let
((snap (ev-bk-snap b "room")) (exp (ev-bk-seq b "room")))
(let
((ra (ev/book-with-observed b "room" 3 (quote a) snap exp))
(rb
(ev/book-with-observed
b
"room"
3
(quote bee)
snap
exp)))
(do
(ev-bk-check!
"room winner booked seat 2"
(get ra :seat)
2)
(ev-bk-check!
"room loser first conflicts"
(get rb :status)
:conflict)
(ev-bk-check!
"room loser retry books seat 3"
(get (ev/book! b "room" 3 (quote bee)) :seat)
3)
(ev-bk-check!
"room roster is x,a,bee"
(ev/roster b "room")
(list (quote x) (quote a) (quote bee)))
(ev-bk-check!
"room is now full"
(ev/seats-left b "room" 3)
0))))))
(let
((b (persist/open)))
(do
(ev/book! b "cx" 2 (quote a))
(ev/book! b "cx" 2 (quote c))
(ev-bk-check!
"occupied to capacity before cancel"
(ev/seats-left b "cx" 2)
0)
(ev-bk-check!
"booking when full (pre-cancel) is refused"
(get (ev/book! b "cx" 2 (quote d)) :status)
:full)
(ev-bk-check!
"cancel reports cancelled"
(get (ev/cancel! b "cx" (quote a)) :status)
:cancelled)
(ev-bk-check!
"cancel removes actor from roster"
(ev/roster b "cx")
(list (quote c)))
(ev-bk-check!
"cancel frees a seat"
(ev/seats-left b "cx" 2)
1)
(ev-bk-check!
"freed seat is bookable again"
(get (ev/book! b "cx" 2 (quote d)) :status)
:booked)
(ev-bk-check!
"roster after rebook is c,d"
(ev/roster b "cx")
(list (quote c) (quote d)))))
(let
((b (persist/open)))
(do
(ev/book! b "ce" 3 (quote a))
(ev-bk-check!
"cancelling an unbooked actor is a no-op"
(get (ev/cancel! b "ce" (quote z)) :status)
:not-booked)
(ev-bk-check!
"no-op cancel leaves roster intact"
(ev/roster b "ce")
(list (quote a)))
(ev/cancel! b "ce" (quote a))
(ev-bk-check!
"double cancel is not-booked the second time"
(get (ev/cancel! b "ce" (quote a)) :status)
:not-booked)
(ev-bk-check!
"empty roster after cancel"
(ev/roster b "ce")
(list))
(ev-bk-check!
"cancelled actor may re-book"
(get (ev/book! b "ce" 3 (quote a)) :status)
:booked)
(ev-bk-check!
"re-booked actor back on roster"
(ev/roster b "ce")
(list (quote a)))))
(let
((b (persist/open)))
(do
(ev/book! b "h" 2 (quote a))
(ev-bk-check!
"hold reports held"
(get (ev/hold! b "h" 2 (quote p)) :status)
:held)
(ev-bk-check!
"held seat is :held state"
(ev/seat-state b "h" (quote p))
:held)
(ev-bk-check!
"held actor is on the roster"
(ev/roster b "h")
(list (quote a) (quote p)))
(ev-bk-check!
"held seat blocks the last booking"
(get (ev/book! b "h" 2 (quote x)) :status)
:full)
(ev-bk-check!
"no seats left with one held"
(ev/seats-left b "h" 2)
0)))
(let
((b (persist/open)))
(do
(ev/hold! b "hc" 3 (quote p))
(ev-bk-check!
"confirm reports confirmed"
(get (ev/confirm! b "hc" (quote p)) :status)
:confirmed)
(ev-bk-check!
"confirmed seat is :confirmed state"
(ev/seat-state b "hc" (quote p))
:confirmed)
(ev-bk-check!
"re-confirm is already-confirmed"
(get (ev/confirm! b "hc" (quote p)) :status)
:already-confirmed)
(ev-bk-check!
"confirming a non-holder is not-held"
(get (ev/confirm! b "hc" (quote z)) :status)
:not-held)
(ev-bk-check!
"confirmed seat still occupies"
(ev/seats-left b "hc" 3)
2)))
(let
((b (persist/open)))
(do
(ev/book! b "hr" 2 (quote a))
(ev/hold! b "hr" 2 (quote p))
(ev-bk-check!
"full while hold pending"
(ev/seats-left b "hr" 2)
0)
(ev-bk-check!
"release reports released"
(get (ev/release! b "hr" (quote p)) :status)
:released)
(ev-bk-check!
"release frees the held seat"
(ev/seats-left b "hr" 2)
1)
(ev-bk-check!
"released actor off the roster"
(ev/roster b "hr")
(list (quote a)))
(ev-bk-check!
"freed seat bookable after release"
(get (ev/book! b "hr" 2 (quote x)) :status)
:booked)
(ev/hold! b "hr2" 1 (quote q))
(ev/confirm! b "hr2" (quote q))
(ev-bk-check!
"release on a confirmed seat is not-held"
(get (ev/release! b "hr2" (quote q)) :status)
:not-held)
(ev-bk-check!
"cancel frees a confirmed-from-hold seat"
(get (ev/cancel! b "hr2" (quote q)) :status)
:cancelled)))
(let
((b (persist/open)))
(do
(ev/book! b "hlast" 2 (quote x))
(let
((snap (ev-bk-snap b "hlast")) (exp (ev-bk-seq b "hlast")))
(let
((ra (ev/hold-with-observed b "hlast" 2 (quote p) snap exp))
(rb
(ev/hold-with-observed
b
"hlast"
2
(quote q)
snap
exp)))
(do
(ev-bk-check! "hold race winner held" (get ra :status) :held)
(ev-bk-check!
"hold race loser conflicts"
(get rb :status)
:conflict)
(ev-bk-check!
"no oversell via concurrent holds"
(ev-booking-count b "hlast")
2)
(ev-bk-check!
"hold loser retry gets full"
(get (ev/hold! b "hlast" 2 (quote q)) :status)
:full))))))
(let
((b (persist/open)))
(do
(ev/hold! b "hi" 4 (quote p))
(ev-bk-check!
"re-holding the same actor is idempotent"
(get (ev/hold! b "hi" 4 (quote p)) :status)
:already)
(ev-bk-check!
"hold idempotency keeps one seat"
(ev-booking-count b "hi")
1))))))
(define
ev-booking-tests-run!
(fn
()
(do
(set! ev-bk-pass 0)
(set! ev-bk-fail 0)
(set! ev-bk-failures (list))
(ev-bk-run-all!)
{:failures ev-bk-failures :total (+ ev-bk-pass ev-bk-fail) :passed ev-bk-pass :failed ev-bk-fail})))

View File

@@ -0,0 +1,433 @@
;; lib/events/tests/calendar.sx — civil date core + RRULE window expansion.
(define ev-cal-pass 0)
(define ev-cal-fail 0)
(define ev-cal-failures (list))
(define
ev-cal-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-cal-pass (+ ev-cal-pass 1))
(do
(set! ev-cal-fail (+ ev-cal-fail 1))
(append!
ev-cal-failures
(str name "\n expected: " expected "\n got: " got))))))
;; Project occurrences to (civil weekday) pairs for legible assertions.
(define
ev-cal-shape
(fn
(occs)
(map
(fn
(o)
(list (ev-dt->civil (get o :start)) (ev-dt-weekday (get o :start))))
occs)))
(define
ev-cal-starts
(fn (occs) (map (fn (o) (ev-dt->civil (get o :start))) occs)))
(define
ev-cal-run-all!
(fn
()
(do
(ev-cal-check!
"epoch day zero"
(ev-days-from-civil 1970 1 1)
0)
(ev-cal-check!
"y2k day number"
(ev-days-from-civil 2000 1 1)
10957)
(ev-cal-check!
"leap day round trip"
(ev-civil-from-days
(ev-days-from-civil 2024 2 29))
(list 2024 2 29))
(ev-cal-check!
"pre-epoch round trip"
(ev-civil-from-days
(ev-days-from-civil 1969 12 31))
(list 1969 12 31))
(ev-cal-check!
"epoch is thursday"
(ev-weekday-of-days 0)
3)
(ev-cal-check!
"2026-06-06 is saturday"
(ev-dt-weekday (ev-date 2026 6 6))
5)
(ev-cal-check!
"dt carries time of day"
(ev-dt-tod
(ev-dt 2026 6 1 9 30))
570)
(ev-cal-check!
"civil from dt"
(ev-dt->civil
(ev-dt 2026 12 25 8 0))
(list 2026 12 25))
(ev-cal-check!
"days in feb 2024 (leap)"
(ev-days-in-month 2024 2)
29)
(ev-cal-check!
"days in feb 2026"
(ev-days-in-month 2026 2)
28)
(ev-cal-check!
"add months wraps year"
(ev-add-months 2026 11 3)
(list 2027 2))
(ev-cal-check!
"add months within year"
(ev-add-months 2026 1 5)
(list 2026 6))
(let
((ev (ev-event (quote one) (ev-dt 2026 6 10 14 0) 60 nil 1)))
(do
(ev-cal-check!
"single inside window emits once"
(len
(ev-expand
ev
(ev-date 2026 6 1)
(ev-date 2026 7 1)))
1)
(ev-cal-check!
"single before window omitted"
(len
(ev-expand
ev
(ev-date 2026 7 1)
(ev-date 2026 8 1)))
0)
(ev-cal-check!
"single after window omitted"
(len
(ev-expand
ev
(ev-date 2026 1 1)
(ev-date 2026 2 1)))
0)
(ev-cal-check!
"occurrence end is start plus duration"
(get
(first
(ev-expand
ev
(ev-date 2026 6 1)
(ev-date 2026 7 1)))
:end)
(+
(ev-dt 2026 6 10 14 0)
60))))
(let
((daily (ev-event (quote d) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 5} 1)))
(do
(ev-cal-check!
"daily count caps occurrences"
(ev-cal-starts
(ev-expand
daily
(ev-date 2026 6 1)
(ev-date 2026 7 1)))
(list
(list 2026 6 1)
(list 2026 6 2)
(list 2026 6 3)
(list 2026 6 4)
(list 2026 6 5)))
(ev-cal-check!
"daily preserves time of day"
(ev-dt-tod
(get
(first
(ev-expand
daily
(ev-date 2026 6 1)
(ev-date 2026 7 1)))
:start))
540)))
(let
((di (ev-event (quote di) (ev-dt 2026 6 1 0 0) 30 {:interval 3 :freq :daily :until (ev-date 2026 6 30)} 1)))
(ev-cal-check!
"daily interval 3 steps by three days"
(ev-cal-starts
(ev-expand
di
(ev-date 2026 6 1)
(ev-date 2026 6 13)))
(list
(list 2026 6 1)
(list 2026 6 4)
(list 2026 6 7)
(list 2026 6 10)
(list 2026 6 13))))
(let
((dc (ev-event (quote dc) (ev-dt 2026 6 1 0 0) 30 {:freq :daily :count 10} 1)))
(ev-cal-check!
"count is window-independent (clip middle)"
(ev-cal-starts
(ev-expand
dc
(ev-date 2026 6 5)
(ev-date 2026 6 8)))
(list
(list 2026 6 5)
(list 2026 6 6)
(list 2026 6 7)
(list 2026 6 8))))
(let
((dc2 (ev-event (quote dc2) (ev-dt 2026 6 1 0 0) 30 {:freq :daily :count 3} 1)))
(ev-cal-check!
"count exhausted before window yields nothing"
(len
(ev-expand
dc2
(ev-date 2026 6 10)
(ev-date 2026 6 20)))
0))
(let
((wk (ev-event (quote w) (ev-dt 2026 6 1 18 0) 90 {:freq :weekly :byday (list 0 2 4)} 1)))
(ev-cal-check!
"weekly byday mon/wed/fri first two weeks"
(ev-cal-shape
(ev-expand
wk
(ev-date 2026 6 1)
(ev-date 2026 6 13)))
(list
(list (list 2026 6 1) 0)
(list (list 2026 6 3) 2)
(list (list 2026 6 5) 4)
(list (list 2026 6 8) 0)
(list (list 2026 6 10) 2)
(list (list 2026 6 12) 4))))
(let
((wu (ev-event (quote wu) (ev-dt 2026 6 1 18 0) 90 {:freq :weekly :until (ev-dt 2026 6 10 23 0) :byday (list 0 2)} 1)))
(ev-cal-check!
"weekly until clips trailing occurrences"
(ev-cal-starts
(ev-expand
wu
(ev-date 2026 6 1)
(ev-date 2026 7 1)))
(list
(list 2026 6 1)
(list 2026 6 3)
(list 2026 6 8)
(list 2026 6 10))))
(let
((wi (ev-event (quote wi) (ev-dt 2026 6 1 18 0) 90 {:interval 2 :freq :weekly :byday (list 0)} 1)))
(ev-cal-check!
"weekly interval 2 skips alternate weeks"
(ev-cal-starts
(ev-expand
wi
(ev-date 2026 6 1)
(ev-date 2026 7 6)))
(list
(list 2026 6 1)
(list 2026 6 15)
(list 2026 6 29))))
(let
((wd (ev-event (quote wd) (ev-dt 2026 6 3 12 0) 60 {:freq :weekly :count 3} 1)))
(ev-cal-check!
"weekly default byday is dtstart weekday"
(ev-cal-shape
(ev-expand
wd
(ev-date 2026 6 1)
(ev-date 2026 8 1)))
(list
(list (list 2026 6 3) 2)
(list (list 2026 6 10) 2)
(list (list 2026 6 17) 2))))
(let
((wc (ev-event (quote wc) (ev-dt 2026 6 1 18 0) 90 {:freq :weekly :count 10 :byday (list 0 2)} 1)))
(ev-cal-check!
"weekly count window-independent (clip middle)"
(ev-cal-starts
(ev-expand
wc
(ev-date 2026 6 15)
(ev-date 2026 7 5)))
(list
(list 2026 6 15)
(list 2026 6 17)
(list 2026 6 22)
(list 2026 6 24)
(list 2026 6 29)
(list 2026 7 1))))
(let
((wf (ev-event (quote wf) (ev-dt 2026 6 3 18 0) 90 {:freq :weekly :count 4 :byday (list 0 2 4)} 1)))
(ev-cal-check!
"first week skips byday earlier than dtstart"
(ev-cal-starts
(ev-expand
wf
(ev-date 2026 6 1)
(ev-date 2026 7 1)))
(list
(list 2026 6 3)
(list 2026 6 5)
(list 2026 6 8)
(list 2026 6 10))))
(let
((md (ev-event (quote md) (ev-dt 2026 1 15 9 0) 60 {:bymonthday (list 15) :freq :monthly} 1)))
(do
(ev-cal-check!
"monthly bymonthday 15th"
(ev-cal-starts
(ev-expand
md
(ev-date 2026 1 1)
(ev-date 2026 4 1)))
(list
(list 2026 1 15)
(list 2026 2 15)
(list 2026 3 15)))
(ev-cal-check!
"monthly preserves time of day"
(ev-dt-tod
(get
(first
(ev-expand
md
(ev-date 2026 1 1)
(ev-date 2026 4 1)))
:start))
540)))
(let
((mm (ev-event (quote mm) (ev-dt 2026 1 1 9 0) 60 {:bymonthday (list 1 15) :freq :monthly :count 4} 1)))
(ev-cal-check!
"monthly multiple bymonthday sorted within month"
(ev-cal-starts
(ev-expand
mm
(ev-date 2026 1 1)
(ev-date 2026 12 1)))
(list
(list 2026 1 1)
(list 2026 1 15)
(list 2026 2 1)
(list 2026 2 15))))
(let
((ml (ev-event (quote ml) (ev-dt 2026 1 31 9 0) 60 {:bymonthday (list -1) :freq :monthly} 1)))
(ev-cal-check!
"monthly bymonthday -1 is last day"
(ev-cal-starts
(ev-expand
ml
(ev-date 2026 1 1)
(ev-date 2026 4 1)))
(list
(list 2026 1 31)
(list 2026 2 28)
(list 2026 3 31))))
(let
((mn (ev-event (quote mn) (ev-dt 2026 1 1 9 0) 60 {:freq :monthly :byday (list {:ord 2 :wd 1})} 1)))
(ev-cal-check!
"monthly 2nd tuesday"
(ev-cal-shape
(ev-expand
mn
(ev-date 2026 1 1)
(ev-date 2026 4 1)))
(list
(list (list 2026 1 13) 1)
(list (list 2026 2 10) 1)
(list (list 2026 3 10) 1))))
(let
((mz (ev-event (quote mz) (ev-dt 2026 1 1 9 0) 60 {:freq :monthly :byday (list {:ord -1 :wd 4})} 1)))
(ev-cal-check!
"monthly last friday"
(ev-cal-shape
(ev-expand
mz
(ev-date 2026 1 1)
(ev-date 2026 4 1)))
(list
(list (list 2026 1 30) 4)
(list (list 2026 2 27) 4)
(list (list 2026 3 27) 4))))
(let
((m31 (ev-event (quote m31) (ev-dt 2026 1 31 9 0) 60 {:freq :monthly :count 4} 1)))
(ev-cal-check!
"monthly default day-of-month skips short months"
(ev-cal-starts
(ev-expand
m31
(ev-date 2026 1 1)
(ev-date 2026 12 1)))
(list
(list 2026 1 31)
(list 2026 3 31)
(list 2026 5 31)
(list 2026 7 31))))
(let
((mi (ev-event (quote mi) (ev-dt 2026 1 10 9 0) 60 {:interval 3 :freq :monthly :count 3} 1)))
(ev-cal-check!
"monthly interval 3 steps by quarter"
(ev-cal-starts
(ev-expand
mi
(ev-date 2026 1 1)
(ev-date 2027 1 1)))
(list
(list 2026 1 10)
(list 2026 4 10)
(list 2026 7 10))))
(let
((mc (ev-event (quote mc) (ev-dt 2026 1 5 9 0) 60 {:freq :monthly :count 12} 1)))
(ev-cal-check!
"monthly count window-independent (clip middle)"
(ev-cal-starts
(ev-expand
mc
(ev-date 2026 4 1)
(ev-date 2026 6 30)))
(list
(list 2026 4 5)
(list 2026 5 5)
(list 2026 6 5))))
(let
((a (ev-event (quote a) (ev-dt 2026 6 2 10 0) 30 {:freq :daily :count 2} 1))
(b
(ev-event
(quote b)
(ev-dt 2026 6 1 9 0)
30
{:freq :daily :count 2}
1)))
(ev-cal-check!
"expand-all sorts merged occurrences by start"
(map
(fn (o) (list (get o :id) (ev-dt->civil (get o :start))))
(ev-expand-all
(list a b)
(ev-date 2026 6 1)
(ev-date 2026 7 1)))
(list
(list (quote b) (list 2026 6 1))
(list (quote b) (list 2026 6 2))
(list (quote a) (list 2026 6 2))
(list (quote a) (list 2026 6 3))))))))
(define
ev-calendar-tests-run!
(fn
()
(do
(set! ev-cal-pass 0)
(set! ev-cal-fail 0)
(set! ev-cal-failures (list))
(ev-cal-run-all!)
{:failures ev-cal-failures :total (+ ev-cal-pass ev-cal-fail) :passed ev-cal-pass :failed ev-cal-fail})))

View File

@@ -0,0 +1,231 @@
;; lib/events/tests/federation.sx — trust-gated cross-instance agenda merge.
(define ev-fd-pass 0)
(define ev-fd-fail 0)
(define ev-fd-failures (list))
(define
ev-fd-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-fd-pass (+ ev-fd-pass 1))
(do
(set! ev-fd-fail (+ ev-fd-fail 1))
(append!
ev-fd-failures
(str name "\n expected: " expected "\n got: " got))))))
;; Local schedule + two peers. Distinct start times make ordering legible.
(define
ev-fd-local
(fn
()
(ev/schedule
(ev/empty)
(quote yoga)
(ev-dt 2026 6 1 9 0)
60
nil
20)))
(define
ev-fd-berlin
(fn
()
(ev/peer
(quote berlin)
(ev/schedule
(ev/empty)
(quote meetup)
(ev-dt 2026 6 1 12 0)
90
nil
100))))
(define
ev-fd-paris
(fn
()
(ev/peer
(quote paris)
(ev/schedule
(ev/empty)
(quote salon)
(ev-dt 2026 6 1 15 0)
60
nil
30))))
(define
ev-fd-run-all!
(fn
()
(let
((local (ev-fd-local))
(peers (list (ev-fd-berlin) (ev-fd-paris)))
(ws (ev-date 2026 6 1))
(we (ev-date 2026 6 2)))
(do
(ev-fd-check!
"trusts a peer in the trust set"
(ev/trusts? (list (quote berlin)) (quote berlin))
true)
(ev-fd-check!
"does not trust a peer outside the set"
(ev/trusts? (list (quote berlin)) (quote paris))
false)
(ev-fd-check!
"trusted-peers filters to the trust set"
(map ev/peer-id (ev/trusted-peers peers (list (quote berlin))))
(list (quote berlin)))
(let
((fed (ev/federated-agenda local peers (list (quote berlin)) ws we)))
(do
(ev-fd-check!
"merge includes local + trusted peer only"
(map (fn (o) (list (get o :origin) (get o :id))) fed)
(list
(list :local (quote yoga))
(list (quote berlin) (quote meetup))))
(ev-fd-check!
"merge is sorted by start"
(map (fn (o) (get o :start)) fed)
(list
(ev-dt 2026 6 1 9 0)
(ev-dt 2026 6 1 12 0)))
(ev-fd-check!
"untrusted peer (paris) contributes nothing"
(len (ev/from-origin fed (quote paris)))
0)
(ev-fd-check!
"local occurrences tagged :local"
(map (fn (o) (get o :id)) (ev/from-origin fed :local))
(list (quote yoga)))
(ev-fd-check!
"peer occurrences tagged with the peer id"
(map
(fn (o) (get o :id))
(ev/from-origin fed (quote berlin)))
(list (quote meetup)))))
(let
((fed2 (ev/federated-agenda local peers (list (quote berlin) (quote paris)) ws we)))
(ev-fd-check!
"trusting both peers merges all three, sorted"
(map (fn (o) (list (get o :origin) (get o :id))) fed2)
(list
(list :local (quote yoga))
(list (quote berlin) (quote meetup))
(list (quote paris) (quote salon)))))
(let
((fed3 (ev/federated-agenda local peers (list) ws we)))
(do
(ev-fd-check!
"empty trust yields only local occurrences"
(map (fn (o) (get o :origin)) fed3)
(list :local))
(ev-fd-check!
"empty trust still includes local"
(len fed3)
1)))
(let
((rpeer (ev/peer (quote tokyo) (ev/schedule (ev/empty) (quote standup) (ev-dt 2026 6 1 8 0) 15 {:freq :daily :count 3} 5))))
(let
((pa (ev/peer-agenda rpeer ws (ev-date 2026 6 4))))
(do
(ev-fd-check!
"peer recurrence expands in the window"
(len pa)
3)
(ev-fd-check!
"every peer occurrence is tagged with the peer id"
(map (fn (o) (get o :origin)) pa)
(list (quote tokyo) (quote tokyo) (quote tokyo))))))))))
;; ---- federated free/busy ----
(define
ev-fd-fb-run-all!
(fn
()
(let
((local-db
(ev-avail-db
(list (ev-occ (quote yoga) (ev-dt 2026 6 1 9 0) 60))
(list (list (quote nia) (str (quote yoga) "@" (ev-dt 2026 6 1 9 0))))))
(berlin
(ev/peer-with-busy
(quote berlin)
(ev/empty)
(list
(list (quote nia)
(list (list (ev-dt 2026 6 1 14 0) (ev-dt 2026 6 1 15 0)))))))
(paris
(ev/peer-with-busy
(quote paris)
(ev/empty)
(list
(list (quote nia)
(list (list (ev-dt 2026 6 1 11 0) (ev-dt 2026 6 1 12 0))))))))
(let
((peers (list berlin paris)))
(do
;; peer-busy reads a peer's published intervals
(ev-fd-check!
"peer-busy returns published intervals for an actor"
(ev/peer-busy berlin (quote nia))
(list (list (ev-dt 2026 6 1 14 0) (ev-dt 2026 6 1 15 0))))
(ev-fd-check!
"peer-busy empty for an actor with nothing published"
(ev/peer-busy berlin (quote zed))
(list))
;; federated-busy unions local + trusted-peer busy, sorted
(ev-fd-check!
"federated-busy unions local + trusted peer, sorted"
(ev/federated-busy local-db (list berlin) (list (quote berlin)) (quote nia))
(list
(list (ev-dt 2026 6 1 9 0) (ev-dt 2026 6 1 10 0))
(list (ev-dt 2026 6 1 14 0) (ev-dt 2026 6 1 15 0))))
(ev-fd-check!
"untrusted peer busy is excluded from federated-busy"
(ev/federated-busy local-db peers (list (quote berlin)) (quote nia))
(list
(list (ev-dt 2026 6 1 9 0) (ev-dt 2026 6 1 10 0))
(list (ev-dt 2026 6 1 14 0) (ev-dt 2026 6 1 15 0))))
;; federated-free? considers both local and trusted-peer commitments
(ev-fd-check!
"free locally and on peers in an open window"
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 16 0) (ev-dt 2026 6 1 17 0))
true)
(ev-fd-check!
"not free during a LOCAL booking"
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 9 30) (ev-dt 2026 6 1 9 45))
false)
(ev-fd-check!
"not free during a TRUSTED PEER busy interval"
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 14 30) (ev-dt 2026 6 1 14 45))
false)
(ev-fd-check!
"free during an UNTRUSTED peer's busy interval (paris not trusted)"
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 11 15) (ev-dt 2026 6 1 11 45))
true)
(ev-fd-check!
"not free once paris is trusted too"
(ev/federated-free? local-db peers (list (quote berlin) (quote paris)) (quote nia) (ev-dt 2026 6 1 11 15) (ev-dt 2026 6 1 11 45))
false)
(ev-fd-check!
"federated-free? half-open at a busy edge"
(ev/federated-free? local-db peers (list (quote berlin)) (quote nia) (ev-dt 2026 6 1 15 0) (ev-dt 2026 6 1 16 0))
true))))))
(define
ev-federation-tests-run!
(fn
()
(do
(set! ev-fd-pass 0)
(set! ev-fd-fail 0)
(set! ev-fd-failures (list))
(ev-fd-run-all!)
(ev-fd-fb-run-all!)
{:failures ev-fd-failures :total (+ ev-fd-pass ev-fd-fail) :passed ev-fd-pass :failed ev-fd-fail})))

View File

@@ -0,0 +1,77 @@
;; lib/events/tests/notify.sx — durable notification delivery flows.
(define ev-nt-pass 0)
(define ev-nt-fail 0)
(define ev-nt-failures (list))
(define
ev-nt-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-nt-pass (+ ev-nt-pass 1))
(do
(set! ev-nt-fail (+ ev-nt-fail 1))
(append!
ev-nt-failures
(str name "\n expected: " expected "\n got: " got))))))
;; Each case runs a Scheme flow program (notify flows preloaded) and asserts on
;; the SX-native result. Scheme symbols come back as strings.
(define
ev-nt-run-all!
(fn
()
(do
(ev-nt-check!
"reminder delivers on the first attempt"
(ev/notify-run
"(define s (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote alice) (quote hello))))\n (flow-run-host (lambda (k p) (list (quote ok) (quote sent))) 5)\n (list (flow/status (car (cdr s))) (flow/result (car (cdr s))))")
(list "done" (list "delivered" "m1" 1)))
(ev-nt-check!
"reminder retries a transient failure then delivers"
(ev/notify-run
"(define hits 0)\n (define s (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote bob) (quote hi))))\n (flow-run-host (lambda (k p) (begin (set! hits (+ hits 1)) (if (< hits 2) (list (quote retry) (quote down)) (list (quote ok) (quote sent))))) 10)\n (list (flow/result (car (cdr s))) hits)")
(list (list "delivered" "m1" 2) 2))
(ev-nt-check!
"reminder gives up after maxn attempts"
(ev/notify-run
"(define s (flow/start (ev-deliver-reminder 2) (list (quote m1) (quote x) (quote y))))\n (flow-run-host (lambda (k p) (list (quote retry) (quote down))) 10)\n (flow/result (car (cdr s)))")
(list "failed" "m1" "down"))
(ev-nt-check!
"redelivery of the same id sends only once (at-least-once, idempotent)"
(ev/notify-run
"(define sent (list)) (define deliveries 0)\n (define (xport k p)\n (let ((id (ev-msg-id p)))\n (if (ev-mem id sent)\n (list (quote ok) (quote duplicate))\n (begin (set! sent (cons id sent)) (set! deliveries (+ deliveries 1)) (list (quote ok) (quote sent))))))\n (define s1 (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote a) (quote hi))))\n (flow-run-host xport 5)\n (define s2 (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote a) (quote hi))))\n (flow-run-host xport 5)\n (list deliveries (flow/result (car (cdr s2))))")
(list 1 (list "delivered" "m1" 1)))
(ev-nt-check!
"digest delivers every message in the batch"
(ev/notify-run
"(define s (flow/start (ev-deliver-digest 3) (list (list (quote a) (quote u1) (quote hi)) (list (quote b) (quote u2) (quote yo)))))\n (flow-run-host (lambda (k p) (list (quote ok) (quote sent))) 10)\n (flow/result (car (cdr s)))")
(list
(list "delivered" "a" 1)
(list "delivered" "b" 1)))
(ev-nt-check!
"digest reports per-message outcomes independently"
(ev/notify-run
"(define (xport k p)\n (let ((id (ev-msg-id p)))\n (if (equal? id (quote b)) (list (quote retry) (quote flaky)) (list (quote ok) (quote sent)))))\n (define s (flow/start (ev-deliver-digest 2) (list (list (quote a) (quote u1) (quote hi)) (list (quote b) (quote u2) (quote yo)) (list (quote c) (quote u3) (quote ya)))))\n (flow-run-host xport 12)\n (flow/result (car (cdr s)))")
(list
(list "delivered" "a" 1)
(list "failed" "b" "flaky")
(list "delivered" "c" 1)))
(ev-nt-check!
"delivery suspends until the transport responds"
(ev/notify-run
"(define s (flow/start (ev-deliver-reminder 3) (list (quote m1) (quote a) (quote hi))))\n (flow/status (car (cdr s)))")
"suspended"))))
(define
ev-notify-tests-run!
(fn
()
(do
(set! ev-nt-pass 0)
(set! ev-nt-fail 0)
(set! ev-nt-failures (list))
(ev-nt-run-all!)
{:failures ev-nt-failures :total (+ ev-nt-pass ev-nt-fail) :passed ev-nt-pass :failed ev-nt-fail})))

View File

@@ -0,0 +1,220 @@
;; lib/events/tests/reminders.sx — reminder + digest derivation from the agenda.
(define ev-rm-pass 0)
(define ev-rm-fail 0)
(define ev-rm-failures (list))
(define
ev-rm-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-rm-pass (+ ev-rm-pass 1))
(do
(set! ev-rm-fail (+ ev-rm-fail 1))
(append!
ev-rm-failures
(str name "\n expected: " expected "\n got: " got))))))
;; A store with a weekly class (Mon+Wed 18:00, 60m, 4 occurrences) and a one-off
;; talk; durable bookings on a persist backend.
(define
ev-rm-store
(fn
()
(ev/schedule
(ev/schedule
(ev/empty)
(quote yoga)
(ev-dt 2026 6 1 18 0)
60
{:freq :weekly :count 4 :byday (list 0 2)}
20)
(quote talk)
(ev-dt 2026 6 2 12 0)
30
nil
50)))
(define
ev-rm-run-all!
(fn
()
(let
((store (ev-rm-store)) (b (persist/open)))
(let
((occs (ev/agenda store (ev-date 2026 6 1) (ev-date 2026 7 1))))
(do
(ev/book-occ! b store (quote nia) (first occs))
(ev/book-occ! b store (quote ola) (first occs))
(ev/book-occ!
b
store
(quote ola)
(ev-occ
(quote talk)
(ev-dt 2026 6 2 12 0)
30))
(do
(let
((rs (ev/occurrence-reminders b (first occs) 60)))
(do
(ev-rm-check!
"one reminder per booked attendee"
(len rs)
2)
(ev-rm-check!
"reminder fires lead minutes before start"
(get (first rs) :fire-at)
(-
(ev-dt
2026
6
1
18
0)
60))
(ev-rm-check!
"reminder idempotency key encodes occ/recipient/lead"
(get (first rs) :id)
(str
(ev-occ-key (first occs))
"/"
(quote nia)
"/"
60))
(ev-rm-check!
"reminder names the event"
(get (first rs) :event)
(quote yoga))))
(ev-rm-check!
"unbooked occurrence has no reminders"
(len
(ev/occurrence-reminders b (ev-occ (quote yoga) (ev-dt 2026 6 3 18 0) 60) 60))
0)
(let
((all (ev/agenda-reminders b store (ev-date 2026 6 1) (ev-date 2026 7 1) 60)))
(do
(ev-rm-check!
"agenda reminders cover all bookings"
(len all)
3)
(ev-rm-check!
"agenda reminders sorted by fire-at"
(map (fn (r) (get r :fire-at)) all)
(list
(-
(ev-dt
2026
6
1
18
0)
60)
(-
(ev-dt
2026
6
1
18
0)
60)
(-
(ev-dt
2026
6
2
12
0)
60)))))
(let
((all (ev/agenda-reminders b store (ev-date 2026 6 1) (ev-date 2026 7 1) 60)))
(do
(ev-rm-check!
"nothing due before the first fire-at"
(len
(ev/due-reminders
all
(-
(ev-dt
2026
6
1
17
0)
1)))
0)
(ev-rm-check!
"the two yoga reminders are due at 17:00"
(len
(ev/due-reminders
all
(ev-dt
2026
6
1
17
0)))
2)
(ev-rm-check!
"all reminders due once past the last fire-at"
(len
(ev/due-reminders
all
(ev-dt
2026
6
2
12
0)))
3)))
(let
((r (first (ev/occurrence-reminders b (first occs) 60))))
(ev-rm-check!
"reminder projects to (id recipient body)"
(ev/reminder->msg r)
(list
(str
(ev-occ-key (first occs))
"/"
(quote nia)
"/"
60)
(quote nia)
(list
:reminder (quote yoga)
(ev-dt
2026
6
1
18
0)))))
(let
((dig (ev/agenda-digest b store (quote ola) (ev-date 2026 6 1) (ev-date 2026 7 1))))
(do
(ev-rm-check!
"digest is addressed to the actor"
(get dig :recipient)
(quote ola))
(ev-rm-check!
"digest lists the actor's booked occurrences"
(map (fn (it) (get it :event)) (get dig :items))
(list (quote yoga) (quote talk)))))
(let
((empty-dig (ev/agenda-digest b store (quote nobody) (ev-date 2026 6 1) (ev-date 2026 7 1))))
(ev-rm-check!
"digest empty for an actor with no bookings"
(get empty-dig :items)
(list)))))))))
(define
ev-reminders-tests-run!
(fn
()
(do
(set! ev-rm-pass 0)
(set! ev-rm-fail 0)
(set! ev-rm-failures (list))
(ev-rm-run-all!)
{:failures ev-rm-failures :total (+ ev-rm-pass ev-rm-fail) :passed ev-rm-pass :failed ev-rm-fail})))

252
lib/events/tests/ticket.sx Normal file
View File

@@ -0,0 +1,252 @@
;; lib/events/tests/ticket.sx — paid-ticket contract + settlement orchestration.
(define ev-tk-pass 0)
(define ev-tk-fail 0)
(define ev-tk-failures (list))
(define
ev-tk-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-tk-pass (+ ev-tk-pass 1))
(do
(set! ev-tk-fail (+ ev-tk-fail 1))
(append!
ev-tk-failures
(str name "\n expected: " expected "\n got: " got))))))
(define
ev-tk-run-all!
(fn
()
(do
(let
((req (ev/checkout-request "occ1" (quote nia) 1500 "GBP" "ref-1")))
(do
(ev-tk-check!
"checkout-request is tagged"
(ev/checkout-request? req)
true)
(ev-tk-check!
"payment-result is not a checkout-request"
(ev/checkout-request? (ev/payment-paid "o" (quote a) "r"))
false)
(ev-tk-check!
"request occ-key accessor"
(ev/req-occ-key req)
"occ1")
(ev-tk-check!
"request actor accessor"
(ev/req-actor req)
(quote nia))
(ev-tk-check!
"request amount accessor"
(ev/req-amount req)
1500)
(ev-tk-check!
"request currency accessor"
(ev/req-currency req)
"GBP")
(ev-tk-check! "request ref accessor" (ev/req-ref req) "ref-1")))
(let
((res (ev/payment-paid "occ1" (quote nia) "ref-1")))
(do
(ev-tk-check!
"payment-result is tagged"
(ev/payment-result? res)
true)
(ev-tk-check! "result status accessor" (ev/result-status res) :paid)
(ev-tk-check!
"failed constructor carries status"
(ev/result-status (ev/payment-failed "o" (quote a) "r"))
:failed)
(ev-tk-check!
"expired constructor carries status"
(ev/result-status (ev/payment-expired "o" (quote a) "r"))
:expired)))
(let
((b (persist/open)))
(do
(let
((r (ev/request-ticket! b "show" 1 (quote a) 2000 "GBP" "ref-a")))
(do
(ev-tk-check!
"request-ticket awaiting-payment"
(get r :status)
:awaiting-payment)
(ev-tk-check!
"request-ticket returns a checkout-request"
(ev/checkout-request? (get r :request))
true)
(ev-tk-check!
"checkout-request carries the amount"
(ev/req-amount (get r :request))
2000)))
(ev-tk-check!
"held seat reserves capacity"
(ev/seats-left b "show" 1)
0)
(ev-tk-check!
"second buyer is full while payment pends"
(get
(ev/request-ticket!
b
"show"
1
(quote c)
2000
"GBP"
"ref-c")
:status)
:full)
(ev-tk-check!
"held seat state pending"
(ev/seat-state b "show" (quote a))
:held)))
(let
((b (persist/open)))
(do
(ev/request-ticket!
b
"gig"
2
(quote a)
2000
"GBP"
"ref-a")
(let
((s (ev/settle-payment! b (ev/payment-paid "gig" (quote a) "ref-a"))))
(ev-tk-check! "settle paid confirms" (get s :status) :confirmed))
(ev-tk-check!
"confirmed seat state"
(ev/seat-state b "gig" (quote a))
:confirmed)
(ev-tk-check!
"redelivered paid is still confirmed (idempotent)"
(get
(ev/settle-payment!
b
(ev/payment-paid "gig" (quote a) "ref-a"))
:status)
:confirmed)
(ev-tk-check!
"still exactly one seat taken"
(ev-booking-count b "gig")
1)))
(let
((b (persist/open)))
(do
(ev/request-ticket!
b
"fail"
1
(quote a)
2000
"GBP"
"ref-a")
(ev-tk-check!
"seat held before failure"
(ev/seats-left b "fail" 1)
0)
(let
((s (ev/settle-payment! b (ev/payment-failed "fail" (quote a) "ref-a"))))
(ev-tk-check! "settle failed releases" (get s :status) :released))
(ev-tk-check!
"released seat frees capacity"
(ev/seats-left b "fail" 1)
1)
(ev-tk-check!
"redelivered failure is a noop"
(get
(ev/settle-payment!
b
(ev/payment-failed "fail" (quote a) "ref-a"))
:status)
:noop)
(ev-tk-check!
"freed seat available to next buyer"
(get
(ev/request-ticket!
b
"fail"
1
(quote c)
2000
"GBP"
"ref-c")
:status)
:awaiting-payment)
(ev/request-ticket!
b
"exp"
1
(quote a)
2000
"GBP"
"ref-a")
(ev-tk-check!
"settle expired releases"
(get
(ev/settle-payment!
b
(ev/payment-expired "exp" (quote a) "ref-a"))
:status)
:released)))
(let
((b (persist/open)))
(do
(ev/request-ticket!
b
"race"
1
(quote a)
2000
"GBP"
"ref-a")
(ev/settle-payment!
b
(ev/payment-expired "race" (quote a) "ref-a"))
(ev-tk-check!
"late paid for a vanished hold needs a refund"
(get
(ev/settle-payment!
b
(ev/payment-paid "race" (quote a) "ref-a"))
:status)
:paid-but-no-hold)
(ev-tk-check!
"no phantom seat created"
(ev-booking-count b "race")
0)))
(let
((b (persist/open)))
(do
(let
((start (ev/request-ticket! b "e2e" 3 (quote nia) 2500 "GBP" "ref-nia")))
(ev/settle-payment!
b
(ev/payment-paid
(ev/req-occ-key (get start :request))
(ev/req-actor (get start :request))
(ev/req-ref (get start :request)))))
(ev-tk-check!
"e2e roster holds the buyer"
(ev/roster b "e2e")
(list (quote nia)))
(ev-tk-check!
"e2e seat confirmed"
(ev/seat-state b "e2e" (quote nia))
:confirmed))))))
(define
ev-ticket-tests-run!
(fn
()
(do
(set! ev-tk-pass 0)
(set! ev-tk-fail 0)
(set! ev-tk-failures (list))
(ev-tk-run-all!)
{:failures ev-tk-failures :total (+ ev-tk-pass ev-tk-fail) :passed ev-tk-pass :failed ev-tk-fail})))

101
lib/events/ticket.sx Normal file
View File

@@ -0,0 +1,101 @@
;; lib/events/ticket.sx — paid-ticket contract between events and commerce.
;;
;; A paid booking spans two subsystems. events does NOT import commerce; instead
;; this module defines the CONTRACT — the two messages on the wire — and the
;; events-side orchestration over provisional holds (booking.sx). commerce
;; imports these shapes; the dependency only points one way.
;;
;; checkout-request events -> commerce "take payment for this seat"
;; {:kind :events.checkout :occ-key :actor :amount :currency :ref}
;;
;; payment-result commerce -> events "here's how payment went"
;; {:kind :events.payment :occ-key :actor :ref :status}
;; :status ∈ :paid | :failed | :expired
;;
;; Flow: ev/request-ticket! places a capacity-safe HOLD (reserving the seat so
;; it can't be oversold while payment pends) and returns a checkout-request to
;; hand to commerce. When commerce reports back, ev/settle-payment! confirms the
;; hold on :paid or releases it otherwise. Settlement is idempotent — an
;; at-least-once redelivery of the same result is safe. `ref` is the opaque
;; correlation/idempotency id; occ-key + actor locate the hold, so settlement
;; needs no side table.
;; ---- contract: checkout request (events -> commerce) ----
(define
ev/checkout-request
(fn (occ-key actor amount currency ref) {:actor actor :amount amount :kind :events.checkout :ref ref :currency currency :occ-key occ-key}))
(define
ev/checkout-request?
(fn (m) (and (dict? m) (= (get m :kind) :events.checkout))))
(define ev/req-occ-key (fn (r) (get r :occ-key)))
(define ev/req-actor (fn (r) (get r :actor)))
(define ev/req-amount (fn (r) (get r :amount)))
(define ev/req-currency (fn (r) (get r :currency)))
(define ev/req-ref (fn (r) (get r :ref)))
;; ---- contract: payment result (commerce -> events) ----
(define ev/payment-result (fn (occ-key actor ref status) {:actor actor :kind :events.payment :status status :ref ref :occ-key occ-key}))
(define
ev/payment-result?
(fn (m) (and (dict? m) (= (get m :kind) :events.payment))))
(define ev/result-occ-key (fn (r) (get r :occ-key)))
(define ev/result-actor (fn (r) (get r :actor)))
(define ev/result-ref (fn (r) (get r :ref)))
(define ev/result-status (fn (r) (get r :status)))
(define
ev/payment-paid
(fn (occ-key actor ref) (ev/payment-result occ-key actor ref :paid)))
(define
ev/payment-failed
(fn (occ-key actor ref) (ev/payment-result occ-key actor ref :failed)))
(define
ev/payment-expired
(fn (occ-key actor ref) (ev/payment-result occ-key actor ref :expired)))
;; ---- orchestration ----
;; Begin a paid booking: place a capacity-safe hold and, if reserved, return a
;; checkout-request for commerce. :full when no seat; :already when the actor
;; already holds/booked this occurrence (no duplicate request).
(define
ev/request-ticket!
(fn
(b occ-key capacity actor amount currency ref)
(let
((h (ev/hold! b occ-key capacity actor)))
(cond
((= (get h :status) :held) {:seat (get h :seat) :request (ev/checkout-request occ-key actor amount currency ref) :status :awaiting-payment})
((= (get h :status) :already) {:seat (get h :seat) :status :already})
(else {:capacity capacity :status :full})))))
;; Settle a payment result from commerce. :paid confirms the hold; :failed /
;; :expired release it. Idempotent: a redelivered :paid stays :confirmed, a
;; redelivered release is a :noop. If a :paid arrives for a hold that is already
;; gone (released/expired first), returns :paid-but-no-hold so the caller can
;; trigger a refund.
(define
ev/settle-payment!
(fn
(b result)
(let
((occ-key (ev/result-occ-key result))
(actor (ev/result-actor result))
(ref (ev/result-ref result)))
(if
(= (ev/result-status result) :paid)
(let
((c (ev/confirm! b occ-key actor)))
(cond
((= (get c :status) :confirmed) {:actor actor :status :confirmed :ref ref})
((= (get c :status) :already-confirmed) {:actor actor :status :confirmed :ref ref})
(else {:actor actor :status :paid-but-no-hold :ref ref})))
(let
((r (ev/release! b occ-key actor)))
(if (= (get r :status) :released) {:actor actor :status :released :ref ref} {:actor actor :status :noop :ref ref}))))))

File diff suppressed because one or more lines are too long

View File

@@ -1,27 +0,0 @@
;; 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)))

View File

@@ -1,29 +0,0 @@
;; identity/cache.sx — a delegated grant-verification cache, mirroring the
;; Redis-cache pattern apps use in front of grant verification.
;;
;; The cache is a process wrapping a token registry. introspect() is
;; memoised; issue/issue_grant/refresh/revoke pass through. The danger
;; with any cache is staleness: a revoked token must NOT keep reading
;; valid out of the cache, not even for a millisecond (the loop's hard
;; rule). We get that for free with GENERATION invalidation:
;;
;; - each cache entry records the generation it was written at;
;; - a hit requires entry.generation == current generation;
;; - any state-changing op that can invalidate an existing token
;; (revoke — which cascades to a grant; refresh — whose reuse cascades)
;; bumps the generation.
;;
;; So a single revoke instantly invalidates every cached positive: the
;; next introspect is a miss and re-validates against the live registry,
;; which returns {inactive}. Revocation stays real; the cache only ever
;; accelerates the steady state, never overrides a revocation.
;;
;; stats() -> {Hits, Misses} so callers can see the cache is live.
(define
identity-cache-source
"-module(identity_grant_cache).\n\n start() ->\n spawn(fun () ->\n Reg = identity_tokens:start(),\n loop(Reg, 1, [], 0, 0)\n end).\n\n issue(C, Subject, Client, Scope) ->\n C ! {issue, Subject, Client, Scope, self()},\n receive {cache_reply, R} -> R end.\n\n issue_grant(C, Subject, Client, Scope) ->\n C ! {issue_grant, Subject, Client, Scope, self()},\n receive {cache_reply, R} -> R end.\n\n refresh(C, RefreshTok) ->\n C ! {refresh, RefreshTok, self()},\n receive {cache_reply, R} -> R end.\n\n introspect(C, Token) ->\n C ! {introspect, Token, self()},\n receive {cache_reply, R} -> R end.\n\n revoke(C, Token) ->\n C ! {revoke, Token, self()},\n receive {cache_reply, R} -> R end.\n\n stats(C) ->\n C ! {stats, self()},\n receive {cache_reply, R} -> R end.\n\n loop(Reg, Gen, Entries, Hits, Misses) ->\n receive\n {introspect, Tok, From} ->\n case lookup_fresh(Tok, Gen, Entries) of\n {hit, Result} ->\n From ! {cache_reply, Result},\n loop(Reg, Gen, Entries, Hits + 1, Misses);\n miss ->\n Result = identity_tokens:introspect(Reg, Tok),\n From ! {cache_reply, Result},\n loop(Reg, Gen, put_entry(Tok, Result, Gen, Entries), Hits, Misses + 1)\n end;\n {issue, Subject, Client, Scope, From} ->\n From ! {cache_reply, identity_tokens:issue(Reg, Subject, Client, Scope)},\n loop(Reg, Gen, Entries, Hits, Misses);\n {issue_grant, Subject, Client, Scope, From} ->\n From ! {cache_reply, identity_tokens:issue_grant(Reg, Subject, Client, Scope)},\n loop(Reg, Gen, Entries, Hits, Misses);\n {refresh, RTok, From} ->\n From ! {cache_reply, identity_tokens:refresh(Reg, RTok)},\n loop(Reg, Gen + 1, Entries, Hits, Misses);\n {revoke, Tok, From} ->\n identity_tokens:revoke(Reg, Tok),\n From ! {cache_reply, ok},\n loop(Reg, Gen + 1, Entries, Hits, Misses);\n {stats, From} ->\n From ! {cache_reply, {Hits, Misses}},\n loop(Reg, Gen, Entries, Hits, Misses)\n end.\n\n lookup_fresh(_, _, []) -> miss;\n lookup_fresh(Tok, Gen, [{T, {Result, G}} | Rest]) ->\n case T =:= Tok of\n true ->\n case G =:= Gen of\n true -> {hit, Result};\n false -> miss\n end;\n false -> lookup_fresh(Tok, Gen, Rest)\n end.\n\n put_entry(Tok, Result, Gen, Entries) ->\n [{Tok, {Result, Gen}} | remove(Tok, Entries)].\n\n remove(_, []) -> [];\n remove(Tok, [{T, V} | Rest]) ->\n case T =:= Tok of\n true -> remove(Tok, Rest);\n false -> [{T, V} | remove(Tok, Rest)]\n end.")
(define
identity-load-cache!
(fn () (erlang-load-module identity-cache-source)))

View File

@@ -1,28 +0,0 @@
;; identity/clients.sx — the OAuth client registry (RFC 6749 §2).
;;
;; A client is registered with a type, a secret, and its allow-listed
;; redirect_uris:
;;
;; public — cannot keep a secret (SPAs, native apps, §2.1);
;; identified but not authenticated.
;; confidential — can authenticate; MUST present its secret at the token
;; endpoint (§3.2.1, §4.1.3). A wrong secret is
;; invalid_client — never a soft pass.
;;
;; Redirect URIs must be pre-registered (§3.1.2.2 + OAuth Security BCP):
;; valid_redirect/3 is the exact-match check the authorize/exchange steps
;; consult so an attacker cannot redirect the code to an unregistered URI.
;;
;; register(C, ClientId, Type, Secret, RedirectUris) -> ok | {error, exists}
;; lookup(C, ClientId) -> {ok, Type, RedirectUris} | {error, unknown_client}
;; authenticate(C, ClientId, Sec) -> {ok, public} | {ok, confidential}
;; | {error, invalid_client} | {error, unknown_client}
;; valid_redirect(C, ClientId, U) -> true | false
(define
identity-clients-source
"-module(identity_clients).\n\n start() ->\n spawn(fun () -> loop([]) end).\n\n register(C, ClientId, Type, Secret, RedirectUris) ->\n C ! {register, ClientId, Type, Secret, RedirectUris, self()},\n receive {client_reply, R} -> R end.\n\n lookup(C, ClientId) ->\n C ! {lookup, ClientId, self()},\n receive {client_reply, R} -> R end.\n\n authenticate(C, ClientId, Secret) ->\n C ! {authenticate, ClientId, Secret, self()},\n receive {client_reply, R} -> R end.\n\n valid_redirect(C, ClientId, Uri) ->\n C ! {valid_redirect, ClientId, Uri, self()},\n receive {client_reply, R} -> R end.\n\n loop(Clients) ->\n receive\n {register, ClientId, Type, Secret, RedirectUris, From} ->\n case find(ClientId, Clients) of\n {ok, _} ->\n From ! {client_reply, {error, exists}},\n loop(Clients);\n none ->\n From ! {client_reply, ok},\n loop([{ClientId, {Type, Secret, RedirectUris}} | Clients])\n end;\n {lookup, ClientId, From} ->\n case find(ClientId, Clients) of\n none -> From ! {client_reply, {error, unknown_client}};\n {ok, {Type, _, Uris}} -> From ! {client_reply, {ok, Type, Uris}}\n end,\n loop(Clients);\n {authenticate, ClientId, Secret, From} ->\n case find(ClientId, Clients) of\n none ->\n From ! {client_reply, {error, unknown_client}};\n {ok, {public, _, _}} ->\n From ! {client_reply, {ok, public}};\n {ok, {confidential, S, _}} ->\n case S =:= Secret of\n true -> From ! {client_reply, {ok, confidential}};\n false -> From ! {client_reply, {error, invalid_client}}\n end\n end,\n loop(Clients);\n {valid_redirect, ClientId, Uri, From} ->\n case find(ClientId, Clients) of\n none -> From ! {client_reply, false};\n {ok, {_, _, Uris}} -> From ! {client_reply, member(Uri, Uris)}\n end,\n loop(Clients);\n {stop, From} ->\n From ! {client_reply, ok}\n end.\n\n member(_, []) -> false;\n member(X, [Y | Rest]) ->\n case X =:= Y of\n true -> true;\n false -> member(X, 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-clients!
(fn () (erlang-load-module identity-clients-source)))

View File

@@ -1,215 +0,0 @@
#!/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"
"token|id-token-test-pass|id-token-test-count"
"registry|id-registry-test-pass|id-registry-test-count"
"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"
"cache|id-cache-test-pass|id-cache-test-count"
"audit|id-audit-test-pass|id-audit-test-count"
"federation|id-fed-test-pass|id-fed-test-count"
"expiry|id-expiry-test-pass|id-expiry-test-count"
"clients|id-clients-test-pass|id-clients-test-count"
"grants|id-grants-test-pass|id-grants-test-count"
"device|id-device-test-pass|id-device-test-count"
"facade|id-facade-test-pass|id-facade-test-count"
"delegation|id-deleg-test-pass|id-deleg-test-count"
"session-mgmt|id-smgmt-test-pass|id-smgmt-test-count"
"exchange|id-xchg-test-pass|id-xchg-test-count"
"introspect|id-intr-test-pass|id-intr-test-count"
"par|id-par-test-pass|id-par-test-count"
"dynreg|id-dyn-test-pass|id-dyn-test-count"
"account|id-acct-test-pass|id-acct-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/token.sx")
(load "lib/identity/registry.sx")
(load "lib/identity/api.sx")
(load "lib/identity/oauth.sx")
(load "lib/identity/membership.sx")
(load "lib/identity/cache.sx")
(load "lib/identity/audit.sx")
(load "lib/identity/federation.sx")
(load "lib/identity/clients.sx")
(load "lib/identity/device.sx")
(load "lib/identity/delegation.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")
(load "lib/identity/tests/cache.sx")
(load "lib/identity/tests/audit.sx")
(load "lib/identity/tests/federation.sx")
(load "lib/identity/tests/expiry.sx")
(load "lib/identity/tests/clients.sx")
(load "lib/identity/tests/grants.sx")
(load "lib/identity/tests/device.sx")
(load "lib/identity/tests/facade.sx")
(load "lib/identity/tests/delegation.sx")
(load "lib/identity/tests/session_mgmt.sx")
(load "lib/identity/tests/exchange.sx")
(load "lib/identity/tests/introspect.sx")
(load "lib/identity/tests/par.sx")
(load "lib/identity/tests/dynreg.sx")
(load "lib/identity/tests/account.sx")
(epoch 100)
(eval "(list id-session-test-pass id-session-test-count)")
(epoch 101)
(eval "(list id-token-test-pass id-token-test-count)")
(epoch 102)
(eval "(list id-registry-test-pass id-registry-test-count)")
(epoch 103)
(eval "(list id-api-test-pass id-api-test-count)")
(epoch 104)
(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)")
(epoch 107)
(eval "(list id-cache-test-pass id-cache-test-count)")
(epoch 108)
(eval "(list id-audit-test-pass id-audit-test-count)")
(epoch 109)
(eval "(list id-fed-test-pass id-fed-test-count)")
(epoch 110)
(eval "(list id-expiry-test-pass id-expiry-test-count)")
(epoch 111)
(eval "(list id-clients-test-pass id-clients-test-count)")
(epoch 112)
(eval "(list id-grants-test-pass id-grants-test-count)")
(epoch 113)
(eval "(list id-device-test-pass id-device-test-count)")
(epoch 114)
(eval "(list id-facade-test-pass id-facade-test-count)")
(epoch 115)
(eval "(list id-deleg-test-pass id-deleg-test-count)")
(epoch 116)
(eval "(list id-smgmt-test-pass id-smgmt-test-count)")
(epoch 117)
(eval "(list id-xchg-test-pass id-xchg-test-count)")
(epoch 118)
(eval "(list id-intr-test-pass id-intr-test-count)")
(epoch 119)
(eval "(list id-par-test-pass id-par-test-count)")
(epoch 120)
(eval "(list id-dyn-test-pass id-dyn-test-count)")
(epoch 121)
(eval "(list id-acct-test-pass id-acct-test-count)")
EPOCHS
timeout 1200 "$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 <<JSON
{
"language": "identity",
"total_pass": $TOTAL_PASS,
"total": $TOTAL_COUNT,
"suites": [$JSON_SUITES
]
}
JSON
cat > lib/identity/scoreboard.md <<MD
# identity-on-sx Scoreboard
**Total: ${TOTAL_PASS} / ${TOTAL_COUNT} tests passing**
| | Suite | Pass | Total |
|---|---|---|---|
$MD_ROWS
Generated by \`lib/identity/conformance.sh\`.
MD
if [ "$TOTAL_PASS" -eq "$TOTAL_COUNT" ]; then
exit 0
else
exit 1
fi

View File

@@ -1,34 +0,0 @@
;; identity/delegation.sx — the identity -> acl delegation boundary.
;;
;; This is the loop's central architectural rule made concrete:
;; AUTHENTICATION is identity's job; AUTHORIZATION is acl's. A request is
;; checked in two stages, and the order matters:
;;
;; 1. identity proves WHO via the opaque token (introspect). If the token
;; is inactive, the answer is {error, unauthenticated} — a 401. acl is
;; NEVER consulted; \"I don't know who you are\" is not a permission
;; question.
;; 2. only for an authenticated subject does identity construct the
;; permission query {Subject, Scope, Action, Resource} and HAND IT OFF
;; to acl. acl returns permit | deny; deny is {error, forbidden} — a
;; 403. identity itself never decides permission.
;;
;; The real decider is acl-on-sx (Datalog), which runs as a different
;; guest language on SX and is wired in at the integration layer. Here the
;; acl side is a labelled STUB process so the boundary is exercised: it
;; permits when the Action is within the token's granted Scope. Swap the
;; stub pid for the acl adapter and the boundary is unchanged.
;;
;; check(TokReg, Acl, Token, Action, Resource) ->
;; {ok, Subject} | {error, unauthenticated} | {error, forbidden}
(define
identity-delegation-source
"-module(identity_delegation).\n\n check(TokReg, Acl, Token, Action, Resource) ->\n case identity_tokens:introspect(TokReg, Token) of\n {inactive} ->\n {error, unauthenticated};\n {active, Subject, _Client, Scope} ->\n Acl ! {acl_query, Subject, Scope, Action, Resource, self()},\n receive {acl_verdict, V} ->\n case V of\n permit -> {ok, Subject};\n deny -> {error, forbidden}\n end\n end\n end.\n\n %% --- stub acl decider (stands in for acl-on-sx / Datalog) ---\n %% Permits iff the Action is one of the token's granted scopes. The real\n %% acl decides on rules + facts; this only exercises the handoff shape.\n stub_acl() ->\n spawn(fun () -> acl_loop() end).\n\n acl_loop() ->\n receive\n {acl_query, _Subject, Scope, Action, _Resource, From} ->\n From ! {acl_verdict, decide(Action, Scope)},\n acl_loop();\n stop ->\n ok\n end.\n\n decide(Action, Scope) ->\n case member(Action, Scope) of\n true -> permit;\n false -> deny\n end.\n\n member(_, []) -> false;\n member(X, [Y | Rest]) ->\n case X =:= Y of\n true -> true;\n false -> member(X, Rest)\n end.")
(define
identity-load-delegation!
(fn
()
(identity-load-token!)
(erlang-load-module identity-delegation-source)))

View File

@@ -1,33 +0,0 @@
;; identity/device.sx — the device authorization grant (RFC 8628).
;;
;; For input-constrained devices (TVs, CLIs): the device gets a device_code
;; + user_code, the user approves out-of-band on another device, and the
;; device polls the token endpoint until it flips. The poll status machine
;; is RFC 8628 §3.5:
;;
;; authorize(ClientId, Scope) -> {ok, DeviceCode, UserCode}
;; approve(UserCode, Subject) -> ok | {error, ...} (the human's browser)
;; deny(UserCode) -> ok | {error, ...}
;; poll(DeviceCode) ->
;; pending -> {error, authorization_pending}
;; denied -> {error, access_denied}
;; approved -> {ok, Token} (device code is then single-use)
;; consumed -> {error, invalid_grant}
;; unknown -> {error, invalid_grant}
;;
;; Tokens are grant-backed (token.sx) so revocation stays real. Device-code
;; expiry and slow_down (poll-rate limiting) are deferred — the substrate
;; has no wall clock and the core status machine is the security-relevant
;; part; introspect via token.sx already honours token TTL.
;;
;; State: loop(TokReg, Requests) where Requests is
;; [{DeviceCode, UserCode, ClientId, Scope, Status}]
;; Status :: pending | {approved, Subject} | denied | consumed
(define
identity-device-source
"-module(identity_device).\n\n start() ->\n spawn(fun () ->\n TokReg = identity_tokens:start(),\n loop(TokReg, [])\n end).\n\n authorize(D, ClientId, Scope) ->\n D ! {authorize, ClientId, Scope, self()},\n receive {device_reply, R} -> R end.\n\n approve(D, UserCode, Subject) ->\n D ! {approve, UserCode, Subject, self()},\n receive {device_reply, R} -> R end.\n\n deny(D, UserCode) ->\n D ! {deny, UserCode, self()},\n receive {device_reply, R} -> R end.\n\n poll(D, DeviceCode) ->\n D ! {poll, DeviceCode, self()},\n receive {device_reply, R} -> R end.\n\n introspect(D, Token) ->\n D ! {introspect, Token, self()},\n receive {device_reply, R} -> R end.\n\n loop(TokReg, Requests) ->\n receive\n {authorize, ClientId, Scope, From} ->\n DeviceCode = make_ref(),\n UserCode = make_ref(),\n From ! {device_reply, {ok, DeviceCode, UserCode}},\n loop(TokReg, [{DeviceCode, UserCode, ClientId, Scope, pending} | Requests]);\n {approve, UserCode, Subject, From} ->\n case find_user(UserCode, Requests) of\n none ->\n From ! {device_reply, {error, unknown_code}},\n loop(TokReg, Requests);\n {ok, {_, _, _, _, pending}} ->\n From ! {device_reply, ok},\n loop(TokReg, set_user(UserCode, {approved, Subject}, Requests));\n {ok, {_, _, _, _, St}} ->\n From ! {device_reply, {error, St}},\n loop(TokReg, Requests)\n end;\n {deny, UserCode, From} ->\n case find_user(UserCode, Requests) of\n none ->\n From ! {device_reply, {error, unknown_code}},\n loop(TokReg, Requests);\n {ok, {_, _, _, _, pending}} ->\n From ! {device_reply, ok},\n loop(TokReg, set_user(UserCode, denied, Requests));\n {ok, {_, _, _, _, St}} ->\n From ! {device_reply, {error, St}},\n loop(TokReg, Requests)\n end;\n {poll, DeviceCode, From} ->\n case find_device(DeviceCode, Requests) of\n none ->\n From ! {device_reply, {error, invalid_grant}},\n loop(TokReg, Requests);\n {ok, {_, _, _, _, pending}} ->\n From ! {device_reply, {error, authorization_pending}},\n loop(TokReg, Requests);\n {ok, {_, _, _, _, denied}} ->\n From ! {device_reply, {error, access_denied}},\n loop(TokReg, Requests);\n {ok, {_, _, _, _, consumed}} ->\n From ! {device_reply, {error, invalid_grant}},\n loop(TokReg, Requests);\n {ok, {_, _, ClientId, Scope, {approved, Subject}}} ->\n {ok, Token} = identity_tokens:issue(TokReg, Subject, ClientId, Scope),\n From ! {device_reply, {ok, Token}},\n loop(TokReg, set_device(DeviceCode, consumed, Requests))\n end;\n {introspect, Token, From} ->\n From ! {device_reply, identity_tokens:introspect(TokReg, Token)},\n loop(TokReg, Requests);\n {stop, From} ->\n From ! {device_reply, ok}\n end.\n\n find_device(_, []) -> none;\n find_device(DCode, [{D, U, C, S, St} | Rest]) ->\n case D =:= DCode of\n true -> {ok, {D, U, C, S, St}};\n false -> find_device(DCode, Rest)\n end.\n\n find_user(_, []) -> none;\n find_user(UCode, [{D, U, C, S, St} | Rest]) ->\n case U =:= UCode of\n true -> {ok, {D, U, C, S, St}};\n false -> find_user(UCode, Rest)\n end.\n\n set_device(_, _, []) -> [];\n set_device(DCode, NewSt, [{D, U, C, S, St} | Rest]) ->\n case D =:= DCode of\n true -> [{D, U, C, S, NewSt} | Rest];\n false -> [{D, U, C, S, St} | set_device(DCode, NewSt, Rest)]\n end.\n\n set_user(_, _, []) -> [];\n set_user(UCode, NewSt, [{D, U, C, S, St} | Rest]) ->\n case U =:= UCode of\n true -> [{D, U, C, S, NewSt} | Rest];\n false -> [{D, U, C, S, St} | set_user(UCode, NewSt, Rest)]\n end.")
(define
identity-load-device!
(fn () (identity-load-token!) (erlang-load-module identity-device-source)))

View File

@@ -1,30 +0,0 @@
;; identity/federation.sx — federated identity: peer-asserted subjects,
;; advisory and trust-gated.
;;
;; A peer instance can assert \"this remote subject authenticated with me\".
;; We accept such an assertion ONLY from a peer we explicitly trust
;; (trust-gated); an assertion from an unknown peer is {error, untrusted},
;; never silently honoured. Even when accepted, the resulting identity is
;; ADVISORY: it is flagged peer_asserted with its origin peer, never
;; promoted to local authority. Downstream (acl) decides how much a
;; peer-asserted identity may do; identity only records who asserted it.
;;
;; Cross-instance subject mapping turns a (Peer, RemoteSubject) pair into a
;; stable local subject. By default it is namespaced — {federated, Peer,
;; RemoteSubject} — so two peers' \"alice\" never collide; an explicit map
;; can alias a remote subject to a local one.
;;
;; trust(F, Peer) / untrust(F, Peer) / trusted(F, Peer)
;; map(F, Peer, Remote, Local) -> ok (optional alias)
;; resolve(F, Peer, Remote) -> {ok, LocalSubject}
;; assert_id(F, Peer, Remote) -> {ok, LocalSubject}
;; | {error, untrusted}
;; provenance(F, LocalSubject) -> {peer_asserted, Peer} | {local}
(define
identity-federation-source
"-module(identity_federation).\n\n start() ->\n spawn(fun () -> loop([], [], []) end).\n\n trust(F, Peer) ->\n F ! {trust, Peer, self()},\n receive {fed_reply, R} -> R end.\n\n untrust(F, Peer) ->\n F ! {untrust, Peer, self()},\n receive {fed_reply, R} -> R end.\n\n trusted(F, Peer) ->\n F ! {trusted, Peer, self()},\n receive {fed_reply, R} -> R end.\n\n map(F, Peer, Remote, Local) ->\n F ! {map, Peer, Remote, Local, self()},\n receive {fed_reply, R} -> R end.\n\n resolve(F, Peer, Remote) ->\n F ! {resolve, Peer, Remote, self()},\n receive {fed_reply, R} -> R end.\n\n assert_id(F, Peer, Remote) ->\n F ! {assert_id, Peer, Remote, self()},\n receive {fed_reply, R} -> R end.\n\n provenance(F, Local) ->\n F ! {provenance, Local, self()},\n receive {fed_reply, R} -> R end.\n\n loop(Trusted, Maps, Asserted) ->\n receive\n {trust, Peer, From} ->\n From ! {fed_reply, ok},\n loop(add_unique(Peer, Trusted), Maps, Asserted);\n {untrust, Peer, From} ->\n From ! {fed_reply, ok},\n loop(drop(Peer, Trusted), Maps, Asserted);\n {trusted, Peer, From} ->\n From ! {fed_reply, member(Peer, Trusted)},\n loop(Trusted, Maps, Asserted);\n {map, Peer, Remote, Local, From} ->\n From ! {fed_reply, ok},\n loop(Trusted, [{{Peer, Remote}, Local} | drop_map(Peer, Remote, Maps)], Asserted);\n {resolve, Peer, Remote, From} ->\n From ! {fed_reply, {ok, resolve_local(Peer, Remote, Maps)}},\n loop(Trusted, Maps, Asserted);\n {assert_id, Peer, Remote, From} ->\n case member(Peer, Trusted) of\n false ->\n From ! {fed_reply, {error, untrusted}},\n loop(Trusted, Maps, Asserted);\n true ->\n Local = resolve_local(Peer, Remote, Maps),\n From ! {fed_reply, {ok, Local}},\n loop(Trusted, Maps, [{Local, Peer} | drop_assert(Local, Asserted)])\n end;\n {provenance, Local, From} ->\n case find_assert(Local, Asserted) of\n {ok, Peer} -> From ! {fed_reply, {peer_asserted, Peer}};\n none -> From ! {fed_reply, {local}}\n end,\n loop(Trusted, Maps, Asserted);\n {stop, From} ->\n From ! {fed_reply, ok}\n end.\n\n resolve_local(Peer, Remote, Maps) ->\n case find_map(Peer, Remote, Maps) of\n {ok, Local} -> Local;\n none -> {federated, Peer, Remote}\n end.\n\n find_map(_, _, []) -> none;\n find_map(Peer, Remote, [{{P, R}, Local} | Rest]) ->\n case same(P, Peer, R, Remote) of\n true -> {ok, Local};\n false -> find_map(Peer, Remote, Rest)\n end.\n\n drop_map(_, _, []) -> [];\n drop_map(Peer, Remote, [{{P, R}, Local} | Rest]) ->\n case same(P, Peer, R, Remote) of\n true -> drop_map(Peer, Remote, Rest);\n false -> [{{P, R}, Local} | drop_map(Peer, Remote, Rest)]\n end.\n\n same(P, Peer, R, Remote) ->\n case P =:= Peer of\n true -> R =:= Remote;\n false -> false\n end.\n\n find_assert(_, []) -> none;\n find_assert(Local, [{L, Peer} | Rest]) ->\n case L =:= Local of\n true -> {ok, Peer};\n false -> find_assert(Local, Rest)\n end.\n\n drop_assert(_, []) -> [];\n drop_assert(Local, [{L, Peer} | Rest]) ->\n case L =:= Local of\n true -> drop_assert(Local, Rest);\n false -> [{L, Peer} | drop_assert(Local, Rest)]\n end.\n\n add_unique(X, Xs) ->\n case member(X, Xs) of\n true -> Xs;\n false -> [X | Xs]\n end.\n\n drop(_, []) -> [];\n drop(X, [Y | Rest]) ->\n case X =:= Y of\n true -> drop(X, Rest);\n false -> [Y | drop(X, Rest)]\n end.\n\n member(_, []) -> false;\n member(X, [Y | Rest]) ->\n case X =:= Y of\n true -> true;\n false -> member(X, Rest)\n end.")
(define
identity-load-federation!
(fn () (erlang-load-module identity-federation-source)))

View File

@@ -1,31 +0,0 @@
;; 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)))

File diff suppressed because one or more lines are too long

View File

@@ -1,22 +0,0 @@
;; identity/registry.sx — routes sessions by id and by (subject, client).
;;
;; The registry is the directory that makes SSO possible: one subject can
;; hold many sessions (one per client), and the OAuth machine asks it the
;; single question that drives silent login — \"is there a live session
;; for this subject + this client?\". It stores (SessionId, Subject,
;; Client, Pid) rows and answers:
;;
;; whereis_session(Id) -> {ok, Pid} | {error, not_found}
;; lookup(Subject, Client) -> {ok, Pid} | {error, not_found} (SSO probe)
;; sessions_for(Subject) -> {ok, [SessionId, ...]} (fan-out)
;;
;; The registry only routes — it holds no grant state and decides nothing.
;; Liveness of the routed-to session is that session process's own affair.
(define
identity-registry-source
"-module(identity_registry).\n\n start() ->\n spawn(fun () -> loop([]) end).\n\n register(Reg, SessionId, Subject, Client, Pid) ->\n Reg ! {register, SessionId, Subject, Client, Pid, self()},\n receive {registry_reply, R} -> R end.\n\n whereis_session(Reg, SessionId) ->\n Reg ! {whereis_session, SessionId, self()},\n receive {registry_reply, R} -> R end.\n\n lookup(Reg, Subject, Client) ->\n Reg ! {lookup, Subject, Client, self()},\n receive {registry_reply, R} -> R end.\n\n sessions_for(Reg, Subject) ->\n Reg ! {sessions_for, Subject, self()},\n receive {registry_reply, R} -> R end.\n\n deregister(Reg, SessionId) ->\n Reg ! {deregister, SessionId, self()},\n receive {registry_reply, R} -> R end.\n\n stop(Reg) ->\n Reg ! {stop, self()},\n receive {registry_reply, R} -> R end.\n\n loop(Entries) ->\n receive\n {register, SessionId, Subject, Client, Pid, From} ->\n From ! {registry_reply, ok},\n loop([{SessionId, Subject, Client, Pid} | remove_id(SessionId, Entries)]);\n {whereis_session, SessionId, From} ->\n From ! {registry_reply, find_id(SessionId, Entries)},\n loop(Entries);\n {lookup, Subject, Client, From} ->\n From ! {registry_reply, find_sc(Subject, Client, Entries)},\n loop(Entries);\n {sessions_for, Subject, From} ->\n From ! {registry_reply, {ok, collect_subject(Subject, Entries)}},\n loop(Entries);\n {deregister, SessionId, From} ->\n From ! {registry_reply, ok},\n loop(remove_id(SessionId, Entries));\n {stop, From} ->\n From ! {registry_reply, ok}\n end.\n\n find_id(_, []) -> {error, not_found};\n find_id(Id, [{Sid, _, _, Pid} | Rest]) ->\n case Sid =:= Id of\n true -> {ok, Pid};\n false -> find_id(Id, Rest)\n end.\n\n find_sc(_, _, []) -> {error, not_found};\n find_sc(Subject, Client, [{_, Su, Cl, Pid} | Rest]) ->\n case Su =:= Subject of\n true ->\n case Cl =:= Client of\n true -> {ok, Pid};\n false -> find_sc(Subject, Client, Rest)\n end;\n false -> find_sc(Subject, Client, Rest)\n end.\n\n collect_subject(_, []) -> [];\n collect_subject(Subject, [{Sid, Su, _, _} | Rest]) ->\n case Su =:= Subject of\n true -> [Sid | collect_subject(Subject, Rest)];\n false -> collect_subject(Subject, Rest)\n end.\n\n remove_id(_, []) -> [];\n remove_id(Id, [{Sid, Su, Cl, Pid} | Rest]) ->\n case Sid =:= Id of\n true -> remove_id(Id, Rest);\n false -> [{Sid, Su, Cl, Pid} | remove_id(Id, Rest)]\n end.")
(define
identity-load-registry!
(fn () (erlang-load-module identity-registry-source)))

View File

@@ -1,29 +0,0 @@
{
"language": "identity",
"total_pass": 229,
"total": 229,
"suites": [
{"name":"session","pass":11,"total":11,"status":"ok"},
{"name":"token","pass":24,"total":24,"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":"membership","pass":17,"total":17,"status":"ok"},
{"name":"cache","pass":9,"total":9,"status":"ok"},
{"name":"audit","pass":11,"total":11,"status":"ok"},
{"name":"federation","pass":12,"total":12,"status":"ok"},
{"name":"expiry","pass":8,"total":8,"status":"ok"},
{"name":"clients","pass":11,"total":11,"status":"ok"},
{"name":"grants","pass":9,"total":9,"status":"ok"},
{"name":"device","pass":10,"total":10,"status":"ok"},
{"name":"facade","pass":9,"total":9,"status":"ok"},
{"name":"delegation","pass":8,"total":8,"status":"ok"},
{"name":"session-mgmt","pass":8,"total":8,"status":"ok"},
{"name":"exchange","pass":8,"total":8,"status":"ok"},
{"name":"introspect","pass":9,"total":9,"status":"ok"},
{"name":"par","pass":7,"total":7,"status":"ok"},
{"name":"dynreg","pass":5,"total":5,"status":"ok"},
{"name":"account","pass":7,"total":7,"status":"ok"}
]
}

View File

@@ -1,31 +0,0 @@
# identity-on-sx Scoreboard
**Total: 229 / 229 tests passing**
| | Suite | Pass | Total |
|---|---|---|---|
| ✅ | session | 11 | 11 |
| ✅ | token | 24 | 24 |
| ✅ | registry | 9 | 9 |
| ✅ | api | 10 | 10 |
| ✅ | oauth | 17 | 17 |
| ✅ | sso | 10 | 10 |
| ✅ | membership | 17 | 17 |
| ✅ | cache | 9 | 9 |
| ✅ | audit | 11 | 11 |
| ✅ | federation | 12 | 12 |
| ✅ | expiry | 8 | 8 |
| ✅ | clients | 11 | 11 |
| ✅ | grants | 9 | 9 |
| ✅ | device | 10 | 10 |
| ✅ | facade | 9 | 9 |
| ✅ | delegation | 8 | 8 |
| ✅ | session-mgmt | 8 | 8 |
| ✅ | exchange | 8 | 8 |
| ✅ | introspect | 9 | 9 |
| ✅ | par | 7 | 7 |
| ✅ | dynreg | 5 | 5 |
| ✅ | account | 7 | 7 |
Generated by `lib/identity/conformance.sh`.

View File

@@ -1,20 +0,0 @@
;; identity/session.sx — a session is an Erlang process.
;;
;; create = spawn a session process holding {subject, client, status}
;; lookup = a message; the live process answers {ok, ...} or {error, S}
;; expire = explicit message OR an idle timeout the process arms itself
;; revoke = explicit message; the grant tombstones immediately
;;
;; Expiry is the process's own `receive ... after Ttl` timeout, never a
;; global sweep. On timeout the process notifies its Owner and becomes a
;; tombstone that still answers lookups — with {error, expired}, never a
;; silent dead mailbox. A revoked or expired session is an explicit
;; negative state, not the absence of a positive one.
(define
identity-session-source
"-module(identity_session).\n\n start(SessionId, Subject, Client, Owner, Ttl) ->\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)))

View File

@@ -1,74 +0,0 @@
;; identity/tests/account.sx — \"apps with access\": per-subject active-grant
;; listing, at the token registry (grants_for) and through the facade
;; (identity:grants). Completes the per-subject security trio with sessions
;; and history.
(define id-acct-test-count 0)
(define id-acct-test-pass 0)
(define id-acct-test-fails (list))
(define
id-acct-test
(fn
(name actual expected)
(set! id-acct-test-count (+ id-acct-test-count 1))
(if
(= actual expected)
(set! id-acct-test-pass (+ id-acct-test-pass 1))
(append! id-acct-test-fails {:name name :expected expected :actual actual}))))
(define ida-ev erlang-eval-ast)
(define idanm (fn (v) (get v :name)))
(identity-load-all!)
;; ── token-level grants_for ───────────────────────────────────────
(id-acct-test
"grants_for lists a subject's active grants"
(ida-ev
"R = identity_tokens:start(),\n identity_tokens:issue(R, alice, web, read),\n identity_tokens:issue(R, alice, cli, write),\n identity_tokens:issue(R, bob, web, read),\n length(identity_tokens:grants_for(R, alice))")
2)
(id-acct-test
"grants_for excludes revoked grants"
(ida-ev
"R = identity_tokens:start(),\n {ok, A} = identity_tokens:issue(R, alice, web, read),\n identity_tokens:issue(R, alice, cli, write),\n identity_tokens:revoke(R, A),\n length(identity_tokens:grants_for(R, alice))")
1)
(id-acct-test
"grants_for is empty for a subject with none"
(ida-ev
"R = identity_tokens:start(),\n identity_tokens:issue(R, alice, web, read),\n length(identity_tokens:grants_for(R, ghost))")
0)
(id-acct-test
"each grant entry carries the client"
(idanm
(ida-ev
"R = identity_tokens:start(),\n identity_tokens:issue(R, alice, web, read),\n case identity_tokens:grants_for(R, alice) of\n [{Client, _Scope}] -> Client;\n _ -> other\n end"))
"web")
;; ── facade-level grants ──────────────────────────────────────────
(id-acct-test
"identity:grants lists apps a subject has logged into"
(ida-ev
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, mobile, read),\n length(identity:grants(Svc, alice))")
2)
(id-acct-test
"revoking a token drops it from identity:grants"
(ida-ev
"Svc = identity:start(),\n {ok, _S1, T1} = identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, mobile, read),\n identity:revoke(Svc, T1),\n length(identity:grants(Svc, alice))")
1)
(id-acct-test
"identity:grants is per-subject"
(ida-ev
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, bob, web, read),\n length(identity:grants(Svc, bob))")
1)
(define
id-acct-test-summary
(str "account " id-acct-test-pass "/" id-acct-test-count))

View File

@@ -1,111 +0,0 @@
;; identity/tests/api.sx — the service facade end-to-end: login issues a
;; session + token, verify proves identity, revoke and logout take effect
;; immediately. Exercises session + token + registry through one door.
(define id-api-test-count 0)
(define id-api-test-pass 0)
(define id-api-test-fails (list))
(define
id-api-test
(fn
(name actual expected)
(set! id-api-test-count (+ id-api-test-count 1))
(if
(= actual expected)
(set! id-api-test-pass (+ id-api-test-pass 1))
(append! id-api-test-fails {:name name :expected expected :actual actual}))))
(define ida-ev erlang-eval-ast)
(define idanm (fn (v) (get v :name)))
(identity-load-all!)
;; ── login + verify (happy path) ──────────────────────────────────
(id-api-test
"login then verify is active"
(idanm
(ida-ev
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n case identity:verify(Svc, Tok) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"active")
(id-api-test
"verify returns the logged-in subject"
(idanm
(ida-ev
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n case identity:verify(Svc, Tok) of\n {active, Subject, _, _} -> Subject\n end"))
"alice")
(id-api-test
"verify returns the granted scope"
(idanm
(ida-ev
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, bob, cli, write),\n case identity:verify(Svc, Tok) of\n {active, _, _, Scope} -> Scope\n end"))
"write")
;; ── revoke is real through the facade ────────────────────────────
(id-api-test
"revoked token verifies inactive immediately"
(idanm
(ida-ev
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n identity:revoke(Svc, Tok),\n case identity:verify(Svc, Tok) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
"inactive")
;; ── session lifecycle through the facade ─────────────────────────
(id-api-test
"fresh session reports active"
(idanm
(ida-ev
"Svc = identity:start(),\n {ok, Sid, _Tok} = identity:login(Svc, alice, web, read),\n identity:session_status(Svc, Sid)"))
"active")
(id-api-test
"logout makes the session gone"
(idanm
(ida-ev
"Svc = identity:start(),\n {ok, Sid, _Tok} = identity:login(Svc, alice, web, read),\n identity:logout(Svc, Sid),\n identity:session_status(Svc, Sid)"))
"gone")
(id-api-test
"status of an unknown session is gone"
(idanm
(ida-ev "Svc = identity:start(),\n identity:session_status(Svc, 999)"))
"gone")
;; ── independence: logins do not bleed into each other ────────────
(id-api-test
"revoking one login leaves the other active"
(idanm
(ida-ev
"Svc = identity:start(),\n {ok, _S1, T1} = identity:login(Svc, alice, web, read),\n {ok, _S2, T2} = identity:login(Svc, bob, cli, write),\n identity:revoke(Svc, T1),\n case identity:verify(Svc, T2) of\n {active, Subject, _, _} -> Subject;\n {inactive} -> inactive\n end"))
"bob")
(id-api-test
"logging out one session leaves the other active"
(idanm
(ida-ev
"Svc = identity:start(),\n {ok, S1, _T1} = identity:login(Svc, alice, web, read),\n {ok, S2, _T2} = identity:login(Svc, alice, cli, read),\n identity:logout(Svc, S1),\n identity:session_status(Svc, S2)"))
"active")
;; ── coordinator deregisters on a session_expired notification ────
;; A live idle session fires its own `after` timeout and notifies its
;; owner (the coordinator), which then deregisters it — timeout-driven,
;; never swept. The owner-internal path can't be observed by driving the
;; scheduler idle from the test's main process, so we assert the handler
;; directly: the mailbox is FIFO, so the expiry notification is processed
;; before the following status query.
(id-api-test
"session_expired notification deregisters the session"
(idanm
(ida-ev
"Svc = identity:start(),\n {ok, Sid, _Tok} = identity:login(Svc, alice, web, read, 50),\n active = identity:session_status(Svc, Sid),\n Svc ! {session_expired, Sid},\n identity:session_status(Svc, Sid)"))
"gone")
(define
id-api-test-summary
(str "api " id-api-test-pass "/" id-api-test-count))

View File

@@ -1,117 +0,0 @@
;; 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, completeness,
;; 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)
;; ── completeness: no grant transition is dropped ─────────────────
(id-audit-test
"the ledger is complete across a mixed transition stream"
(ida-ev
"A = identity_audit:start(),\n Reg = identity_tokens:start(A),\n identity_tokens:issue(Reg, alice, web, read),\n {ok, _G, R} = identity_tokens:issue_grant(Reg, alice, cli, read),\n identity_tokens:refresh(Reg, R),\n {ok, B} = identity_tokens:issue(Reg, bob, web, read),\n identity_tokens:revoke(Reg, B),\n length(identity_audit:all(A))")
5)
;; ── 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))

View File

@@ -1,102 +0,0 @@
;; identity/tests/cache.sx — delegated grant-verification cache. Proves
;; the cache is live (hits/misses) AND that revocation stays real: a
;; revoked token never reads valid out of the cache, because any revoke
;; bumps the generation and forces re-validation.
(define id-cache-test-count 0)
(define id-cache-test-pass 0)
(define id-cache-test-fails (list))
(define
id-cache-test
(fn
(name actual expected)
(set! id-cache-test-count (+ id-cache-test-count 1))
(if
(= actual expected)
(set! id-cache-test-pass (+ id-cache-test-pass 1))
(append! id-cache-test-fails {:name name :expected expected :actual actual}))))
(define idc-ev erlang-eval-ast)
(define idcnm (fn (v) (get v :name)))
(identity-load-token!)
(identity-load-cache!)
;; ── delegation: cache forwards to the registry ───────────────────
(id-cache-test
"introspect through the cache returns active"
(idcnm
(idc-ev
"C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n case identity_grant_cache:introspect(C, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"active")
;; ── the cache is actually caching ────────────────────────────────
(id-cache-test
"a repeated introspect is a cache hit"
(idc-ev
"C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n identity_grant_cache:introspect(C, T),\n identity_grant_cache:introspect(C, T),\n case identity_grant_cache:stats(C) of {H, _} -> H end")
1)
(id-cache-test
"the first introspect of a token is a miss"
(idc-ev
"C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n identity_grant_cache:introspect(C, T),\n identity_grant_cache:introspect(C, T),\n case identity_grant_cache:stats(C) of {_, M} -> M end")
1)
;; ── revocation stays real through the cache (the centrepiece) ─────
(id-cache-test
"a revoked token introspects inactive through the cache"
(idcnm
(idc-ev
"C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n identity_grant_cache:introspect(C, T),\n identity_grant_cache:revoke(C, T),\n case identity_grant_cache:introspect(C, T) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
"inactive")
(id-cache-test
"revoke invalidates the cache (post-revoke read re-validates)"
(idc-ev
"C = identity_grant_cache:start(),\n {ok, T} = identity_grant_cache:issue(C, alice, web, read),\n identity_grant_cache:introspect(C, T),\n identity_grant_cache:revoke(C, T),\n identity_grant_cache:introspect(C, T),\n case identity_grant_cache:stats(C) of {_, M} -> M end")
2)
;; ── cascade visibility through the cache ──────────────────────────
(id-cache-test
"cascade revocation is visible through the cache"
(idcnm
(idc-ev
"C = identity_grant_cache:start(),\n {ok, A, R} = identity_grant_cache:issue_grant(C, alice, web, read),\n identity_grant_cache:introspect(C, A),\n identity_grant_cache:revoke(C, R),\n case identity_grant_cache:introspect(C, A) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
"inactive")
;; ── a sibling token re-validates correctly after a revoke ────────
(id-cache-test
"revoking one token leaves an independent token valid"
(idcnm
(idc-ev
"C = identity_grant_cache:start(),\n {ok, A} = identity_grant_cache:issue(C, alice, web, read),\n {ok, B} = identity_grant_cache:issue(C, bob, cli, write),\n identity_grant_cache:introspect(C, A),\n identity_grant_cache:introspect(C, B),\n identity_grant_cache:revoke(C, A),\n case identity_grant_cache:introspect(C, B) of\n {active, Subject, _, _} -> Subject;\n {inactive} -> inactive\n end"))
"bob")
;; ── refresh flows through the cache and stays correct ────────────
(id-cache-test
"a refreshed token introspects active through the cache"
(idcnm
(idc-ev
"C = identity_grant_cache:start(),\n {ok, _A, R} = identity_grant_cache:issue_grant(C, alice, web, read),\n {ok, A2, _R2} = identity_grant_cache:refresh(C, R),\n case identity_grant_cache:introspect(C, A2) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"active")
;; ── unknown token is inactive, and cached as such ────────────────
(id-cache-test
"an unknown token introspects inactive through the cache"
(idcnm
(idc-ev
"C = identity_grant_cache:start(),\n Bogus = make_ref(),\n case identity_grant_cache:introspect(C, Bogus) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"inactive")
(define
id-cache-test-summary
(str "cache " id-cache-test-pass "/" id-cache-test-count))

View File

@@ -1,108 +0,0 @@
;; identity/tests/clients.sx — OAuth client registry: registration,
;; public vs confidential authentication, and redirect_uri allow-listing.
(define id-clients-test-count 0)
(define id-clients-test-pass 0)
(define id-clients-test-fails (list))
(define
id-clients-test
(fn
(name actual expected)
(set! id-clients-test-count (+ id-clients-test-count 1))
(if
(= actual expected)
(set! id-clients-test-pass (+ id-clients-test-pass 1))
(append! id-clients-test-fails {:name name :expected expected :actual actual}))))
(define idc-ev erlang-eval-ast)
(define idcnm (fn (v) (get v :name)))
(identity-load-clients!)
;; ── registration + lookup ────────────────────────────────────────
(id-clients-test
"a registered client looks up its type"
(idcnm
(idc-ev
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:lookup(C, app1) of\n {ok, Type, _} -> Type;\n {error, W} -> W\n end"))
"confidential")
(id-clients-test
"registering the same client twice is an error"
(idcnm
(idc-ev
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:register(C, app1, public, none, [uri1]) of\n ok -> ok;\n {error, W} -> W\n end"))
"exists")
(id-clients-test
"looking up an unregistered client is unknown_client"
(idcnm
(idc-ev
"C = identity_clients:start(),\n case identity_clients:lookup(C, ghost) of\n {ok, _, _} -> found;\n {error, W} -> W\n end"))
"unknown_client")
;; ── confidential client authentication ───────────────────────────
(id-clients-test
"a confidential client authenticates with the right secret"
(idcnm
(idc-ev
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:authenticate(C, app1, s3cret) of\n {ok, Kind} -> Kind;\n {error, W} -> W\n end"))
"confidential")
(id-clients-test
"a confidential client with the wrong secret is invalid_client"
(idcnm
(idc-ev
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:authenticate(C, app1, wrongsecret) of\n {ok, _} -> accepted;\n {error, W} -> W\n end"))
"invalid_client")
(id-clients-test
"a public client needs no secret to authenticate"
(idcnm
(idc-ev
"C = identity_clients:start(),\n identity_clients:register(C, spa, public, none, [uri1]),\n case identity_clients:authenticate(C, spa, anything) of\n {ok, Kind} -> Kind;\n {error, W} -> W\n end"))
"public")
(id-clients-test
"authenticating an unknown client is unknown_client"
(idcnm
(idc-ev
"C = identity_clients:start(),\n case identity_clients:authenticate(C, ghost, x) of\n {ok, _} -> accepted;\n {error, W} -> W\n end"))
"unknown_client")
;; ── redirect_uri allow-listing ───────────────────────────────────
(id-clients-test
"a registered redirect_uri is valid"
(idcnm
(idc-ev
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1, uri2]),\n case identity_clients:valid_redirect(C, app1, uri1) of\n true -> yes;\n false -> no\n end"))
"yes")
(id-clients-test
"a second registered redirect_uri is also valid"
(idcnm
(idc-ev
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1, uri2]),\n case identity_clients:valid_redirect(C, app1, uri2) of\n true -> yes;\n false -> no\n end"))
"yes")
(id-clients-test
"an unregistered redirect_uri is rejected"
(idcnm
(idc-ev
"C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:valid_redirect(C, app1, evil_uri) of\n true -> yes;\n false -> no\n end"))
"no")
(id-clients-test
"redirect validation for an unknown client is rejected"
(idcnm
(idc-ev
"C = identity_clients:start(),\n case identity_clients:valid_redirect(C, ghost, uri1) of\n true -> yes;\n false -> no\n end"))
"no")
(define
id-clients-test-summary
(str "clients " id-clients-test-pass "/" id-clients-test-count))

View File

@@ -1,102 +0,0 @@
;; identity/tests/delegation.sx — the identity -> acl boundary.
;; Authentication (identity) gates BEFORE authorization (acl): an inactive
;; token is unauthenticated (401) and acl is never consulted; only an
;; authenticated subject's request is delegated to acl for permit/deny.
(define id-deleg-test-count 0)
(define id-deleg-test-pass 0)
(define id-deleg-test-fails (list))
(define
id-deleg-test
(fn
(name actual expected)
(set! id-deleg-test-count (+ id-deleg-test-count 1))
(if
(= actual expected)
(set! id-deleg-test-pass (+ id-deleg-test-pass 1))
(append! id-deleg-test-fails {:name name :expected expected :actual actual}))))
(define idl-ev erlang-eval-ast)
(define idlnm (fn (v) (get v :name)))
(identity-load-delegation!)
;; Shared prelude: a token registry, a stub acl, and a token granting
;; [read, write] to alice, all bound.
(define
idl-setup
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read, write])")
;; ── authenticated + acl permits ──────────────────────────────────
(id-deleg-test
"an authenticated, permitted request returns the subject"
(idlnm
(idl-ev
(str
idl-setup
", case identity_delegation:check(R, A, T, read, doc1) of\n {ok, S} -> S;\n {error, W} -> W\n end")))
"alice")
;; ── authenticated + acl denies → 403 ─────────────────────────────
(id-deleg-test
"an authenticated but unpermitted request is forbidden"
(idlnm
(idl-ev
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read]),\n case identity_delegation:check(R, A, T, write, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end"))
"forbidden")
;; ── unauthenticated → 401, acl never consulted ───────────────────
(id-deleg-test
"a revoked token is unauthenticated, not forbidden"
(idlnm
(idl-ev
(str
idl-setup
", identity_tokens:revoke(R, T),\n case identity_delegation:check(R, A, T, read, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end")))
"unauthenticated")
(id-deleg-test
"an unknown token is unauthenticated"
(idlnm
(idl-ev
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n Bogus = make_ref(),\n case identity_delegation:check(R, A, Bogus, read, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end"))
"unauthenticated")
(id-deleg-test
"an expired token is unauthenticated"
(idlnm
(idl-ev
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read], 100),\n identity_tokens:advance(R, 100),\n case identity_delegation:check(R, A, T, read, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end"))
"unauthenticated")
;; ── 401 takes precedence over 403 (identity gates first) ─────────
(id-deleg-test
"a revoked token with no matching scope is still unauthenticated"
(idlnm
(idl-ev
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [admin]),\n identity_tokens:revoke(R, T),\n case identity_delegation:check(R, A, T, read, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end"))
"unauthenticated")
;; ── acl is what decides for an authenticated subject ─────────────
(id-deleg-test
"the same subject is permitted one action and denied another"
(idl-ev
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read]),\n Allowed = case identity_delegation:check(R, A, T, read, doc1) of\n {ok, _} -> 1; {error, _} -> 0 end,\n Denied = case identity_delegation:check(R, A, T, write, doc1) of\n {ok, _} -> 1; {error, _} -> 0 end,\n Allowed - Denied")
1)
(id-deleg-test
"identity does not widen permission beyond the token scope"
(idlnm
(idl-ev
"R = identity_tokens:start(),\n A = identity_delegation:stub_acl(),\n {ok, T} = identity_tokens:issue(R, alice, web, [read, write]),\n case identity_delegation:check(R, A, T, delete, doc1) of\n {ok, _} -> permitted;\n {error, W} -> W\n end"))
"forbidden")
(define
id-deleg-test-summary
(str "delegation " id-deleg-test-pass "/" id-deleg-test-count))

View File

@@ -1,109 +0,0 @@
;; identity/tests/device.sx — device authorization grant (RFC 8628):
;; authorize → poll(pending) → approve/deny out-of-band → poll(token/denied).
(define id-device-test-count 0)
(define id-device-test-pass 0)
(define id-device-test-fails (list))
(define
id-device-test
(fn
(name actual expected)
(set! id-device-test-count (+ id-device-test-count 1))
(if
(= actual expected)
(set! id-device-test-pass (+ id-device-test-pass 1))
(append! id-device-test-fails {:name name :expected expected :actual actual}))))
(define idd-ev erlang-eval-ast)
(define iddnm (fn (v) (get v :name)))
(identity-load-device!)
;; ── polling before approval ──────────────────────────────────────
(id-device-test
"polling a pending device code is authorization_pending"
(iddnm
(idd-ev
"D = identity_device:start(),\n {ok, Dc, _Uc} = identity_device:authorize(D, tv, watch),\n case identity_device:poll(D, Dc) of\n {ok, _} -> got;\n {error, W} -> W\n end"))
"authorization_pending")
;; ── approve → token ──────────────────────────────────────────────
(id-device-test
"after approval, polling yields a working token"
(iddnm
(idd-ev
"D = identity_device:start(),\n {ok, Dc, Uc} = identity_device:authorize(D, tv, watch),\n identity_device:approve(D, Uc, alice),\n {ok, T} = identity_device:poll(D, Dc),\n case identity_device:introspect(D, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"active")
(id-device-test
"the device token carries the approving subject"
(iddnm
(idd-ev
"D = identity_device:start(),\n {ok, Dc, Uc} = identity_device:authorize(D, tv, watch),\n identity_device:approve(D, Uc, alice),\n {ok, T} = identity_device:poll(D, Dc),\n case identity_device:introspect(D, T) of\n {active, Subject, _, _} -> Subject\n end"))
"alice")
(id-device-test
"the device token carries the requested scope"
(iddnm
(idd-ev
"D = identity_device:start(),\n {ok, Dc, Uc} = identity_device:authorize(D, tv, stream),\n identity_device:approve(D, Uc, alice),\n {ok, T} = identity_device:poll(D, Dc),\n case identity_device:introspect(D, T) of\n {active, _, _, Scope} -> Scope\n end"))
"stream")
;; ── deny ─────────────────────────────────────────────────────────
(id-device-test
"after denial, polling is access_denied"
(iddnm
(idd-ev
"D = identity_device:start(),\n {ok, Dc, Uc} = identity_device:authorize(D, tv, watch),\n identity_device:deny(D, Uc),\n case identity_device:poll(D, Dc) of\n {ok, _} -> got;\n {error, W} -> W\n end"))
"access_denied")
;; ── unknown codes ────────────────────────────────────────────────
(id-device-test
"polling an unknown device code is invalid_grant"
(iddnm
(idd-ev
"D = identity_device:start(),\n Bogus = make_ref(),\n case identity_device:poll(D, Bogus) of\n {ok, _} -> got;\n {error, W} -> W\n end"))
"invalid_grant")
(id-device-test
"approving an unknown user code is unknown_code"
(iddnm
(idd-ev
"D = identity_device:start(),\n Bogus = make_ref(),\n case identity_device:approve(D, Bogus, alice) of\n ok -> ok;\n {error, W} -> W\n end"))
"unknown_code")
;; ── single-use device code ───────────────────────────────────────
(id-device-test
"the device code is single-use after issuing a token"
(iddnm
(idd-ev
"D = identity_device:start(),\n {ok, Dc, Uc} = identity_device:authorize(D, tv, watch),\n identity_device:approve(D, Uc, alice),\n identity_device:poll(D, Dc),\n case identity_device:poll(D, Dc) of\n {ok, _} -> got;\n {error, W} -> W\n end"))
"invalid_grant")
;; ── guarded transitions ──────────────────────────────────────────
(id-device-test
"approving an already-denied request is rejected"
(iddnm
(idd-ev
"D = identity_device:start(),\n {ok, _Dc, Uc} = identity_device:authorize(D, tv, watch),\n identity_device:deny(D, Uc),\n case identity_device:approve(D, Uc, alice) of\n ok -> ok;\n {error, W} -> W\n end"))
"denied")
;; ── independence ─────────────────────────────────────────────────
(id-device-test
"two device requests are independent"
(iddnm
(idd-ev
"D = identity_device:start(),\n {ok, Dc1, Uc1} = identity_device:authorize(D, tv, watch),\n {ok, Dc2, _Uc2} = identity_device:authorize(D, cli, deploy),\n identity_device:approve(D, Uc1, alice),\n case identity_device:poll(D, Dc2) of\n {ok, _} -> got;\n {error, W} -> W\n end"))
"authorization_pending")
(define
id-device-test-summary
(str "device " id-device-test-pass "/" id-device-test-count))

View File

@@ -1,68 +0,0 @@
;; identity/tests/dynreg.sx — dynamic client registration (RFC 7591): the
;; server generates the client_id + secret for self-service onboarding.
(define id-dyn-test-count 0)
(define id-dyn-test-pass 0)
(define id-dyn-test-fails (list))
(define
id-dyn-test
(fn
(name actual expected)
(set! id-dyn-test-count (+ id-dyn-test-count 1))
(if
(= actual expected)
(set! id-dyn-test-pass (+ id-dyn-test-pass 1))
(append! id-dyn-test-fails {:name name :expected expected :actual actual}))))
(define idd-ev erlang-eval-ast)
(define iddnm (fn (v) (get v :name)))
(identity-load-oauth!)
;; ── self-service registration yields usable credentials ──────────
(id-dyn-test
"a dynamically registered confidential client can get a token"
(iddnm
(idd-ev
"O = identity_oauth:start(),\n {ok, Cid, Sec} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, Cid, Sec, batch),\n case identity_oauth:introspect(O, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"active")
(id-dyn-test
"the token's subject is the generated client id"
(iddnm
(idd-ev
"O = identity_oauth:start(),\n {ok, Cid, Sec} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, Cid, Sec, batch),\n case identity_oauth:introspect(O, T) of\n {active, Sub, _, _} ->\n case Sub =:= Cid of true -> matches; false -> mismatch end;\n {inactive} -> inactive\n end"))
"matches")
;; ── the generated secret is required ─────────────────────────────
(id-dyn-test
"a wrong secret for a dynamic client is invalid_client"
(iddnm
(idd-ev
"O = identity_oauth:start(),\n {ok, Cid, _Sec} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n case identity_oauth:client_credentials(O, Cid, wrongsecret, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end"))
"invalid_client")
;; ── uniqueness ───────────────────────────────────────────────────
(id-dyn-test
"two registrations yield distinct client ids"
(iddnm
(idd-ev
"O = identity_oauth:start(),\n {ok, C1, _} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n {ok, C2, _} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n case C1 =:= C2 of true -> collision; false -> distinct end"))
"distinct")
;; ── a dynamic public client still cannot use client-credentials ──
(id-dyn-test
"a dynamic public client is unauthorized for client-credentials"
(iddnm
(idd-ev
"O = identity_oauth:start(),\n {ok, Cid, Sec} = identity_oauth:register_dynamic(O, public, [uri1]),\n case identity_oauth:client_credentials(O, Cid, Sec, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end"))
"unauthorized_client")
(define
id-dyn-test-summary
(str "dynreg " id-dyn-test-pass "/" id-dyn-test-count))

View File

@@ -1,110 +0,0 @@
;; identity/tests/exchange.sx — token exchange (RFC 8693 §2.1): downscope a
;; valid access token into a new independent token for a downstream service.
(define id-xchg-test-count 0)
(define id-xchg-test-pass 0)
(define id-xchg-test-fails (list))
(define
id-xchg-test
(fn
(name actual expected)
(set! id-xchg-test-count (+ id-xchg-test-count 1))
(if
(= actual expected)
(set! id-xchg-test-pass (+ id-xchg-test-pass 1))
(append! id-xchg-test-fails {:name name :expected expected :actual actual}))))
(define idx-ev erlang-eval-ast)
(define idxnm (fn (v) (get v :name)))
(identity-load-oauth!)
;; Shared prelude: an access token A for alice with scope [read, write].
(define
idx-token
"O = identity_oauth:start(),\n {consent_required, Rq} = identity_oauth:authorize(O, web, uri1, [read, write], alice, v),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, v)")
;; ── downscoping ──────────────────────────────────────────────────
(id-xchg-test
"exchange downscopes to a subset"
(idxnm
(idx-ev
(str
idx-token
", {ok, X} = identity_oauth:token_exchange(O, A, [read]),\n case identity_oauth:introspect(O, X) of\n {active, _, _, [read]} -> downscoped;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end")))
"downscoped")
(id-xchg-test
"the exchanged token keeps the subject"
(idxnm
(idx-ev
(str
idx-token
", {ok, X} = identity_oauth:token_exchange(O, A, [read]),\n case identity_oauth:introspect(O, X) of\n {active, Subject, _, _} -> Subject\n end")))
"alice")
(id-xchg-test
"exchange to the same scope is allowed"
(idxnm
(idx-ev
(str
idx-token
", {ok, X} = identity_oauth:token_exchange(O, A, [read, write]),\n case identity_oauth:introspect(O, X) of\n {active, _, _, [read, write]} -> full;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end")))
"full")
;; ── scope cannot be widened ──────────────────────────────────────
(id-xchg-test
"exchange cannot widen beyond the subject token's scope"
(idxnm
(idx-ev
"O = identity_oauth:start(),\n {consent_required, Rq} = identity_oauth:authorize(O, web, uri1, [read], alice, v),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, v),\n case identity_oauth:token_exchange(O, A, [read, write]) of\n {ok, _} -> widened;\n {error, W} -> W\n end"))
"invalid_scope")
;; ── inactive subject token cannot be exchanged ───────────────────
(id-xchg-test
"exchanging a revoked subject token is invalid_grant"
(idxnm
(idx-ev
(str
idx-token
", identity_oauth:revoke(O, A),\n case identity_oauth:token_exchange(O, A, [read]) of\n {ok, _} -> issued;\n {error, W} -> W\n end")))
"invalid_grant")
;; ── independent lifecycles ───────────────────────────────────────
(id-xchg-test
"revoking the subject token does not revoke the exchanged token"
(idxnm
(idx-ev
(str
idx-token
", {ok, X} = identity_oauth:token_exchange(O, A, [read]),\n identity_oauth:revoke(O, A),\n case identity_oauth:introspect(O, X) of\n {active, _, _, _} -> still_active;\n {inactive} -> inactive\n end")))
"still_active")
(id-xchg-test
"revoking the exchanged token does not revoke the subject token"
(idxnm
(idx-ev
(str
idx-token
", {ok, X} = identity_oauth:token_exchange(O, A, [read]),\n identity_oauth:revoke(O, X),\n case identity_oauth:introspect(O, A) of\n {active, _, _, _} -> still_active;\n {inactive} -> inactive\n end")))
"still_active")
;; ── chained downscoping ──────────────────────────────────────────
(id-xchg-test
"an exchanged token can itself be exchanged (chain)"
(idxnm
(idx-ev
(str
idx-token
", {ok, X1} = identity_oauth:token_exchange(O, A, [read, write]),\n {ok, X2} = identity_oauth:token_exchange(O, X1, [read]),\n case identity_oauth:introspect(O, X2) of\n {active, _, _, [read]} -> chained;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end")))
"chained")
(define
id-xchg-test-summary
(str "exchange " id-xchg-test-pass "/" id-xchg-test-count))

View File

@@ -1,92 +0,0 @@
;; identity/tests/expiry.sx — access-token expiry on a logical clock
;; (RFC 6749 §4.2.2 expires_in). `advance` stands in for time passing;
;; introspect returns inactive once the clock reaches a token's expiry.
;; Refresh mints a fresh short-lived access token — the point of refresh.
(define id-expiry-test-count 0)
(define id-expiry-test-pass 0)
(define id-expiry-test-fails (list))
(define
id-expiry-test
(fn
(name actual expected)
(set! id-expiry-test-count (+ id-expiry-test-count 1))
(if
(= actual expected)
(set! id-expiry-test-pass (+ id-expiry-test-pass 1))
(append! id-expiry-test-fails {:name name :expected expected :actual actual}))))
(define ide-ev erlang-eval-ast)
(define idenm (fn (v) (get v :name)))
(identity-load-token!)
;; ── within TTL is active; past TTL is inactive ───────────────────
(id-expiry-test
"a token within its TTL is active"
(idenm
(ide-ev
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 50),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"active")
(id-expiry-test
"a token at its TTL boundary is expired"
(idenm
(ide-ev
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 100),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"inactive")
(id-expiry-test
"a token just before its TTL is still active"
(idenm
(ide-ev
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 99),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"active")
;; ── no TTL (infinity) never expires ──────────────────────────────
(id-expiry-test
"a token issued without a TTL never expires"
(idenm
(ide-ev
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read),\n identity_tokens:advance(R, 100000),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"active")
;; ── refresh mints a fresh short-lived token ──────────────────────
(id-expiry-test
"refresh renews access after the old token expired"
(idenm
(ide-ev
"R = identity_tokens:start(),\n {ok, A, Rt} = identity_tokens:issue_grant(R, alice, web, read, 100),\n identity_tokens:advance(R, 100),\n inactive = case identity_tokens:introspect(R, A) of\n {active, _, _, _} -> active; {inactive} -> inactive end,\n {ok, A2, _R2} = identity_tokens:refresh(R, Rt),\n case identity_tokens:introspect(R, A2) of\n {active, _, _, _} -> renewed;\n {inactive} -> inactive\n end"))
"renewed")
(id-expiry-test
"the renewed token also expires after its own TTL"
(idenm
(ide-ev
"R = identity_tokens:start(),\n {ok, _A, Rt} = identity_tokens:issue_grant(R, alice, web, read, 100),\n identity_tokens:advance(R, 100),\n {ok, A2, _R2} = identity_tokens:refresh(R, Rt),\n identity_tokens:advance(R, 100),\n case identity_tokens:introspect(R, A2) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"inactive")
;; ── the logical clock ────────────────────────────────────────────
(id-expiry-test
"the clock starts at zero and advances"
(ide-ev
"R = identity_tokens:start(),\n identity_tokens:advance(R, 7),\n identity_tokens:advance(R, 35),\n identity_tokens:now(R)")
42)
;; ── expiry composes with revocation ──────────────────────────────
(id-expiry-test
"an expired token is also inactive after revoke (no contradiction)"
(idenm
(ide-ev
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 200),\n identity_tokens:revoke(R, T),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"inactive")
(define
id-expiry-test-summary
(str "expiry " id-expiry-test-pass "/" id-expiry-test-count))

View File

@@ -1,97 +0,0 @@
;; identity/tests/facade.sx — the unified facade: one coordinator wiring
;; sessions+tokens, the audit ledger, and membership. Exercises the
;; cross-module integration (login/logout auditing, audit history, member
;; enrollment + projection) through the single `identity` door.
(define id-facade-test-count 0)
(define id-facade-test-pass 0)
(define id-facade-test-fails (list))
(define
id-facade-test
(fn
(name actual expected)
(set! id-facade-test-count (+ id-facade-test-count 1))
(if
(= actual expected)
(set! id-facade-test-pass (+ id-facade-test-pass 1))
(append! id-facade-test-fails {:name name :expected expected :actual actual}))))
(define idfc-ev erlang-eval-ast)
(define idfcnm (fn (v) (get v :name)))
(identity-load-all!)
;; ── login + logout are audited through the ledger ────────────────
(id-facade-test
"login then logout records login, issue, logout in order"
(idfcnm
(idfc-ev
"Svc = identity:start(),\n {ok, Sid, _Tok} = identity:login(Svc, alice, web, read),\n identity:logout(Svc, Sid),\n case identity:history(Svc, alice) of\n [login, issue, logout] -> ordered;\n Other -> Other\n end"))
"ordered")
(id-facade-test
"revoking a token is audited"
(idfcnm
(idfc-ev
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n identity:revoke(Svc, Tok),\n case identity:history(Svc, alice) of\n [login, issue, revoke] -> ordered;\n Other -> Other\n end"))
"ordered")
(id-facade-test
"history is per-subject"
(idfc-ev
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, bob, cli, read),\n identity:login(Svc, alice, mobile, read),\n length(identity:history(Svc, alice))")
4)
;; ── membership through the facade ────────────────────────────────
(id-facade-test
"enroll makes the subject an active member"
(idfcnm
(idfc-ev
"Svc = identity:start(),\n identity:enroll(Svc, alice, supporter),\n case identity:member_status(Svc, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end"))
"active")
(id-facade-test
"enroll keeps the tier"
(idfcnm
(idfc-ev
"Svc = identity:start(),\n identity:enroll(Svc, alice, supporter),\n case identity:member_status(Svc, alice) of\n {ok, _, Tier} -> Tier\n end"))
"supporter")
(id-facade-test
"an enrolled member projects per-app"
(idfcnm
(idfc-ev
"Svc = identity:start(),\n identity:enroll(Svc, alice, basic),\n case identity:member_project(Svc, alice, market) of\n {member, _, App} -> App;\n {Tag, _} -> Tag\n end"))
"market")
(id-facade-test
"a non-member projects as non_member"
(idfcnm
(idfc-ev
"Svc = identity:start(),\n case identity:member_project(Svc, stranger, blog) of\n {member, _, _} -> member;\n {Tag, _} -> Tag\n end"))
"non_member")
;; ── the facade still proves identity ─────────────────────────────
(id-facade-test
"verify still returns the subject after login"
(idfcnm
(idfc-ev
"Svc = identity:start(),\n {ok, _Sid, Tok} = identity:login(Svc, alice, web, read),\n case identity:verify(Svc, Tok) of\n {active, Subject, _, _} -> Subject;\n {inactive} -> inactive\n end"))
"alice")
;; ── identity and membership are distinct axes ────────────────────
(id-facade-test
"logging in does not enroll membership"
(idfcnm
(idfc-ev
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n case identity:member_status(Svc, alice) of\n {ok, St, _} -> St;\n {none} -> none\n end"))
"none")
(define
id-facade-test-summary
(str "facade " id-facade-test-pass "/" id-facade-test-count))

View File

@@ -1,115 +0,0 @@
;; identity/tests/federation.sx — federated identity: trust-gated,
;; advisory peer assertions + cross-instance subject mapping.
(define id-fed-test-count 0)
(define id-fed-test-pass 0)
(define id-fed-test-fails (list))
(define
id-fed-test
(fn
(name actual expected)
(set! id-fed-test-count (+ id-fed-test-count 1))
(if
(= actual expected)
(set! id-fed-test-pass (+ id-fed-test-pass 1))
(append! id-fed-test-fails {:name name :expected expected :actual actual}))))
(define idf-ev erlang-eval-ast)
(define idfnm (fn (v) (get v :name)))
(identity-load-federation!)
;; ── trust gating ─────────────────────────────────────────────────
(id-fed-test
"an assertion from an untrusted peer is rejected"
(idfnm
(idf-ev
"F = identity_federation:start(),\n case identity_federation:assert_id(F, peer1, alice) of\n {ok, _} -> accepted;\n {error, Why} -> Why\n end"))
"untrusted")
(id-fed-test
"a trusted peer's assertion is accepted"
(idfnm
(idf-ev
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n case identity_federation:assert_id(F, peer1, alice) of\n {ok, _} -> accepted;\n {error, Why} -> Why\n end"))
"accepted")
(id-fed-test
"untrust closes the door to future assertions"
(idfnm
(idf-ev
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n identity_federation:untrust(F, peer1),\n case identity_federation:assert_id(F, peer1, alice) of\n {ok, _} -> accepted;\n {error, Why} -> Why\n end"))
"untrusted")
(id-fed-test
"trusted? is true for a trusted peer"
(idfnm
(idf-ev
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n case identity_federation:trusted(F, peer1) of\n true -> yes;\n false -> no\n end"))
"yes")
(id-fed-test
"trusted? is false for an unknown peer"
(idfnm
(idf-ev
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n case identity_federation:trusted(F, peer2) of\n true -> yes;\n false -> no\n end"))
"no")
;; ── advisory provenance ──────────────────────────────────────────
(id-fed-test
"an asserted identity is flagged peer_asserted with its origin"
(idfnm
(idf-ev
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n {ok, L} = identity_federation:assert_id(F, peer1, alice),\n case identity_federation:provenance(F, L) of\n {peer_asserted, P} -> P;\n {local} -> local\n end"))
"peer1")
(id-fed-test
"a non-federated subject has local provenance"
(idfnm
(idf-ev
"F = identity_federation:start(),\n case identity_federation:provenance(F, alice) of\n {peer_asserted, _} -> peer_asserted;\n {local} -> local\n end"))
"local")
;; ── cross-instance subject mapping ───────────────────────────────
(id-fed-test
"remote subjects are namespaced by peer by default"
(idfnm
(idf-ev
"F = identity_federation:start(),\n case identity_federation:resolve(F, peer1, alice) of\n {ok, {federated, _, Remote}} -> Remote;\n _ -> other\n end"))
"alice")
(id-fed-test
"the same remote name from two peers maps to distinct subjects"
(idfnm
(idf-ev
"F = identity_federation:start(),\n {ok, L1} = identity_federation:resolve(F, peer1, alice),\n {ok, L2} = identity_federation:resolve(F, peer2, alice),\n case L1 =:= L2 of\n true -> collision;\n false -> distinct\n end"))
"distinct")
(id-fed-test
"an explicit map aliases a remote subject to a local one"
(idfnm
(idf-ev
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n identity_federation:map(F, peer1, alice, alice_local),\n case identity_federation:assert_id(F, peer1, alice) of\n {ok, alice_local} -> mapped;\n {ok, _} -> unmapped;\n {error, W} -> W\n end"))
"mapped")
(id-fed-test
"a mapped subject keeps peer_asserted provenance"
(idfnm
(idf-ev
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n identity_federation:map(F, peer1, alice, alice_local),\n identity_federation:assert_id(F, peer1, alice),\n case identity_federation:provenance(F, alice_local) of\n {peer_asserted, P} -> P;\n {local} -> local\n end"))
"peer1")
(id-fed-test
"two peers asserting same name keep separate provenance"
(idfnm
(idf-ev
"F = identity_federation:start(),\n identity_federation:trust(F, peer1),\n identity_federation:trust(F, peer2),\n {ok, L1} = identity_federation:assert_id(F, peer1, alice),\n {ok, _L2} = identity_federation:assert_id(F, peer2, alice),\n case identity_federation:provenance(F, L1) of\n {peer_asserted, P} -> P;\n {local} -> local\n end"))
"peer1")
(define
id-fed-test-summary
(str "federation " id-fed-test-pass "/" id-fed-test-count))

View File

@@ -1,96 +0,0 @@
;; identity/tests/grants.sx — the client-credentials grant (RFC 6749
;; §4.4): a confidential client authenticates and gets a token acting on
;; its own behalf — no end-user, no refresh token (§4.4.3). Public clients
;; cannot use it.
(define id-grants-test-count 0)
(define id-grants-test-pass 0)
(define id-grants-test-fails (list))
(define
id-grants-test
(fn
(name actual expected)
(set! id-grants-test-count (+ id-grants-test-count 1))
(if
(= actual expected)
(set! id-grants-test-pass (+ id-grants-test-pass 1))
(append! id-grants-test-fails {:name name :expected expected :actual actual}))))
(define idg-ev erlang-eval-ast)
(define idgnm (fn (v) (get v :name)))
(identity-load-oauth!)
;; ── confidential client-credentials happy path ───────────────────
(id-grants-test
"a confidential client obtains a working token"
(idgnm
(idg-ev
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, svc, sk, batch),\n case identity_oauth:introspect(O, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"active")
(id-grants-test
"the client-credentials token's subject is the client itself"
(idgnm
(idg-ev
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, svc, sk, batch),\n case identity_oauth:introspect(O, T) of\n {active, Subject, _, _} -> Subject\n end"))
"svc")
(id-grants-test
"the client-credentials token carries the requested scope"
(idgnm
(idg-ev
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, svc, sk, reports),\n case identity_oauth:introspect(O, T) of\n {active, _, _, Scope} -> Scope\n end"))
"reports")
(id-grants-test
"client-credentials issues no refresh token (single value)"
(idgnm
(idg-ev
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n case identity_oauth:client_credentials(O, svc, sk, batch) of\n {ok, _, _} -> pair;\n {ok, _} -> single;\n {error, W} -> W\n end"))
"single")
;; ── authentication failures ──────────────────────────────────────
(id-grants-test
"a wrong client secret is invalid_client"
(idgnm
(idg-ev
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n case identity_oauth:client_credentials(O, svc, wrong, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end"))
"invalid_client")
(id-grants-test
"a public client cannot use client-credentials"
(idgnm
(idg-ev
"O = identity_oauth:start(),\n identity_oauth:register_client(O, spa, public, none, [uri1]),\n case identity_oauth:client_credentials(O, spa, none, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end"))
"unauthorized_client")
(id-grants-test
"an unregistered client cannot use client-credentials"
(idgnm
(idg-ev
"O = identity_oauth:start(),\n case identity_oauth:client_credentials(O, ghost, x, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end"))
"invalid_client")
;; ── independence + real revocation for client tokens ─────────────
(id-grants-test
"two confidential clients get independent tokens"
(idgnm
(idg-ev
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc1, confidential, k1, [uri1]),\n identity_oauth:register_client(O, svc2, confidential, k2, [uri1]),\n {ok, _T1} = identity_oauth:client_credentials(O, svc1, k1, batch),\n {ok, T2} = identity_oauth:client_credentials(O, svc2, k2, batch),\n case identity_oauth:introspect(O, T2) of\n {active, Subject, _, _} -> Subject\n end"))
"svc2")
(id-grants-test
"a client-credentials token can be revoked"
(idgnm
(idg-ev
"O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, svc, sk, batch),\n identity_oauth:revoke(O, T),\n case identity_oauth:introspect(O, T) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
"inactive")
(define
id-grants-test-summary
(str "grants " id-grants-test-pass "/" id-grants-test-count))

View File

@@ -1,93 +0,0 @@
;; identity/tests/introspect.sx — RFC 7662 §2.2 full introspection metadata
;; (sub, client_id, scope, exp, iat, token_type) alongside the live-lookup
;; active/inactive semantics.
(define id-intr-test-count 0)
(define id-intr-test-pass 0)
(define id-intr-test-fails (list))
(define
id-intr-test
(fn
(name actual expected)
(set! id-intr-test-count (+ id-intr-test-count 1))
(if
(= actual expected)
(set! id-intr-test-pass (+ id-intr-test-pass 1))
(append! id-intr-test-fails {:name name :expected expected :actual actual}))))
(define idi-ev erlang-eval-ast)
(define idinm (fn (v) (get v :name)))
(identity-load-token!)
;; ── metadata fields ──────────────────────────────────────────────
(id-intr-test
"introspect_full reports token_type bearer"
(idinm
(idi-ev
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, _, _, _, _, _, Tt} -> Tt;\n {inactive} -> inactive\n end"))
"bearer")
(id-intr-test
"introspect_full reports the subject"
(idinm
(idi-ev
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, Sub, _, _, _, _, _} -> Sub\n end"))
"alice")
(id-intr-test
"introspect_full reports the client_id"
(idinm
(idi-ev
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, mobile, read, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, _, Cl, _, _, _, _} -> Cl\n end"))
"mobile")
(id-intr-test
"introspect_full reports the scope"
(idinm
(idi-ev
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, write, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, _, _, Sc, _, _, _} -> Sc\n end"))
"write")
;; ── exp / iat reflect the logical clock ──────────────────────────
(id-intr-test
"iat is the clock value at issue"
(idi-ev
"R = identity_tokens:start(),\n identity_tokens:advance(R, 7),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, _, _, _, _, Iat, _} -> Iat\n end")
7)
(id-intr-test
"exp is iat plus the ttl"
(idi-ev
"R = identity_tokens:start(),\n identity_tokens:advance(R, 7),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, _, _, _, Exp, Iat, _} -> Exp - Iat\n end")
100)
;; ── inactive / expired / revoked ─────────────────────────────────
(id-intr-test
"an expired token introspects inactive in full mode too"
(idinm
(idi-ev
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 100),\n case identity_tokens:introspect_full(R, T) of\n {active, _, _, _, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"inactive")
(id-intr-test
"a revoked token introspects inactive in full mode"
(idinm
(idi-ev
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read),\n identity_tokens:revoke(R, T),\n case identity_tokens:introspect_full(R, T) of\n {active, _, _, _, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"inactive")
(id-intr-test
"an unknown token introspects inactive in full mode"
(idinm
(idi-ev
"R = identity_tokens:start(),\n Bogus = make_ref(),\n case identity_tokens:introspect_full(R, Bogus) of\n {active, _, _, _, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"inactive")
(define
id-intr-test-summary
(str "introspect " id-intr-test-pass "/" id-intr-test-count))

View File

@@ -1,155 +0,0 @@
;; 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

@@ -1,192 +0,0 @@
;; identity/tests/oauth.sx — OAuth2 authorization-code flow (RFC 6749
;; §4.1) + PKCE (RFC 7636) + refresh grant (§6). Covers the full happy
;; path end-to-end (code exchange → access+refresh → refresh rotation) and
;; every rejection: denied consent, single-use codes, client/redirect
;; binding, PKCE mismatch, unknown code/request, refresh-token reuse, and
;; revoke-then-use (which must fail).
(define id-oauth-test-count 0)
(define id-oauth-test-pass 0)
(define id-oauth-test-fails (list))
(define
id-oauth-test
(fn
(name actual expected)
(set! id-oauth-test-count (+ id-oauth-test-count 1))
(if
(= actual expected)
(set! id-oauth-test-pass (+ id-oauth-test-pass 1))
(append! id-oauth-test-fails {:name name :expected expected :actual actual}))))
(define ido-ev erlang-eval-ast)
(define idonm (fn (v) (get v :name)))
(identity-load-token!)
(identity-load-oauth!)
;; Shared prelude: authorize + consent(allow) leaving Code bound.
(define
ido-granted
"O = identity_oauth:start(),\n {consent_required, ReqId} =\n identity_oauth:authorize(O, webapp, uri1, read, alice, verif1),\n {code, Code} = identity_oauth:consent(O, ReqId, allow)")
;; ── full happy path ──────────────────────────────────────────────
(id-oauth-test
"authorize asks for consent"
(idonm
(ido-ev
"O = identity_oauth:start(),\n case identity_oauth:authorize(O, webapp, uri1, read, alice, verif1) of\n {consent_required, _} -> consent_required;\n Other -> Other\n end"))
"consent_required")
(id-oauth-test
"consent(allow) returns a code"
(idonm (ido-ev (str ido-granted ", case Code of _ -> issued end")))
"issued")
(id-oauth-test
"exchanged access token introspects active"
(idonm
(ido-ev
(str
ido-granted
", {ok, Tok, _R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n case identity_oauth:introspect(O, Tok) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")))
"active")
(id-oauth-test
"exchanged token carries the authorized subject"
(idonm
(ido-ev
(str
ido-granted
", {ok, Tok, _R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n case identity_oauth:introspect(O, Tok) of\n {active, Subject, _, _} -> Subject\n end")))
"alice")
(id-oauth-test
"exchanged token carries the authorized scope"
(idonm
(ido-ev
(str
ido-granted
", {ok, Tok, _R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n case identity_oauth:introspect(O, Tok) of\n {active, _, _, Scope} -> Scope\n end")))
"read")
;; ── refresh grant (RFC 6749 §6) end-to-end ───────────────────────
(id-oauth-test
"refresh after exchange yields a working access token"
(idonm
(ido-ev
(str
ido-granted
", {ok, _A, R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n {ok, A2, _R2} = identity_oauth:refresh(O, R),\n case identity_oauth:introspect(O, A2) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")))
"active")
(id-oauth-test
"reusing a rotated refresh token is invalid_grant"
(idonm
(ido-ev
(str
ido-granted
", {ok, _A, R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n {ok, _A2, _R2} = identity_oauth:refresh(O, R),\n case identity_oauth:refresh(O, R) of\n {ok, _, _} -> rotated;\n {error, Why} -> Why\n end")))
"invalid_grant")
;; ── consent denied (§4.1.2.1) ────────────────────────────────────
(id-oauth-test
"denied consent yields access_denied"
(idonm
(ido-ev
"O = identity_oauth:start(),\n {consent_required, ReqId} =\n identity_oauth:authorize(O, webapp, uri1, read, alice, verif1),\n case identity_oauth:consent(O, ReqId, deny) of\n {error, Why} -> Why;\n {code, _} -> issued\n end"))
"access_denied")
;; ── single-use codes (§10.5) ─────────────────────────────────────
(id-oauth-test
"code cannot be exchanged twice"
(idonm
(ido-ev
(str
ido-granted
", identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n case identity_oauth:exchange(O, Code, webapp, uri1, verif1) of\n {ok, _, _} -> replayed;\n {error, Why} -> Why\n end")))
"invalid_grant")
;; ── code binding to client + redirect_uri (§4.1.3) ───────────────
(id-oauth-test
"exchange with wrong client is invalid_grant"
(idonm
(ido-ev
(str
ido-granted
", case identity_oauth:exchange(O, Code, attacker, uri1, verif1) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end")))
"invalid_grant")
(id-oauth-test
"exchange with wrong redirect_uri is invalid_grant"
(idonm
(ido-ev
(str
ido-granted
", case identity_oauth:exchange(O, Code, webapp, evil_uri, verif1) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end")))
"invalid_grant")
;; ── PKCE verifier mismatch (RFC 7636) ────────────────────────────
(id-oauth-test
"exchange with wrong PKCE verifier is invalid_grant"
(idonm
(ido-ev
(str
ido-granted
", case identity_oauth:exchange(O, Code, webapp, uri1, badverif) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end")))
"invalid_grant")
;; ── unknown code / request ───────────────────────────────────────
(id-oauth-test
"exchanging an unknown code is invalid_grant"
(idonm
(ido-ev
"O = identity_oauth:start(),\n Bogus = make_ref(),\n case identity_oauth:exchange(O, Bogus, webapp, uri1, verif1) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end"))
"invalid_grant")
(id-oauth-test
"consent on an unknown request is unknown_request"
(idonm
(ido-ev
"O = identity_oauth:start(),\n Bogus = make_ref(),\n case identity_oauth:consent(O, Bogus, allow) of\n {code, _} -> issued;\n {error, Why} -> Why\n end"))
"unknown_request")
;; ── revoke-then-use must fail (RFC 7009) ─────────────────────────
(id-oauth-test
"revoked exchanged token introspects inactive"
(idonm
(ido-ev
(str
ido-granted
", {ok, Tok, _R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n identity_oauth:revoke(O, Tok),\n case identity_oauth:introspect(O, Tok) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end")))
"inactive")
(id-oauth-test
"revoking the access token blocks a later refresh (cascade)"
(idonm
(ido-ev
(str
ido-granted
", {ok, A, R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n identity_oauth:revoke(O, A),\n case identity_oauth:refresh(O, R) of\n {ok, _, _} -> refreshed;\n {error, Why} -> Why\n end")))
"invalid_grant")
;; ── independence: two concurrent authorizations don't collide ────
(id-oauth-test
"two authorizations issue independent grants"
(idonm
(ido-ev
"O = identity_oauth:start(),\n {consent_required, R1} =\n identity_oauth:authorize(O, webapp, uri1, read, alice, va),\n {consent_required, R2} =\n identity_oauth:authorize(O, cli, uri2, write, bob, vb),\n {code, C1} = identity_oauth:consent(O, R1, allow),\n {code, C2} = identity_oauth:consent(O, R2, allow),\n {ok, _A1, _RR1} = identity_oauth:exchange(O, C1, webapp, uri1, va),\n {ok, A2, _RR2} = identity_oauth:exchange(O, C2, cli, uri2, vb),\n case identity_oauth:introspect(O, A2) of\n {active, Subject, _, _} -> Subject\n end"))
"bob")
(define
id-oauth-test-summary
(str "oauth " id-oauth-test-pass "/" id-oauth-test-count))

View File

@@ -1,84 +0,0 @@
;; identity/tests/par.sx — pushed authorization requests (PAR, RFC 9126):
;; lodge the authorization params up front under a single-use request_uri,
;; then redeem it into the normal consent flow. The binding (client,
;; redirect, PKCE) carried by the pushed request is enforced at exchange.
(define id-par-test-count 0)
(define id-par-test-pass 0)
(define id-par-test-fails (list))
(define
id-par-test
(fn
(name actual expected)
(set! id-par-test-count (+ id-par-test-count 1))
(if
(= actual expected)
(set! id-par-test-pass (+ id-par-test-pass 1))
(append! id-par-test-fails {:name name :expected expected :actual actual}))))
(define idp-ev erlang-eval-ast)
(define idpnm (fn (v) (get v :name)))
(identity-load-oauth!)
;; ── pushed request redeems into consent ──────────────────────────
(id-par-test
"authorize_pushed on a fresh request_uri asks for consent"
(idpnm
(idp-ev
"O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n case identity_oauth:authorize_pushed(O, Ru) of\n {consent_required, _} -> consent_required;\n {error, W} -> W\n end"))
"consent_required")
;; ── full PAR flow ────────────────────────────────────────────────
(id-par-test
"the full PAR flow yields a working token"
(idpnm
(idp-ev
"O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n {consent_required, Rq} = identity_oauth:authorize_pushed(O, Ru),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, v),\n case identity_oauth:introspect(O, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"active")
(id-par-test
"the PAR token carries the pushed subject"
(idpnm
(idp-ev
"O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n {consent_required, Rq} = identity_oauth:authorize_pushed(O, Ru),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, v),\n case identity_oauth:introspect(O, A) of\n {active, Subject, _, _} -> Subject\n end"))
"alice")
;; ── request_uri is single-use ────────────────────────────────────
(id-par-test
"a request_uri cannot be redeemed twice"
(idpnm
(idp-ev
"O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n identity_oauth:authorize_pushed(O, Ru),\n case identity_oauth:authorize_pushed(O, Ru) of\n {consent_required, _} -> reused;\n {error, W} -> W\n end"))
"invalid_request_uri")
(id-par-test
"an unknown request_uri is rejected"
(idpnm
(idp-ev
"O = identity_oauth:start(),\n Bogus = make_ref(),\n case identity_oauth:authorize_pushed(O, Bogus) of\n {consent_required, _} -> ok;\n {error, W} -> W\n end"))
"invalid_request_uri")
;; ── the pushed binding is still enforced at exchange ─────────────
(id-par-test
"a PAR-issued code still enforces PKCE"
(idpnm
(idp-ev
"O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n {consent_required, Rq} = identity_oauth:authorize_pushed(O, Ru),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n case identity_oauth:exchange(O, Cd, web, uri1, wrongverif) of\n {ok, _, _} -> ok;\n {error, W} -> W\n end"))
"invalid_grant")
(id-par-test
"a PAR-issued code still enforces client binding"
(idpnm
(idp-ev
"O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n {consent_required, Rq} = identity_oauth:authorize_pushed(O, Ru),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n case identity_oauth:exchange(O, Cd, attacker, uri1, v) of\n {ok, _, _} -> ok;\n {error, W} -> W\n end"))
"invalid_grant")
(define
id-par-test-summary
(str "par " id-par-test-pass "/" id-par-test-count))

View File

@@ -1,99 +0,0 @@
;; identity/tests/registry.sx — routing by id and by (subject, client),
;; SSO fan-out (one subject, many clients), and integration with live
;; session processes routed through the registry.
(define id-registry-test-count 0)
(define id-registry-test-pass 0)
(define id-registry-test-fails (list))
(define
id-registry-test
(fn
(name actual expected)
(set! id-registry-test-count (+ id-registry-test-count 1))
(if
(= actual expected)
(set! id-registry-test-pass (+ id-registry-test-pass 1))
(append! id-registry-test-fails {:name name :expected expected :actual actual}))))
(define idr-ev erlang-eval-ast)
(define idrnm (fn (v) (get v :name)))
(identity-load-session!)
(identity-load-registry!)
;; ── whereis by session id ────────────────────────────────────────
(id-registry-test
"registered session is found by id"
(idrnm
(idr-ev
"Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n case identity_registry:whereis_session(Reg, s1) of\n {ok, _} -> found;\n {error, _} -> missing\n end"))
"found")
(id-registry-test
"unknown session id is not_found, not a crash"
(idrnm
(idr-ev
"Reg = identity_registry:start(),\n case identity_registry:whereis_session(Reg, nope) of\n {ok, _} -> found;\n {error, Why} -> Why\n end"))
"not_found")
;; ── lookup by (subject, client) — the SSO probe ──────────────────
(id-registry-test
"lookup finds a session for subject+client"
(idrnm
(idr-ev
"Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n case identity_registry:lookup(Reg, alice, web) of\n {ok, _} -> found;\n {error, _} -> missing\n end"))
"found")
(id-registry-test
"lookup is precise: right subject, wrong client misses"
(idrnm
(idr-ev
"Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n case identity_registry:lookup(Reg, alice, cli) of\n {ok, _} -> found;\n {error, _} -> missing\n end"))
"missing")
;; ── SSO fan-out: one subject, many clients ───────────────────────
(id-registry-test
"sessions_for returns all of a subject's sessions"
(idr-ev
"Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n identity_registry:register(Reg, s2, alice, cli, Me),\n identity_registry:register(Reg, s3, bob, web, Me),\n case identity_registry:sessions_for(Reg, alice) of\n {ok, L} -> length(L)\n end")
2)
(id-registry-test
"sessions_for an unknown subject is empty"
(idr-ev
"Reg = identity_registry:start(),\n case identity_registry:sessions_for(Reg, ghost) of\n {ok, L} -> length(L)\n end")
0)
;; ── re-register replaces the row for that id (no duplicates) ──────
(id-registry-test
"re-registering an id does not duplicate it"
(idr-ev
"Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n identity_registry:register(Reg, s1, alice, web, Me),\n case identity_registry:sessions_for(Reg, alice) of\n {ok, L} -> length(L)\n end")
1)
;; ── deregister removes routing ───────────────────────────────────
(id-registry-test
"deregistered session is no longer found"
(idrnm
(idr-ev
"Me = self(),\n Reg = identity_registry:start(),\n identity_registry:register(Reg, s1, alice, web, Me),\n identity_registry:deregister(Reg, s1),\n case identity_registry:whereis_session(Reg, s1) of\n {ok, _} -> found;\n {error, _} -> missing\n end"))
"missing")
;; ── integration: route to a live session and look it up ──────────
(id-registry-test
"routed-to session answers lookup as active"
(idrnm
(idr-ev
"Me = self(),\n Reg = identity_registry:start(),\n S = identity_session:start(s1, alice, web, Me, infinity),\n identity_registry:register(Reg, s1, alice, web, S),\n {ok, Pid} = identity_registry:lookup(Reg, alice, web),\n case identity_session:lookup(Pid) of\n {ok, {_,_,_,St}} -> St;\n {error, St} -> St\n end"))
"active")
(define
id-registry-test-summary
(str "registry " id-registry-test-pass "/" id-registry-test-count))

View File

@@ -1,118 +0,0 @@
;; 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))

View File

@@ -1,81 +0,0 @@
;; identity/tests/session_mgmt.sx — subject-wide session management:
;; enumerate a subject's sessions and \"log out everywhere\".
(define id-smgmt-test-count 0)
(define id-smgmt-test-pass 0)
(define id-smgmt-test-fails (list))
(define
id-smgmt-test
(fn
(name actual expected)
(set! id-smgmt-test-count (+ id-smgmt-test-count 1))
(if
(= actual expected)
(set! id-smgmt-test-pass (+ id-smgmt-test-pass 1))
(append! id-smgmt-test-fails {:name name :expected expected :actual actual}))))
(define idsm-ev erlang-eval-ast)
(define idsmnm (fn (v) (get v :name)))
(identity-load-all!)
;; ── enumerate a subject's sessions ───────────────────────────────
(id-smgmt-test
"sessions lists all of a subject's sessions"
(idsm-ev
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, cli, read),\n length(identity:sessions(Svc, alice))")
2)
(id-smgmt-test
"sessions is empty for a subject with none"
(idsm-ev
"Svc = identity:start(),\n length(identity:sessions(Svc, stranger))")
0)
;; ── log out everywhere ───────────────────────────────────────────
(id-smgmt-test
"logout_all ends every session of the subject"
(idsmnm
(idsm-ev
"Svc = identity:start(),\n {ok, S1, _} = identity:login(Svc, alice, web, read),\n {ok, S2, _} = identity:login(Svc, alice, cli, read),\n identity:logout_all(Svc, alice),\n case {identity:session_status(Svc, S1), identity:session_status(Svc, S2)} of\n {gone, gone} -> both_gone;\n _ -> some_left\n end"))
"both_gone")
(id-smgmt-test
"after logout_all the subject has no sessions"
(idsm-ev
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, cli, read),\n identity:logout_all(Svc, alice),\n length(identity:sessions(Svc, alice))")
0)
(id-smgmt-test
"logout_all leaves other subjects' sessions intact"
(idsm-ev
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, bob, web, read),\n identity:logout_all(Svc, alice),\n length(identity:sessions(Svc, bob))")
1)
(id-smgmt-test
"logout_all on an unknown subject is ok, not a crash"
(idsmnm
(idsm-ev "Svc = identity:start(),\n identity:logout_all(Svc, ghost)"))
"ok")
;; ── logout_all is audited ────────────────────────────────────────
(id-smgmt-test
"logout_all records a logout event"
(idsmnm
(idsm-ev
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:logout_all(Svc, alice),\n case identity:history(Svc, alice) of\n [login, issue, logout] -> audited;\n Other -> Other\n end"))
"audited")
(id-smgmt-test
"logout_all audits each of several sessions"
(idsm-ev
"Svc = identity:start(),\n identity:login(Svc, alice, web, read),\n identity:login(Svc, alice, cli, read),\n identity:logout_all(Svc, alice),\n length(identity:history(Svc, alice))")
6)
(define
id-smgmt-test-summary
(str "session-mgmt " id-smgmt-test-pass "/" id-smgmt-test-count))

View File

@@ -1,115 +0,0 @@
;; identity/tests/sso.sx — silent SSO (prompt=none, OIDC §3.1.2.1) as a
;; fast-path through the authorization-code machine. One subject session,
;; many client apps; no session → login_required (a negative state, not a
;; redirect). Silently-issued codes carry the same client/redirect/PKCE
;; binding as consented codes.
(define id-sso-test-count 0)
(define id-sso-test-pass 0)
(define id-sso-test-fails (list))
(define
id-sso-test
(fn
(name actual expected)
(set! id-sso-test-count (+ id-sso-test-count 1))
(if
(= actual expected)
(set! id-sso-test-pass (+ id-sso-test-pass 1))
(append! id-sso-test-fails {:name name :expected expected :actual actual}))))
(define ids-ev erlang-eval-ast)
(define idsnm (fn (v) (get v :name)))
(identity-load-token!)
(identity-load-session!)
(identity-load-registry!)
(identity-load-oauth!)
;; ── no session → login_required ──────────────────────────────────
(id-sso-test
"silent authorize without a session is login_required"
(idsnm
(ids-ev
"O = identity_oauth:start(),\n case identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end"))
"login_required")
;; ── established session → silent code ────────────────────────────
(id-sso-test
"silent authorize for the same client returns a code"
(idsnm
(ids-ev
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n case identity_oauth:silent_authorize(O, web, uri1, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end"))
"got_code")
;; ── one session, many clients ────────────────────────────────────
(id-sso-test
"a different client gets a silent code off the same session"
(idsnm
(ids-ev
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n case identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end"))
"got_code")
(id-sso-test
"many clients all silently authorize off one session"
(idsnm
(ids-ev
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, _C1} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n {code, _C2} = identity_oauth:silent_authorize(O, mobile, uri3, read, alice, vv),\n case identity_oauth:silent_authorize(O, billing, uri4, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end"))
"got_code")
;; ── full SSO → token ─────────────────────────────────────────────
(id-sso-test
"silent code exchanges to a working token"
(idsnm
(ids-ev
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, C} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n {ok, A, _R} = identity_oauth:exchange(O, C, dashboard, uri2, vv),\n case identity_oauth:introspect(O, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"active")
(id-sso-test
"SSO token carries the subject"
(idsnm
(ids-ev
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, C} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n {ok, A, _R} = identity_oauth:exchange(O, C, dashboard, uri2, vv),\n case identity_oauth:introspect(O, A) of\n {active, Subject, _, _} -> Subject\n end"))
"alice")
;; ── silent codes keep the full binding ───────────────────────────
(id-sso-test
"silent code still enforces PKCE at exchange"
(idsnm
(ids-ev
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, C} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n case identity_oauth:exchange(O, C, dashboard, uri2, wrongverif) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end"))
"invalid_grant")
(id-sso-test
"silent code still enforces client binding at exchange"
(idsnm
(ids-ev
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, C} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n case identity_oauth:exchange(O, C, attacker, uri2, vv) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end"))
"invalid_grant")
;; ── subject scoping: SSO is per subject ──────────────────────────
(id-sso-test
"another subject is still login_required"
(idsnm
(ids-ev
"O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n case identity_oauth:silent_authorize(O, dashboard, uri2, read, bob, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end"))
"login_required")
;; ── ending the session closes the SSO fast-path ──────────────────
(id-sso-test
"after end_session, silent authorize is login_required"
(idsnm
(ids-ev
"O = identity_oauth:start(),\n {ok, Sid} = identity_oauth:establish(O, alice, web),\n identity_oauth:end_session(O, Sid),\n case identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end"))
"login_required")
(define
id-sso-test-summary
(str "sso " id-sso-test-pass "/" id-sso-test-count))

View File

@@ -1,215 +0,0 @@
;; identity/tests/token.sx — opaque tokens, grant-backed lookup, real
;; revocation, refresh-token rotation, cascading revocation, and scope
;; narrowing on refresh. The revoke-then-introspect and refresh-reuse
;; paths are the security centrepieces.
(define id-token-test-count 0)
(define id-token-test-pass 0)
(define id-token-test-fails (list))
(define
id-token-test
(fn
(name actual expected)
(set! id-token-test-count (+ id-token-test-count 1))
(if
(= actual expected)
(set! id-token-test-pass (+ id-token-test-pass 1))
(append! id-token-test-fails {:name name :expected expected :actual actual}))))
(define idt-ev erlang-eval-ast)
(define idtnm (fn (v) (get v :name)))
(identity-load-token!)
;; ── issue + introspect (happy path) ──────────────────────────────
(id-token-test
"fresh token introspects active"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, read),\n case identity_tokens:introspect(Reg, Tok) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"active")
(id-token-test
"introspect returns the granted subject"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, read),\n case identity_tokens:introspect(Reg, Tok) of\n {active, Subject, _, _} -> Subject\n end"))
"alice")
(id-token-test
"introspect returns the granted scope"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, write),\n case identity_tokens:introspect(Reg, Tok) of\n {active, _, _, Scope} -> Scope\n end"))
"write")
;; ── opacity: distinct tokens, no cross-talk ──────────────────────
(id-token-test
"two issues yield independent grants"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, A} = identity_tokens:issue(Reg, alice, web, read),\n {ok, B} = identity_tokens:issue(Reg, bob, cli, write),\n identity_tokens:revoke(Reg, A),\n case identity_tokens:introspect(Reg, B) of\n {active, Subject, _, _} -> Subject;\n {inactive} -> inactive\n end"))
"bob")
;; ── revocation is real (RFC 7009) ────────────────────────────────
(id-token-test
"revoked token introspects inactive immediately"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, read),\n active = case identity_tokens:introspect(Reg, Tok) of\n {active, _, _, _} -> active end,\n identity_tokens:revoke(Reg, Tok),\n case identity_tokens:introspect(Reg, Tok) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
"inactive")
(id-token-test
"revoke is idempotent"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, read),\n identity_tokens:revoke(Reg, Tok),\n identity_tokens:revoke(Reg, Tok)"))
"ok")
;; ── unknown tokens are inactive, never an error/crash ────────────
(id-token-test
"introspecting an unknown token is inactive"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n Bogus = make_ref(),\n case identity_tokens:introspect(Reg, Bogus) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"inactive")
(id-token-test
"revoking an unknown token is ok, not a crash"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n Bogus = make_ref(),\n identity_tokens:revoke(Reg, Bogus)"))
"ok")
;; ── one revocation does not affect a sibling token ───────────────
(id-token-test
"revoking one token leaves the other active"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, A} = identity_tokens:issue(Reg, alice, web, read),\n {ok, B} = identity_tokens:issue(Reg, alice, cli, read),\n identity_tokens:revoke(Reg, A),\n case identity_tokens:introspect(Reg, B) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"active")
;; ── issue_grant: access + refresh pair (RFC 6749 §4.1.4 / §5.1) ───
(id-token-test
"issue_grant access token introspects active"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, A, _R} = identity_tokens:issue_grant(Reg, alice, web, read),\n case identity_tokens:introspect(Reg, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"active")
;; ── refresh rotation (RFC 6749 §6) ───────────────────────────────
(id-token-test
"refresh mints a working new access token"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"active")
(id-token-test
"rotated token keeps the grant's subject"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, Subject, _, _} -> Subject\n end"))
"alice")
(id-token-test
"refresh chains across rotations"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, _A2, R2} = identity_tokens:refresh(Reg, R),\n {ok, A3, _R3} = identity_tokens:refresh(Reg, R2),\n case identity_tokens:introspect(Reg, A3) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"active")
(id-token-test
"refreshing an unknown token is invalid_grant"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n Bogus = make_ref(),\n case identity_tokens:refresh(Reg, Bogus) of\n {ok, _, _} -> rotated;\n {error, Why} -> Why\n end"))
"invalid_grant")
;; ── refresh-token reuse = theft → revoke the family (RFC 6819) ────
(id-token-test
"reusing a superseded refresh token is invalid_grant"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, _A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:refresh(Reg, R) of\n {ok, _, _} -> rotated;\n {error, Why} -> Why\n end"))
"invalid_grant")
(id-token-test
"refresh reuse revokes the live descendant too"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
"inactive")
;; ── cascading revocation: revoke any token, the grant dies ───────
(id-token-test
"revoking the access token blocks refresh"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n identity_tokens:revoke(Reg, A),\n case identity_tokens:refresh(Reg, R) of\n {ok, _, _} -> refreshed;\n {error, Why} -> Why\n end"))
"invalid_grant")
(id-token-test
"revoking the refresh token deactivates the access token"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n identity_tokens:revoke(Reg, R),\n case identity_tokens:introspect(Reg, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
"inactive")
;; ── scope as a set + narrowing on refresh (RFC 6749 §6 / §3.3) ───
(id-token-test
"a list scope round-trips through introspect"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, A, _R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n case identity_tokens:introspect(Reg, A) of\n {active, _, _, [read, write]} -> matched;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end"))
"matched")
(id-token-test
"refresh can narrow the scope to a subset"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R, [read]),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, [read]} -> narrowed;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end"))
"narrowed")
(id-token-test
"refresh cannot widen scope beyond the grant"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read]),\n case identity_tokens:refresh(Reg, R, [read, write]) of\n {ok, _, _} -> widened;\n {error, Why} -> Why\n end"))
"invalid_scope")
(id-token-test
"an invalid_scope refresh does not consume the refresh token"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n identity_tokens:refresh(Reg, R, [admin]),\n case identity_tokens:refresh(Reg, R, [read]) of\n {ok, _, _} -> still_usable;\n {error, Why} -> Why\n end"))
"still_usable")
(id-token-test
"plain refresh keeps the full grant scope"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, [read, write]} -> full;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end"))
"full")
(id-token-test
"a narrowed token still cascades on revoke"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R, [read]),\n identity_tokens:revoke(Reg, A2),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
"inactive")
(define
id-token-test-summary
(str "token " id-token-test-pass "/" id-token-test-count))

File diff suppressed because one or more lines are too long

View File

@@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher
## Status (rolling) ## Status (rolling)
`bash lib/events/conformance.sh`**0/0** (not yet started) `bash lib/events/conformance.sh`**219/219** (Phases 1-4 + ext: federated free/busy)
## Ground rules ## Ground rules
@@ -54,28 +54,148 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ──
``` ```
## Phase 1 — Calendar + recurrence ## Phase 1 — Calendar + recurrence
- [ ] `calendar.sx` — event facts, RRULE expansion in a window - [x] `calendar.sx` — event facts, RRULE expansion in a window (DAILY/WEEKLY)
- [ ] `availability.sx` — free/busy rules - [x] `calendar.sx` — MONTHLY (bymonthday + nth-weekday byday)
- [ ] `api.sx` + tests + scoreboard + conformance.sh - [x] `availability.sx` — free/busy rules (busy/conflict/busy_in on Datalog)
- [x] `availability.sx` — next-free slot search (same rules, different bindings)
- [x] `api.sx` — public entry points (schedule/agenda/book/free/next-free/conflicts)
- [x] tests + scoreboard + conformance.sh (73/73)
## Phase 2 — Ticketing + booking ## Phase 2 — Ticketing + booking
- [ ] capacity rules; transactional booking → `persist` (no overbooking) - [x] capacity rules; transactional booking → `persist` (no overbooking)
- [ ] paid tickets compose with `commerce` order flow - [x] wire `booking.sx` into `api.sx` (persist-backed `ev/book-occ!` + derived availability)
- [ ] tests: capacity edge, double-book guard, conflict detection - [x] cancellation (tombstone events) + seat release
- [x] provisional holds (hold/confirm/release) — reserve a seat during pending payment
- [x] paid tickets compose with `commerce` order flow (contract module over holds)
- [x] tests: capacity edge, double-book guard, conflict detection
## Phase 3 — Notification delivery (flow) ## Phase 3 — Notification delivery (flow)
- [ ] `notify.sx` — reminder/digest flows over injected transport - [x] `notify.sx` — reminder/digest flows over injected transport
- [ ] retry/backoff on transport failure (flow suspend/resume) - [x] retry/backoff on transport failure (flow suspend/resume)
- [ ] tests: delivery success, retry path, idempotent re-send - [x] tests: delivery success, retry path, idempotent re-send
- [x] wire reminders to occurrences (`reminders.sx` — derive from agenda + roster)
- [ ] NOTE: shared with `feed/notify` — candidate for later extraction to a - [ ] NOTE: shared with `feed/notify` — candidate for later extraction to a
`delivery-on-sx` once a second consumer is real `delivery-on-sx` once a second consumer is real. **Delivery core
(request→dispatch→resume, idempotent, bounded retry) is the extraction seam.**
## Phase 4 — Federation ## Phase 4 — Federation
- [ ] cross-instance events (peer calendar) — trust-gated stub - [x] cross-instance events (peer calendar) — trust-gated stub
- [ ] tests: federated agenda merge - [x] tests: federated agenda merge
- [x] federated availability/free-busy across trusted peers
- [ ] real transport behind `ev/peer-agenda` (fed-sx / signed fetch)
## Progress log ## Progress log
(loop fills this in)
- 2026-06-07 — Federated free/busy (extension). Peers publish BUSY intervals
per actor (iCal free/busy model — privacy-preserving, not event details).
`ev/peer-with-busy`, `ev/peer-busy`; `ev/federated-busy` unions local
availability-db busy + trusted peers' published busy (sorted);
`ev/federated-free?` answers "is X free in [qs,qe)?" across instances,
half-open, trust-gated (untrusted peers' busy ignored; revocation immediate).
+10 tests, 219/219 green.
- 2026-06-07 — **Phase 4: federation (trust-gated stub).** `federation.sx`:
a peer publishes a schedule (events store); `ev/federated-agenda` merges the
local agenda (origin :local) with every TRUSTED peer's agenda, sorted by
start, each occurrence tagged with :origin provenance. Trust is a peer-id set
re-checked per merge (revocation is immediate); untrusted peers contribute
nothing. `ev/peer`, `ev/trusts?`, `ev/trusted-peers`, `ev/peer-agenda`
(expands the peer's recurrence in-window), `ev/from-origin` (filter by
source). Real transport slots behind `ev/peer-agenda` unchanged. +13 tests,
**209/209 green — all four plan phases implemented.**
- 2026-06-07 — Reminders + digests from the agenda. `reminders.sx` bridges
calendar + durable rosters to notify: `ev/occurrence-reminders` (one per
booked attendee, fires `lead` before start, idempotency key
occ-key/recipient/lead), `ev/agenda-reminders` (window-wide, sorted by
fire-at), `ev/due-reminders` (fire-at ≤ now — the scheduler query),
`ev/reminder->msg` (projects to notify's (id recipient body) shape),
`ev/agenda-digest` + `ev/agenda-for-p` (an actor's upcoming booked
occurrences). +14 tests, 196/196 green.
- 2026-06-07 — **Phase 3 start: notification delivery flows.** `notify.sx`:
reminders + digests as durable `flow`s over an INJECTED transport (the host
`dispatch`). A flow `request`s delivery (suspend), the host sends and resumes
with the outcome; flow's replay log means a completed send is never re-run on
recovery. At-least-once + idempotent: messages carry an id; the transport
dedups (re-send is a no-op that still reports ok) and replay logs each
outcome. Retry rides suspend/resume — each attempt uses a DISTINCT tag
`(deliver <id> <n>)` so replay stays correct; dispatch returns (ok) /
(retry reason), bounded by maxn → (failed id reason). Digest delivers a batch
with independent per-message outcomes. Authored as Scheme flow source run via
`ev/notify-run` (scheme + flow substrate preloaded). +7 tests, 182/182 green.
Delivery core is the `delivery-on-sx` extraction seam for feed/notify.
- 2026-06-07 — **Phase 2 complete: paid-ticket contract.** `ticket.sx` defines
the two wire messages between events and commerce — `checkout-request`
(events→commerce) and `payment-result` (commerce→events, :paid/:failed/
:expired) — so commerce imports the contract, not vice versa. Orchestration
over holds: `ev/request-ticket!` places a capacity-safe hold + emits a
checkout-request; `ev/settle-payment!` confirms on :paid, releases on
failure/expiry. Idempotent (redelivered :paid stays confirmed, redelivered
release is :noop); a late :paid for a vanished hold → :paid-but-no-hold
(refund signal), no phantom seat. occ-key+actor locate the hold so no side
table. +31 tests, 175/175 green. Phase 3 (notification flows) is next.
- 2026-06-07 — Provisional holds (paid-ticket foundation). Booking stream now
carries :booking/:hold/:confirm/:release/:cancel; the fold tracks per-actor
seat STATE (:held / :confirmed). A held seat counts toward capacity, so a
pending payment cannot be oversold. `ev/hold!` (capacity-safe, retrying),
`ev/confirm!` (held→confirmed), `ev/release!` (frees a held seat only),
`ev/seat-state`. Seat-acquiring writes (:booking/:hold) go through
append-expect; seat-freeing writes (:cancel/:release) and :confirm append
directly (never oversell). Holds race test mirrors the booking race. +24
tests, 144/144 green. Next: ticket.sx contract module over holds.
- 2026-06-07 — Wired `booking.sx` into `api.sx`: durable persist-backed booking
path alongside the in-memory one. `ev/book-occ!`, `ev/cancel-occ!`,
`ev/roster-occ`, `ev/seats-left-occ` (capacity from the scheduled event);
`ev/free-p?`, `ev/next-free-p`, `ev/conflicts-p` derive availability by
replaying persist booking streams for in-window occurrences. Capacity-safe +
cancellable bookings now flow through the public API. Reordered conformance
preloads (persist + booking before events/api). +10 tests, 120/120 green.
- 2026-06-07 — Cancellation + seat release. Booking stream now carries
:booking / :cancel events; the live roster is the FOLDED replay (book adds,
cancel removes) so capacity reopens when a seat is freed. `ev/cancel!`
(retrying, append-expect), `ev/cancel-with-observed`. Edge cases: cancelling
an unbooked actor → :not-booked (no-op), double cancel → :not-booked,
cancelled actor may re-book. Capacity count is the folded roster size, not
the physical event count. +13 tests, 110/110 green.
- 2026-06-07 — **Phase 2 start: capacity-safe booking.** `booking.sx`: one
append-only persist stream per occurrence; roster = stream replayed. Booking
decisions made against an OBSERVED (roster, last-seq) snapshot, committed via
`persist/append-expect` — append only if last-seq unchanged, else a conflict
value the booker retries. This makes capacity-check + append atomic at the
persist boundary: no overbooking, no lock. `ev/book!` (retrying),
`ev/book-with-observed`, `ev/roster`, `ev/seats-left`. Idempotent per actor
(:already). Explicit last-seat race test: two bookers on the same snapshot →
one :booked, one :conflict, roster never exceeds capacity; loser retry →
:full (or next seat when room remains). 24 tests, 97/97 green.
- 2026-06-07 — **Phase 1 complete.** `api.sx`: immutable `store`
({:events :bookings}) facade over calendar + availability. `ev/schedule`,
`ev/book`, `ev/agenda`, `ev/agenda-for`, `ev/free?`, `ev/next-free`,
`ev/conflicts`. Availability queries auto-widen the expansion window back by
the longest event so any overlapping occurrence is captured. 14 tests,
73/73 green. Phase 2 (transactional booking on persist) is next — `ev/book`
becomes capacity-safe via a persist append at that point.
- 2026-06-07 — `next-free` slot search: earliest start ≥ after where
[s,s+duration) is free and ends ≤ horizon, else nil. Candidates are `after`
plus each busy-interval end (interval-packing); each probe reuses the
`busy_in` Datalog rule via `ev-free?`. Finds gaps between bookings, skips
too-short gaps, half-open at edges. +6 tests, 59/59.
- 2026-06-07 — `availability.sx`: free/busy + conflict detection as forward-
chained Datalog over `occurrence`/`booking` EDB. Rules `busy(A,S,E)`,
`conflict(A,O1,O2)` (canonical `O1<O2`, half-open overlap `S1<E2 ∧ S2<E1`),
`busy_in(A,QS,QE)` for window queries. API: `ev-busy`, `ev-conflicts`,
`ev-has-conflict?`, `ev-free?` (transient qwindow assert/retract). Integrates
with calendar expansion (book expanded occurrences). 16 tests, 53/53 green.
- 2026-06-06 — MONTHLY recurrence. `ev-days-in-month`, `ev-add-months`,
BYMONTHDAY (incl. negative = from month end), ordinal BYDAY (`{:ord N :wd W}`,
ord<0 = nth-from-last), default day-of-month (skips months too short, e.g.
day-31 monthly skips Feb/Apr). Refactored weekly+monthly onto a shared
`ev-emit-occs` per-period emitter. 37/37 green (+13).
- 2026-06-06 — Phase 1 scaffold + calendar recurrence. `calendar.sx`: integer
epoch-minute datetimes, Hinnant civil<->day-number conversion, DAILY/WEEKLY
RRULE expansion in a bounded (start,end) window with INTERVAL, COUNT (window-
independent), UNTIL, BYDAY (weekly). `ev-expand-all` merges + sorts. Wired
conformance harness (conf + thin wrapper reusing `lib/guest/conformance.sh`),
scoreboard. 24/24 green. MONTHLY deferred to next commit.
## Blockers ## Blockers
(loop fills this in)
- None. Substrates present: `lib/datalog` (276/276), `lib/persist`, `lib/flow`
all exist — Phase 2/3 unblocked when reached.

View File

@@ -19,7 +19,7 @@ through the event log, all authorization questions delegated to `acl-on-sx`.
## Status (rolling) ## Status (rolling)
`bash lib/identity/conformance.sh`**229/229** (4 phases + 14 ext) — slow (~10min, run in background; internal timeout 1200) `bash lib/identity/conformance.sh`**0/0** (not yet started)
## Ground rules ## Ground rules
@@ -57,230 +57,28 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke)
``` ```
## Phase 1 — Sessions + tokens ## Phase 1 — Sessions + tokens
- [x] `session.sx` — session process, create/lookup/expire - [ ] `session.sx` — session process, create/lookup/expire
- [x] `token.sx` — issue/introspect/revoke (opaque, grant-backed) - [ ] `token.sx` — issue/introspect/revoke (opaque, grant-backed)
- [x] `registry.sx` — route by subject/client - [ ] `registry.sx` — route by subject/client
- [x] `api.sx` + tests + scoreboard + conformance.sh - [ ] `api.sx` + tests + scoreboard + conformance.sh
## Phase 2 — OAuth2 flows ## Phase 2 — OAuth2 flows
- [x] authorization-code flow as a message protocol - [ ] authorization-code flow as a message protocol
- [x] refresh + rotation; revocation cascades to issued tokens - [ ] refresh + rotation; revocation cascades to issued tokens
- [x] tests: full code exchange, refresh, revoke-then-use (must fail) - [ ] tests: full code exchange, refresh, revoke-then-use (must fail)
## Phase 3 — Silent SSO + membership ## Phase 3 — Silent SSO + membership
- [x] `prompt=none` cross-app login (one session, many clients) - [ ] `prompt=none` cross-app login (one session, many clients)
- [x] membership state + per-app grant projection - [ ] membership state + per-app grant projection
- [x] grant verification delegated cache (mirror Redis-cache pattern) - [ ] grant verification delegated cache (mirror Redis-cache pattern)
## Phase 4 — Audit + federation ## Phase 4 — Audit + federation
- [x] every issue/refresh/revoke is a `persist` event; `(identity/audit subject)` - [ ] every issue/refresh/revoke is a `persist` event; `(identity/audit subject)`
- [x] federated identity (peer-asserted subject) — advisory, trust-gated stub - [ ] federated identity (peer-asserted subject) — advisory, trust-gated stub
- [x] tests: audit completeness, cross-instance subject mapping - [ ] tests: audit completeness, cross-instance subject mapping
## Extensions (base roadmap complete; deepen the engine)
- [~] PKCE S256 method (RFC 7636 §4.2) — BLOCKED on erlang substrate (see Blockers)
- [x] access-token TTL / `expires_in` — logical-clock expiry, introspect honours it
- [x] scope as a set + scope narrowing on refresh (RFC 6749 §6)
- [x] client registry: public vs confidential clients, client authentication (RFC 6749 §2)
- [x] client-credentials grant (RFC 6749 §4.4) + device grant (RFC 8628)
- [x] acl-on-sx delegation: identity-gates-before-acl boundary (401 vs 403), stub decider (live Datalog bridge is cross-substrate)
- [~] OAuth `state`/OIDC `nonce` — low value in this server-centric model (client-side echo); skipped
- [x] pushed authorization requests (PAR, RFC 9126): single-use request_uri → consent
- [x] dynamic client registration (RFC 7591): server-generated client_id + secret
- [x] "apps with access": `grants_for(Subject)` / `identity:grants` (per-subject active grants)
- [x] unify `api.sx` over membership + audit (one facade, audited login/logout)
- [x] subject-wide session management: `sessions(Subject)` + `logout_all` (log out everywhere)
- [x] token exchange (RFC 8693): downscope a token into a new independent token
- [x] RFC 7662 full introspection metadata (`introspect_full`: sub/client_id/scope/exp/iat/token_type)
## Progress log ## Progress log
- 2026-06-07 — "apps with access" (ext): `identity_tokens:grants_for(Subject)` (loop fills this in)
lists a subject's ACTIVE grants as `[{Client, Scope}]` (revoked excluded),
exposed through the facade as `identity:grants(Subject)`. Completes the
per-subject account-security trio: sessions (where), grants (which apps),
history (what happened). New tests/account.sx (7). 222→229. NOTE: conformance
is now slow (~10 min, 22 suites); run it in the background — internal
sx_server timeout raised to 1200s. The suite is at its monolithic-runtime
ceiling; further test growth should consider splitting the harness.
- 2026-06-07 — dynamic client registration (ext, RFC 7591): `register_dynamic`
generates a client_id + secret server-side (make_ref each) and registers the
client, returning {ok, ClientId, Secret} — self-service onboarding distinct
from the manual register_client. A dynamic confidential client can then use
client_credentials; a dynamic public client stays unauthorized_client. New
tests/dynreg.sx (5). 217→222.
- 2026-06-07 — PAR (ext, RFC 9126): `push_authorization_request` lodges the
authorization params under a single-use `request_uri`; `authorize_pushed`
redeems it into the normal consent flow. Pushed requests reuse the pending
store (`{pushed, Rec}` keyed by the request_uri ref — distinct from consent
req_ids, no collision), so no new loop state. The pushed binding (client +
redirect + PKCE) is enforced at exchange. New tests/par.sx (7). 210→217.
- 2026-06-07 — full introspection (ext, RFC 7662 §2.2): `introspect_full`
returns {active, Subject, Client, Scope, Exp, Iat, bearer} for live tokens,
{inactive} otherwise — deepening the opaque-token/live-lookup model the
whole design rests on. Access tokens now carry `Iat` (clock-at-issue);
exp = iat + ttl. Simple `introspect` unchanged. New tests/introspect.sx (9).
201→210. NOTE: conformance now needs an explicit long timeout (>120s, 19
suites) — run with `timeout 580`.
- 2026-06-07 — token exchange (ext, RFC 8693 §2.1): `oauth.sx` gains
`token_exchange(SubjectToken, RequestedScope)` — a valid access token is
downscoped into a NEW independent grant for the same subject (subset only,
else invalid_scope; inactive subject token → invalid_grant). The new token's
lifecycle is independent (revoking either leaves the other active);
exchanges chain. Least-privilege handoff to downstream services. New
tests/exchange.sx (8). 193→201.
- 2026-06-07 — subject-wide session management (ext): `api.sx` gains
`sessions(Subject)` (enumerate) and `logout_all(Subject)` ("log out
everywhere") — revokes + deregisters every session a subject holds,
auditing a logout per session, leaving other subjects untouched. Builds on
registry.sessions_for. New tests/session_mgmt.sx (8). 185→193.
- 2026-06-07 — `delegation.sx` (ext): the identity→acl boundary made concrete.
`check` introspects the token first: inactive → `{error, unauthenticated}`
(401, acl never consulted); active → constructs {Subject, Scope, Action,
Resource} and hands off to acl, which returns permit/deny (`forbidden` =
403). 401 strictly precedes 403 (a revoked token with no scope is still
unauthenticated). acl-on-sx (Datalog) is a different SX guest language —
wired at the integration layer — so the decider here is a labelled stub
(permits when Action ∈ Scope); swap the pid, boundary unchanged. New
tests/delegation.sx (8). 177→185. **Extensions backlog clear.**
- 2026-06-07 — unified facade (ext): `api.sx` coordinator now owns an audit
ledger + a membership registry alongside its token table (started with the
ledger) and session registry. login/logout are audited; new ops
`history`/`enroll`/`member_status`/`member_project` expose the audit +
membership axes through the one `identity` door. identity proves who +
reports membership; acl still decides permission. Existing api behaviour
unchanged (10/10). New tests/facade.sx (9). 168→177.
- 2026-06-07 — `device.sx` (ext, RFC 8628): device authorization grant for
input-constrained devices. authorize → {device_code, user_code}; the human
approve/deny out-of-band by user_code; the device polls by device_code
through the §3.5 status machine (authorization_pending → access_denied /
{ok,Token}). Device code is single-use once a token issues; guarded
transitions (approve-after-deny rejected). Tokens grant-backed. Device-code
expiry + slow_down deferred (no wall clock). New tests/device.sx (10). 158→168.
- 2026-06-07 — client-credentials grant (ext, RFC 6749 §4.4): `oauth.sx` now
owns a client registry (loop/6); `register_client` + `client_credentials`.
A confidential client authenticates and gets a token acting on its own
behalf (subject = the client), no refresh token (§4.4.3). A public client is
`unauthorized_client`; any auth failure (unknown client OR wrong secret) is
`invalid_client` — no client-existence oracle (§5.2). `identity-load-oauth!`
now pulls its deps (token/session/registry/clients). New tests/grants.sx (9).
149→158.
- 2026-06-07 — `clients.sx` (ext): OAuth client registry (RFC 6749 §2). public
vs confidential clients; confidential clients MUST present the right secret
(wrong → invalid_client), public clients are identified but not
authenticated; redirect_uris are allow-listed with exact-match
`valid_redirect` (§3.1.2.2 + Security BCP). Standalone module (no oauth
wiring yet — that's a follow-up). New tests/clients.sx (11). 138→149.
- 2026-06-07 — access-token expiry (ext): logical clock in the token registry
(`advance`/`now`; no wall clock in substrate). Grants carry a Ttl; each
access token carries an Expires (Now-at-issue + Ttl, or infinity); introspect
returns inactive once `Now` reaches it. Refresh mints a fresh short-lived
access token (new Expires) — short access tokens, long refresh tokens. issue/4
+ issue_grant/4 default to infinity, so all prior tests unchanged. New
tests/expiry.sx (8). token loop/6. 130→138.
- 2026-06-07 — scope narrowing (ext): each access token now carries its own
EFFECTIVE scope (<= the grant's max). `refresh/3` requests a narrower scope;
the request must be a subset of the grant scope (RFC 6749 §6) else
`{error, invalid_scope}` and the refresh token is NOT consumed (client may
retry, §5.2). `refresh/2` keeps full scope; scope stays opaque (atom or list)
for issue, so all prior atom-scope tests pass unchanged. token 18→24, 130/130.
Also filed Blocker: PKCE S256 needs SHA256+binary compare, both broken in the
erlang substrate (binary `=:=` always true; crypto:hash ignores binary
content) — deferred, plain method stays.
- 2026-06-07 — `federation.sx`: trust-gated, advisory federated identity.
A peer assertion is accepted only from an explicitly trusted peer
(else `{error, untrusted}`) and is flagged `{peer_asserted, Peer}`, never
promoted to local authority — acl decides what it may do. Cross-instance
subject mapping namespaces remote subjects by peer (`{federated, Peer,
Remote}`) so two peers' "alice" never collide, with optional explicit
aliasing. Added an audit-completeness test (mixed transition stream → no
event dropped). New tests/federation.sx (12). **Phase 4 complete — all four
phases done.** +13 → 124/124.
- 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
counter so every cached positive instantly becomes a miss and re-validates
against the live registry. A revoked token never reads valid from cache.
stats() exposes hits/misses. New tests/cache.sx (9). **Phase 3 complete.**
+9 → 101/101.
- 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
consent) bound to client+redirect+PKCE, else `login_required`. Same machine,
fast-path — one session, many clients; `end_session` closes the path.
New `tests/sso.sx` (10). +10 → 75/75.
- 2026-06-07 — `oauth.sx` refresh wiring + e2e: exchange now issues an
access+refresh pair (RFC 6749 §4.1.4/§5.1) via token.sx issue_grant; added
the refresh grant (§6) delegating to token rotation. End-to-end tests:
code-exchange→refresh→introspect, refresh-reuse rejected, and
revoke-then-refresh blocked by cascade. **Phase 2 complete.** +3 → oauth 17,
65/65.
- 2026-06-07 — `token.sx` grant-centric rewrite: refresh-token rotation
(RFC 6749 §6) + cascading revocation. The grant {Subject,Client,Scope,
Status} is the cascade unit; access + refresh tokens reference it.
`issue_grant` → {ok, Access, Refresh}; `refresh` supersedes the old
refresh + mints a new pair; reusing a superseded refresh token revokes
the whole family (RFC 6819 §5.2.2.3), killing the live descendant.
`revoke` of ANY token (access or refresh) cascades to the grant. All
prior issue/introspect/revoke behaviour preserved. +9 → token 18, 62/62.
- 2026-06-07 — `oauth.sx`: OAuth2 authorization-code flow as a message
protocol (RFC 6749 §4.1) + PKCE (RFC 7636, plain). State machine on one
authz-server process: authorize → {consent_required} → consent →
{code} → exchange → {ok, Token}. Exchange enforces single-use codes
(§10.5; removed on first attempt, replay → invalid_grant), client_id +
redirect_uri binding (§4.1.3), and PKCE verifier match. Issued tokens are
grant-backed so revocation stays real. +14 → 53/53.
- 2026-06-06 — `api.sx`: service facade. `identity:start()` spawns one
coordinator owning the token table + session registry; exposes
login/verify/revoke/logout/session_status. Coordinator is the sessions'
owner, so an expired session deregisters itself (timeout-driven, no
sweep). `verify` answers IDENTITY only ({active, Subject, Client, Scope});
permission is acl's job — explicit delegation boundary. **Phase 1 complete.**
+10 → 39/39.
- 2026-06-06 — `registry.sx`: directory process routing sessions by id and
by (subject, client). Answers the SSO probe `lookup(Subject, Client)` and
the fan-out `sessions_for(Subject)` (one subject, many clients). Routes
only — holds no grant state. Integration-tested end-to-end: register a live
session, route to it, confirm it answers active. +9 → 29/29.
- 2026-06-06 — `token.sx`: opaque grant-backed tokens. Token = `make_ref`
(carries no info); the token table is a process; `introspect` is a live
lookup every time so revocation is real (RFC 7009) — a revoked token reads
`{inactive}` on the next introspection, no validity window. Reply shapes
follow RFC 7662 §2.2 (`{active,...}` / `{inactive}`, never says why). +9 → 20/20.
- 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 ## Blockers
- 2026-06-07 — **PKCE S256 blocked: erlang binary bugs.** Two substrate bugs (loop fills this in)
in `lib/erlang` make a correct/secure S256 impossible (S256 needs
`BASE64URL(SHA256(verifier))` compared against the stored challenge):
1. **Binary `=:=` always true.** `<<"v1">> =:= <<"v2">>``true`;
`<<"abc">> =:= <<"abd">>``true`. So a hash comparison can't reject a
wrong verifier.
2. **`crypto:hash` ignores binary-literal content.**
`crypto:hash(sha256, <<"v1">>)` and `crypto:hash(sha256, <<"v2">>)` return
the *identical* 32-byte digest (`6e 34 0b 9c …`), which is also ≠ the
correct SX-level `(crypto-sha256 "abc")` (`ba 78 16 bf …`). The binary
payload isn't reaching the hash. (Atom input → badarg→nil, separate issue.)
Minimal repro (epoch protocol, after loading lib/erlang/runtime.sx):
`(erlang-eval-ast "case <<\"a\">> =:= <<\"b\">> of true -> bug; false -> ok end")`
`bug`. Not in scope to fix (lib/erlang is a substrate). PKCE `plain`
remains correct and in use; S256 deferred until the binary path is fixed.