Compare commits
15 Commits
loops/iden
...
loops/sear
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d62d08e1c | |||
| db2a5dc6ab | |||
| cfa68c3db3 | |||
| cf4e613e43 | |||
| 911a2f57c0 | |||
| 7231cb651f | |||
| 5945b51cfd | |||
| 3ab8270a58 | |||
| 9d3b775b25 | |||
| 77ab827b91 | |||
| a3f9d4f6c9 | |||
| 4c84decc01 | |||
| 0f0da0319c | |||
| b8cf3eb1b8 | |||
| e2de5a4675 |
@@ -1,45 +0,0 @@
|
|||||||
;; lib/acl/api.sx — public ACL surface over an implicit current db.
|
|
||||||
;;
|
|
||||||
;; Callers load a fact set once, then issue decisions without threading the db
|
|
||||||
;; through every call. The current db is module state; (acl/load! facts) rebuilds
|
|
||||||
;; it. This is the boundary the rest of rose-ash imports.
|
|
||||||
|
|
||||||
(define acl-current-db nil)
|
|
||||||
|
|
||||||
;; Replace the current fact base. Rebuilds the Datalog db under the active
|
|
||||||
;; ruleset (see lib/acl/engine.sx).
|
|
||||||
(define
|
|
||||||
acl/load!
|
|
||||||
(fn
|
|
||||||
(facts)
|
|
||||||
(do (set! acl-current-db (acl-build-db facts)) acl-current-db)))
|
|
||||||
|
|
||||||
;; Ensure a db exists, building an empty one on first use.
|
|
||||||
(define
|
|
||||||
acl-ensure-db!
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(do
|
|
||||||
(when
|
|
||||||
(= acl-current-db nil)
|
|
||||||
(set! acl-current-db (acl-build-db (list))))
|
|
||||||
acl-current-db)))
|
|
||||||
|
|
||||||
;; Public decision against the current db (pure, no logging).
|
|
||||||
(define
|
|
||||||
acl/permit?
|
|
||||||
(fn (subj act res) (acl-permit? (acl-ensure-db!) subj act res)))
|
|
||||||
|
|
||||||
;; Decision-with-proof against the current db. See lib/acl/explain.sx.
|
|
||||||
(define
|
|
||||||
acl/explain
|
|
||||||
(fn (subj act res) (acl-explain (acl-ensure-db!) subj act res)))
|
|
||||||
|
|
||||||
;; Audited decision: logs the outcome to the append-only audit log and returns
|
|
||||||
;; the boolean. See lib/acl/audit.sx.
|
|
||||||
(define
|
|
||||||
acl/audit
|
|
||||||
(fn (subj act res) (acl-audit-decide! (acl-ensure-db!) subj act res)))
|
|
||||||
|
|
||||||
;; Recent audited decisions (chronological).
|
|
||||||
(define acl/audit-tail (fn (n) (acl-audit-tail n)))
|
|
||||||
110
lib/acl/audit.sx
110
lib/acl/audit.sx
@@ -1,110 +0,0 @@
|
|||||||
;; lib/acl/audit.sx — append-only decision log.
|
|
||||||
;;
|
|
||||||
;; Every decision routed through acl-audit-decide! is appended to an in-memory
|
|
||||||
;; log with a monotonic sequence number (no wall-clock — deterministic and
|
|
||||||
;; testable; a host can stamp time at the serializer boundary). The log is
|
|
||||||
;; append-only: there is no mutate or delete, only append, tail, clear,
|
|
||||||
;; snapshot/restore, and serialize-for-disk.
|
|
||||||
|
|
||||||
(define acl-audit-log (list))
|
|
||||||
(define acl-audit-seq 0)
|
|
||||||
|
|
||||||
;; Copy a list into a fresh, append!-able list. `map`/`rest`-derived lists are
|
|
||||||
;; NOT extensible by append! in this runtime (it silently no-ops), so the live
|
|
||||||
;; log must always be a list built with `list` + `append!`.
|
|
||||||
(define
|
|
||||||
acl-audit-copy
|
|
||||||
(fn
|
|
||||||
(xs)
|
|
||||||
(let
|
|
||||||
((fresh (list)))
|
|
||||||
(do (for-each (fn (e) (append! fresh e)) xs) fresh))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-audit-clear!
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(do (set! acl-audit-log (list)) (set! acl-audit-seq 0) nil)))
|
|
||||||
|
|
||||||
;; Append a decision record. Returns the record.
|
|
||||||
(define
|
|
||||||
acl-audit-record!
|
|
||||||
(fn
|
|
||||||
(subj act res allowed?)
|
|
||||||
(let
|
|
||||||
((entry {:allowed? allowed? :act act :subj subj :res res :seq acl-audit-seq}))
|
|
||||||
(do
|
|
||||||
(set! acl-audit-seq (+ acl-audit-seq 1))
|
|
||||||
(append! acl-audit-log entry)
|
|
||||||
entry))))
|
|
||||||
|
|
||||||
;; Decide against db, log the outcome, and return the boolean. This is the
|
|
||||||
;; audited path; acl-permit? remains the pure, side-effect-free decision.
|
|
||||||
(define
|
|
||||||
acl-audit-decide!
|
|
||||||
(fn
|
|
||||||
(db subj act res)
|
|
||||||
(let
|
|
||||||
((allowed? (acl-permit? db subj act res)))
|
|
||||||
(do (acl-audit-record! subj act res allowed?) allowed?))))
|
|
||||||
|
|
||||||
(define acl-audit-count (fn () (len acl-audit-log)))
|
|
||||||
|
|
||||||
;; Most recent n entries (in chronological order). n >= log size returns all.
|
|
||||||
(define
|
|
||||||
acl-audit-tail
|
|
||||||
(fn
|
|
||||||
(n)
|
|
||||||
(let
|
|
||||||
((total (len acl-audit-log)))
|
|
||||||
(if
|
|
||||||
(<= total n)
|
|
||||||
acl-audit-log
|
|
||||||
(acl-audit-drop acl-audit-log (- total n))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-audit-drop
|
|
||||||
(fn
|
|
||||||
(xs k)
|
|
||||||
(if (<= k 0) xs (acl-audit-drop (rest xs) (- k 1)))))
|
|
||||||
|
|
||||||
;; Structured snapshot for save/restore — a {:seq :entries} value carrying a
|
|
||||||
;; copy of the log (so later appends don't mutate a held snapshot).
|
|
||||||
(define acl-audit-snapshot (fn () {:seq acl-audit-seq :entries (acl-audit-copy acl-audit-log)}))
|
|
||||||
|
|
||||||
;; Replace the live log from a snapshot. Restores both entries and the seq
|
|
||||||
;; counter so subsequent records continue numbering correctly. The log is
|
|
||||||
;; rebuilt as a fresh append!-able list (see acl-audit-copy).
|
|
||||||
(define
|
|
||||||
acl-audit-restore!
|
|
||||||
(fn
|
|
||||||
(snap)
|
|
||||||
(do
|
|
||||||
(set! acl-audit-log (acl-audit-copy (get snap :entries)))
|
|
||||||
(set! acl-audit-seq (get snap :seq))
|
|
||||||
nil)))
|
|
||||||
|
|
||||||
;; Serialize the whole log to a disk-ready string: one record per line,
|
|
||||||
;; "seq\tsubj\tact\tres\tallowed?". A host writes this; structured reload is via
|
|
||||||
;; snapshot/restore.
|
|
||||||
(define
|
|
||||||
acl-audit-serialize
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(reduce
|
|
||||||
(fn
|
|
||||||
(acc e)
|
|
||||||
(str
|
|
||||||
acc
|
|
||||||
(get e :seq)
|
|
||||||
"\t"
|
|
||||||
(get e :subj)
|
|
||||||
"\t"
|
|
||||||
(get e :act)
|
|
||||||
"\t"
|
|
||||||
(get e :res)
|
|
||||||
"\t"
|
|
||||||
(get e :allowed?)
|
|
||||||
"\n"))
|
|
||||||
""
|
|
||||||
acl-audit-log)))
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# ACL conformance config — sourced by lib/guest/conformance.sh.
|
|
||||||
|
|
||||||
LANG_NAME=acl
|
|
||||||
MODE=dict
|
|
||||||
|
|
||||||
PRELOADS=(
|
|
||||||
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/acl/schema.sx
|
|
||||||
lib/acl/facts.sx
|
|
||||||
lib/acl/engine.sx
|
|
||||||
lib/acl/explain.sx
|
|
||||||
lib/acl/audit.sx
|
|
||||||
lib/acl/federation.sx
|
|
||||||
lib/acl/api.sx
|
|
||||||
)
|
|
||||||
|
|
||||||
SUITES=(
|
|
||||||
"direct:lib/acl/tests/direct.sx:(acl-direct-tests-run!)"
|
|
||||||
"inherit:lib/acl/tests/inherit.sx:(acl-inherit-tests-run!)"
|
|
||||||
"explain:lib/acl/tests/explain.sx:(acl-explain-tests-run!)"
|
|
||||||
"fed:lib/acl/tests/fed.sx:(acl-fed-tests-run!)"
|
|
||||||
"harden:lib/acl/tests/harden.sx:(acl-harden-tests-run!)"
|
|
||||||
)
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Thin wrapper — see lib/guest/conformance.sh and lib/acl/conformance.conf.
|
|
||||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
;; lib/acl/engine.sx — ACL ruleset + decision reducer over lib/datalog/.
|
|
||||||
;;
|
|
||||||
;; The engine is a thin layer: it owns the permit ruleset (SX data rules) and
|
|
||||||
;; reduces a (subject, action, resource) decision to a Datalog query against a
|
|
||||||
;; db built from EDB facts. The rule engine itself is Datalog's.
|
|
||||||
;;
|
|
||||||
;; Policy — inheritance + federation with deny-overrides:
|
|
||||||
;;
|
|
||||||
;; eff_grant(S,A,R) :- grant(S,A,R). ; direct
|
|
||||||
;; eff_grant(S,A,R) :- member_of(S,G), eff_grant(G,A,R). ; group/role chain
|
|
||||||
;; eff_grant(S,A,R) :- child_of(R,P), eff_grant(S,A,P). ; resource tree
|
|
||||||
;; eff_grant(S,A,R) :- member_of(S,Role), role_grant(Role,A,R). ; role expansion
|
|
||||||
;; eff_grant(S,A,R) :- delegate(Peer,S,A,R), ; federated grant
|
|
||||||
;; trust(Peer,L), level_covers(L,A).
|
|
||||||
;;
|
|
||||||
;; eff_deny(S,A,R) :- deny(S,A,R). ; direct
|
|
||||||
;; eff_deny(S,A,R) :- member_of(S,G), eff_deny(G,A,R). ; group chain
|
|
||||||
;; eff_deny(S,A,R) :- child_of(R,P), eff_deny(S,A,P). ; resource tree
|
|
||||||
;;
|
|
||||||
;; permit(S,A,R) :- eff_grant(S,A,R), not eff_deny(S,A,R).
|
|
||||||
;;
|
|
||||||
;; DENY-OVERRIDES: an effective deny anywhere in the inheritance closure of
|
|
||||||
;; (S,A,R) defeats any effective grant — including federated grants. Deny
|
|
||||||
;; inherits through the *same* group and resource chains as grant, so a
|
|
||||||
;; group-level or ancestor-resource deny is authoritative for members/
|
|
||||||
;; descendants. This is the principled, fail-safe reading of "deny wins".
|
|
||||||
;;
|
|
||||||
;; FEDERATION — non-transitive trust: a peer's `delegate` fact only grants if a
|
|
||||||
;; *local* `trust(Peer, L)` exists AND that level `level_covers` the action.
|
|
||||||
;; Trust is re-checked on every query (it is a body literal), never baked in at
|
|
||||||
;; fact-ingestion time, so revoking trust or narrowing a level takes effect
|
|
||||||
;; immediately on the next decision.
|
|
||||||
;;
|
|
||||||
;; Termination & stratification:
|
|
||||||
;; - eff_grant/eff_deny recurse only over member_of and child_of, which are
|
|
||||||
;; EDB relations with no function symbols, so the closure is finite (cyclic
|
|
||||||
;; membership/containment just reaches a fixpoint, never loops). The
|
|
||||||
;; federation rule is non-recursive.
|
|
||||||
;; - permit negates eff_deny; neither eff_grant nor eff_deny depends on
|
|
||||||
;; permit, so the program is stratifiable (permit sits in a higher stratum).
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-rules
|
|
||||||
(quote
|
|
||||||
((eff_grant S A R <- (grant S A R))
|
|
||||||
(eff_grant S A R <- (member_of S G) (eff_grant G A R))
|
|
||||||
(eff_grant S A R <- (child_of R P) (eff_grant S A P))
|
|
||||||
(eff_grant S A R <- (member_of S Role) (role_grant Role A R))
|
|
||||||
(eff_grant
|
|
||||||
S
|
|
||||||
A
|
|
||||||
R
|
|
||||||
<-
|
|
||||||
(delegate Peer S A R)
|
|
||||||
(trust Peer L)
|
|
||||||
(level_covers L A))
|
|
||||||
(eff_deny S A R <- (deny S A R))
|
|
||||||
(eff_deny S A R <- (member_of S G) (eff_deny G A R))
|
|
||||||
(eff_deny S A R <- (child_of R P) (eff_deny S A P))
|
|
||||||
(permit S A R <- (eff_grant S A R) {:neg (eff_deny S A R)}))))
|
|
||||||
|
|
||||||
;; Build a Datalog db from a list of EDB facts under the ACL ruleset.
|
|
||||||
(define acl-build-db (fn (facts) (dl-program-data facts acl-rules)))
|
|
||||||
|
|
||||||
;; Core decision: does the db permit subject S to perform action A on
|
|
||||||
;; resource R? Reduces to a ground Datalog query on the derived `permit`
|
|
||||||
;; relation — non-empty result means permitted.
|
|
||||||
(define
|
|
||||||
acl-permit?
|
|
||||||
(fn
|
|
||||||
(db subj act res)
|
|
||||||
(> (len (dl-query db (list (quote permit) subj act res))) 0)))
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
;; lib/acl/explain.sx — proof-tree reconstruction over the saturated db.
|
|
||||||
;;
|
|
||||||
;; lib/datalog/ records derived facts but not their provenance, so the proof is
|
|
||||||
;; reconstructed here by goal-directed search over the *saturated* db: for a
|
|
||||||
;; ground goal we find the first ACL rule (in rule order) whose body holds, take
|
|
||||||
;; the first solution binding its remaining variables, and recurse on each body
|
|
||||||
;; literal. Negated literals are recorded as verified `:neg-ok` leaves.
|
|
||||||
;;
|
|
||||||
;; CANONICAL DERIVATION: the Datalog derivation graph is a DAG (a fact may hold
|
|
||||||
;; many ways). We pick ONE canonical proof — first matching rule, first solution
|
|
||||||
;; — matching the rule order in lib/acl/engine.sx (direct/EDB rules first). A
|
|
||||||
;; depth cap guards against pathological cyclic data producing unbounded search.
|
|
||||||
;;
|
|
||||||
;; A proof node is one of:
|
|
||||||
;; {:fact <lit> :via "edb"} — base EDB fact
|
|
||||||
;; {:fact <lit> :rule <head> :body (<node|negleaf> ...)} — derived
|
|
||||||
;; {:neg-ok <lit>} — negation verified to fail
|
|
||||||
;; {:fact <lit> :truncated true} — depth cap hit
|
|
||||||
|
|
||||||
(define acl-proof-max-depth 64)
|
|
||||||
|
|
||||||
;; Substitute a body literal, descending into {:neg ...} dicts (dl-apply-subst
|
|
||||||
;; does not recurse into dicts, which would leak the neg's free vars).
|
|
||||||
(define
|
|
||||||
acl-subst-lit
|
|
||||||
(fn
|
|
||||||
(lit s)
|
|
||||||
(if
|
|
||||||
(and (dict? lit) (has-key? lit :neg))
|
|
||||||
{:neg (dl-apply-subst (get lit :neg) s)}
|
|
||||||
(dl-apply-subst lit s))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-lit-edb?
|
|
||||||
(fn
|
|
||||||
(lit)
|
|
||||||
(and
|
|
||||||
(list? lit)
|
|
||||||
(> (len lit) 0)
|
|
||||||
(symbol? (first lit))
|
|
||||||
(has-key? acl-edb-arity (symbol->string (first lit))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-subst-zip!
|
|
||||||
(fn
|
|
||||||
(d ks vs)
|
|
||||||
(when
|
|
||||||
(> (len ks) 0)
|
|
||||||
(do
|
|
||||||
(dict-set! d (symbol->string (first ks)) (first vs))
|
|
||||||
(acl-subst-zip! d (rest ks) (rest vs))))))
|
|
||||||
|
|
||||||
;; Bind a rule head's variables to a ground goal's arguments (positional).
|
|
||||||
(define
|
|
||||||
acl-bind-head
|
|
||||||
(fn
|
|
||||||
(head goal)
|
|
||||||
(let
|
|
||||||
((d {}))
|
|
||||||
(do (acl-subst-zip! d (rest head) (rest goal)) d))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-subst-union
|
|
||||||
(fn
|
|
||||||
(a b)
|
|
||||||
(let
|
|
||||||
((d {}))
|
|
||||||
(do
|
|
||||||
(for-each (fn (k) (dict-set! d k (get a k))) (keys a))
|
|
||||||
(for-each (fn (k) (dict-set! d k (get b k))) (keys b))
|
|
||||||
d))))
|
|
||||||
|
|
||||||
(define acl-prove (fn (db goal) (acl-prove-d db goal 0)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-prove-d
|
|
||||||
(fn
|
|
||||||
(db goal depth)
|
|
||||||
(cond
|
|
||||||
((> depth acl-proof-max-depth) {:truncated true :fact goal})
|
|
||||||
((acl-lit-edb? goal)
|
|
||||||
(if (> (len (dl-query db goal)) 0) {:via "edb" :fact goal} nil))
|
|
||||||
(else (acl-prove-rules db goal acl-rules depth)))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-prove-rules
|
|
||||||
(fn
|
|
||||||
(db goal rules depth)
|
|
||||||
(if
|
|
||||||
(= (len rules) 0)
|
|
||||||
nil
|
|
||||||
(let
|
|
||||||
((p (dl-rule-from-list (first rules))))
|
|
||||||
(if
|
|
||||||
(= (first (get p :head)) (first goal))
|
|
||||||
(let
|
|
||||||
((hs (acl-bind-head (get p :head) goal)))
|
|
||||||
(let
|
|
||||||
((qbody (map (fn (l) (acl-subst-lit l hs)) (get p :body))))
|
|
||||||
(let
|
|
||||||
((sols (dl-query db qbody)))
|
|
||||||
(if
|
|
||||||
(> (len sols) 0)
|
|
||||||
(acl-prove-build db goal p hs (first sols) depth)
|
|
||||||
(acl-prove-rules db goal (rest rules) depth)))))
|
|
||||||
(acl-prove-rules db goal (rest rules) depth))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-prove-build
|
|
||||||
(fn
|
|
||||||
(db goal p hs sol depth)
|
|
||||||
(let ((full (acl-subst-union hs sol))) {:body (map (fn (l) (let ((g (acl-subst-lit l full))) (if (and (dict? g) (has-key? g :neg)) {:neg-ok (get g :neg)} (acl-prove-d db g (+ depth 1))))) (get p :body)) :rule (get p :head) :fact goal})))
|
|
||||||
|
|
||||||
;; Public decision-with-proof. Returns:
|
|
||||||
;; {:allowed? <bool> :proof <node|nil> :reason <eff_deny proof|nil>}
|
|
||||||
;; When permitted, :proof is the permit derivation. When denied, :proof is nil
|
|
||||||
;; and :reason carries the blocking eff_deny proof if one exists (an explicit or
|
|
||||||
;; inherited deny), else nil (simply no grant).
|
|
||||||
(define
|
|
||||||
acl-explain
|
|
||||||
(fn
|
|
||||||
(db subj act res)
|
|
||||||
(let
|
|
||||||
((proof (acl-prove db (list (quote permit) subj act res))))
|
|
||||||
(if (= proof nil) {:allowed? false :proof nil :reason (acl-prove db (list (quote eff_deny) subj act res))} {:allowed? true :proof proof :reason nil}))))
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
;; lib/acl/facts.sx — EDB fact constructors.
|
|
||||||
;;
|
|
||||||
;; Each constructor returns a Datalog fact tuple (a list whose head is the
|
|
||||||
;; predicate symbol). These are the only shapes lib/acl/engine.sx feeds to
|
|
||||||
;; lib/datalog/.
|
|
||||||
;; Phase 1: actor/resource/grant/deny.
|
|
||||||
;; Phase 2: member_of (subject -> group/role), child_of (resource -> parent),
|
|
||||||
;; role_grant (role -> action,resource capability).
|
|
||||||
;; Phase 4: peer/trust/delegate/level_covers (federation).
|
|
||||||
|
|
||||||
(define acl-actor (fn (id kind) (list (quote actor) id kind)))
|
|
||||||
|
|
||||||
(define acl-resource-fact (fn (id kind) (list (quote resource) id kind)))
|
|
||||||
|
|
||||||
(define acl-grant (fn (subj act res) (list (quote grant) subj act res)))
|
|
||||||
|
|
||||||
(define acl-deny (fn (subj act res) (list (quote deny) subj act res)))
|
|
||||||
|
|
||||||
;; subject S is a member of group/role G (one hop; transitivity is derived).
|
|
||||||
(define acl-member-of (fn (subj grp) (list (quote member_of) subj grp)))
|
|
||||||
|
|
||||||
;; resource R is a child of parent P (one hop; transitivity is derived).
|
|
||||||
(define acl-child-of (fn (res parent) (list (quote child_of) res parent)))
|
|
||||||
|
|
||||||
;; role confers capability (act on res) to every member of the role.
|
|
||||||
(define
|
|
||||||
acl-role-grant
|
|
||||||
(fn (role act res) (list (quote role_grant) role act res)))
|
|
||||||
|
|
||||||
;; --- federation ---
|
|
||||||
|
|
||||||
;; a known peer instance at addr, of some kind (e.g. peer).
|
|
||||||
(define acl-peer (fn (addr kind) (list (quote peer) addr kind)))
|
|
||||||
|
|
||||||
;; local trust in a peer at a named level. Gates delegated grants at query time.
|
|
||||||
(define acl-trust (fn (peer level) (list (quote trust) peer level)))
|
|
||||||
|
|
||||||
;; a peer asserts that subject S may A on R. Only takes effect if local trust in
|
|
||||||
;; that peer covers action A (see level_covers).
|
|
||||||
(define
|
|
||||||
acl-delegate
|
|
||||||
(fn (peer subj act res) (list (quote delegate) peer subj act res)))
|
|
||||||
|
|
||||||
;; local policy: trust `level` authorises delegated grants for action `act`.
|
|
||||||
(define
|
|
||||||
acl-level-covers
|
|
||||||
(fn (level act) (list (quote level_covers) level act)))
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
;; lib/acl/federation.sx — cross-instance ACL facts + revocation.
|
|
||||||
;;
|
|
||||||
;; fed-sx replicates ACL facts between instances; this module models the local
|
|
||||||
;; side. A peer's authority arrives as `delegate(Peer, S, A, R)` facts, which
|
|
||||||
;; only take effect when a local `trust(Peer, L)` and `level_covers(L, A)`
|
|
||||||
;; authorise them (enforced by the engine rule, re-checked every query). The
|
|
||||||
;; actual network transport is fed-sx's job and is mocked in tests as a dict.
|
|
||||||
;;
|
|
||||||
;; Trust is NOT transitive: trusting peer α does not extend to peers α trusts.
|
|
||||||
;; Only delegate facts that α itself asserts, and that local trust covers, flow.
|
|
||||||
|
|
||||||
;; Mock fed-sx pull: `transport` is a dict mapping a peer address (its string
|
|
||||||
;; name) to the list of delegate facts that peer asserts. Returns the facts for
|
|
||||||
;; `addr`, or an empty list if the peer is unknown / unreachable.
|
|
||||||
(define
|
|
||||||
acl-fed-fetch
|
|
||||||
(fn
|
|
||||||
(transport addr)
|
|
||||||
(let
|
|
||||||
((k (if (symbol? addr) (symbol->string addr) addr)))
|
|
||||||
(if (has-key? transport k) (get transport k) (list)))))
|
|
||||||
|
|
||||||
;; Gather delegate facts from every peer in `addrs` via the transport.
|
|
||||||
(define
|
|
||||||
acl-fed-collect
|
|
||||||
(fn
|
|
||||||
(transport addrs)
|
|
||||||
(let
|
|
||||||
((acc (list)))
|
|
||||||
(do
|
|
||||||
(for-each
|
|
||||||
(fn
|
|
||||||
(addr)
|
|
||||||
(for-each
|
|
||||||
(fn (f) (append! acc f))
|
|
||||||
(acl-fed-fetch transport addr)))
|
|
||||||
addrs)
|
|
||||||
acc))))
|
|
||||||
|
|
||||||
;; Build a db from local facts plus delegate facts pulled from `peers`. Local
|
|
||||||
;; facts must include the `trust`/`level_covers` policy; replicated delegate
|
|
||||||
;; facts are gated against it by the engine rule at query time.
|
|
||||||
(define
|
|
||||||
acl-fed-build-db
|
|
||||||
(fn
|
|
||||||
(local-facts transport peers)
|
|
||||||
(let
|
|
||||||
((all (list)))
|
|
||||||
(do
|
|
||||||
(for-each (fn (f) (append! all f)) local-facts)
|
|
||||||
(for-each
|
|
||||||
(fn (f) (append! all f))
|
|
||||||
(acl-fed-collect transport peers))
|
|
||||||
(acl-build-db all)))))
|
|
||||||
|
|
||||||
;; Propagated revocation: retract a replicated fact (e.g. a peer's delegate, or
|
|
||||||
;; local trust) from a live db. The next decision re-saturates and reflects it.
|
|
||||||
(define acl-revoke! (fn (db fact) (do (dl-retract! db fact) db)))
|
|
||||||
|
|
||||||
;; Propagated assertion: ingest a newly replicated fact into a live db.
|
|
||||||
(define acl-fed-assert! (fn (db fact) (do (dl-assert! db fact) db)))
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
;; lib/acl/schema.sx — ACL sorts and EDB predicate vocabulary.
|
|
||||||
;;
|
|
||||||
;; Datalog is untyped; this module is the schema-as-data layer. It declares
|
|
||||||
;; the subject/resource/action sorts and the arity of every EDB predicate the
|
|
||||||
;; ACL engine recognises, plus light validators. Facts that pass these checks
|
|
||||||
;; are well-formed inputs to lib/acl/engine.sx.
|
|
||||||
|
|
||||||
(define acl-subject-kinds (quote (user group role service)))
|
|
||||||
(define acl-resource-kinds (quote (page post thread peer)))
|
|
||||||
|
|
||||||
;; Actions are open-ended (a grant may name any action symbol), but these are
|
|
||||||
;; the platform's well-known verbs.
|
|
||||||
(define acl-actions (quote (read edit comment moderate federate)))
|
|
||||||
|
|
||||||
;; EDB predicate name -> arity.
|
|
||||||
;; Phase 1: actor/resource/grant/deny.
|
|
||||||
;; Phase 2: member_of (subject->group/role), child_of (resource->parent),
|
|
||||||
;; role_grant (role->action,resource).
|
|
||||||
;; Phase 4: peer (addr->kind), trust (peer->level),
|
|
||||||
;; delegate (peer->subj,action,resource), level_covers (level->action).
|
|
||||||
(define acl-edb-arity {:role_grant 3 :child_of 2 :trust 2 :peer 2 :actor 2 :level_covers 2 :delegate 4 :member_of 2 :deny 3 :grant 3 :resource 2})
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-member?
|
|
||||||
(fn
|
|
||||||
(x xs)
|
|
||||||
(cond
|
|
||||||
((= (len xs) 0) false)
|
|
||||||
((= (first xs) x) true)
|
|
||||||
(else (acl-member? x (rest xs))))))
|
|
||||||
|
|
||||||
(define acl-subject-kind? (fn (k) (acl-member? k acl-subject-kinds)))
|
|
||||||
|
|
||||||
(define acl-resource-kind? (fn (k) (acl-member? k acl-resource-kinds)))
|
|
||||||
|
|
||||||
(define acl-known-action? (fn (a) (acl-member? a acl-actions)))
|
|
||||||
|
|
||||||
;; A fact is a list whose head is a predicate symbol. Valid when the predicate
|
|
||||||
;; is known and the argument count matches the declared arity.
|
|
||||||
(define
|
|
||||||
acl-fact-valid?
|
|
||||||
(fn
|
|
||||||
(f)
|
|
||||||
(and
|
|
||||||
(list? f)
|
|
||||||
(> (len f) 0)
|
|
||||||
(symbol? (first f))
|
|
||||||
(let
|
|
||||||
((pred (symbol->string (first f))))
|
|
||||||
(and
|
|
||||||
(has-key? acl-edb-arity pred)
|
|
||||||
(= (- (len f) 1) (get acl-edb-arity pred)))))))
|
|
||||||
|
|
||||||
;; Return the sublist of facts that fail acl-fact-valid?. Empty list means the
|
|
||||||
;; whole set is well-formed. acl-build-db stays lenient (Datalog accepts any
|
|
||||||
;; tuple, and custom action symbols are allowed); callers opt in to checking.
|
|
||||||
(define
|
|
||||||
acl-validate-facts
|
|
||||||
(fn
|
|
||||||
(facts)
|
|
||||||
(let
|
|
||||||
((bad (list)))
|
|
||||||
(do
|
|
||||||
(for-each
|
|
||||||
(fn (f) (when (not (acl-fact-valid? f)) (append! bad f)))
|
|
||||||
facts)
|
|
||||||
bad))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-facts-valid?
|
|
||||||
(fn (facts) (= (len (acl-validate-facts facts)) 0)))
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"lang": "acl",
|
|
||||||
"total_passed": 145,
|
|
||||||
"total_failed": 0,
|
|
||||||
"total": 145,
|
|
||||||
"suites": [
|
|
||||||
{"name":"direct","passed":24,"failed":0,"total":24},
|
|
||||||
{"name":"inherit","passed":30,"failed":0,"total":30},
|
|
||||||
{"name":"explain","passed":35,"failed":0,"total":35},
|
|
||||||
{"name":"fed","passed":31,"failed":0,"total":31},
|
|
||||||
{"name":"harden","passed":25,"failed":0,"total":25}
|
|
||||||
],
|
|
||||||
"generated": "2026-06-06T22:43:27+00:00"
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# acl scoreboard
|
|
||||||
|
|
||||||
**145 / 145 passing** (0 failure(s)).
|
|
||||||
|
|
||||||
| Suite | Passed | Total | Status |
|
|
||||||
|-------|--------|-------|--------|
|
|
||||||
| direct | 24 | 24 | ok |
|
|
||||||
| inherit | 30 | 30 | ok |
|
|
||||||
| explain | 35 | 35 | ok |
|
|
||||||
| fed | 31 | 31 | ok |
|
|
||||||
| harden | 25 | 25 | ok |
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
;; lib/acl/tests/direct.sx — Phase 1: direct grants + deny-overrides.
|
|
||||||
|
|
||||||
(define acl-dt-pass 0)
|
|
||||||
(define acl-dt-fail 0)
|
|
||||||
(define acl-dt-failures (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-dt-check!
|
|
||||||
(fn
|
|
||||||
(name got expected)
|
|
||||||
(if
|
|
||||||
(= got expected)
|
|
||||||
(set! acl-dt-pass (+ acl-dt-pass 1))
|
|
||||||
(do
|
|
||||||
(set! acl-dt-fail (+ acl-dt-fail 1))
|
|
||||||
(append!
|
|
||||||
acl-dt-failures
|
|
||||||
(str name "\n expected: " expected "\n got: " got))))))
|
|
||||||
|
|
||||||
;; A small fixture used by most cases: alice can read page1, is denied edit on
|
|
||||||
;; page1, and a service may federate peer1.
|
|
||||||
(define
|
|
||||||
acl-dt-fixture
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(acl-build-db
|
|
||||||
(list
|
|
||||||
(acl-actor (quote alice) (quote user))
|
|
||||||
(acl-actor (quote svc1) (quote service))
|
|
||||||
(acl-resource-fact (quote page1) (quote page))
|
|
||||||
(acl-resource-fact (quote peer1) (quote peer))
|
|
||||||
(acl-grant (quote alice) (quote read) (quote page1))
|
|
||||||
(acl-grant (quote alice) (quote edit) (quote page1))
|
|
||||||
(acl-deny (quote alice) (quote edit) (quote page1))
|
|
||||||
(acl-grant (quote svc1) (quote federate) (quote peer1))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-dt-run-all!
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(let
|
|
||||||
((db (acl-dt-fixture)))
|
|
||||||
(do
|
|
||||||
(acl-dt-check!
|
|
||||||
"direct grant permits"
|
|
||||||
(acl-permit? db (quote alice) (quote read) (quote page1))
|
|
||||||
true)
|
|
||||||
(acl-dt-check!
|
|
||||||
"service grant permits federate"
|
|
||||||
(acl-permit? db (quote svc1) (quote federate) (quote peer1))
|
|
||||||
true)
|
|
||||||
(acl-dt-check!
|
|
||||||
"missing action denied"
|
|
||||||
(acl-permit? db (quote alice) (quote comment) (quote page1))
|
|
||||||
false)
|
|
||||||
(acl-dt-check!
|
|
||||||
"missing resource denied"
|
|
||||||
(acl-permit? db (quote alice) (quote read) (quote page2))
|
|
||||||
false)
|
|
||||||
(acl-dt-check!
|
|
||||||
"missing subject denied"
|
|
||||||
(acl-permit? db (quote bob) (quote read) (quote page1))
|
|
||||||
false)
|
|
||||||
(acl-dt-check!
|
|
||||||
"wrong subject for service grant denied"
|
|
||||||
(acl-permit? db (quote alice) (quote federate) (quote peer1))
|
|
||||||
false)
|
|
||||||
(acl-dt-check!
|
|
||||||
"grant plus deny -> deny wins"
|
|
||||||
(acl-permit? db (quote alice) (quote edit) (quote page1))
|
|
||||||
false)
|
|
||||||
(acl-dt-check!
|
|
||||||
"deny alone still denies"
|
|
||||||
(acl-permit?
|
|
||||||
(acl-build-db
|
|
||||||
(list (acl-deny (quote alice) (quote read) (quote page1))))
|
|
||||||
(quote alice)
|
|
||||||
(quote read)
|
|
||||||
(quote page1))
|
|
||||||
false)
|
|
||||||
(acl-dt-check!
|
|
||||||
"deny on edit does not block read"
|
|
||||||
(acl-permit? db (quote alice) (quote read) (quote page1))
|
|
||||||
true)
|
|
||||||
(acl-dt-check!
|
|
||||||
"empty db denies"
|
|
||||||
(acl-permit?
|
|
||||||
(acl-build-db (list))
|
|
||||||
(quote alice)
|
|
||||||
(quote read)
|
|
||||||
(quote page1))
|
|
||||||
false)
|
|
||||||
(let
|
|
||||||
((db2 (acl-build-db (list (acl-grant (quote a) (quote read) (quote r)) (acl-grant (quote b) (quote read) (quote r)) (acl-deny (quote b) (quote read) (quote r))))))
|
|
||||||
(do
|
|
||||||
(acl-dt-check!
|
|
||||||
"subject a allowed"
|
|
||||||
(acl-permit? db2 (quote a) (quote read) (quote r))
|
|
||||||
true)
|
|
||||||
(acl-dt-check!
|
|
||||||
"subject b denied by override"
|
|
||||||
(acl-permit? db2 (quote b) (quote read) (quote r))
|
|
||||||
false)))
|
|
||||||
(let
|
|
||||||
((db3 (acl-build-db (list (acl-actor (quote editors) (quote role)) (acl-grant (quote editors) (quote edit) (quote post1))))))
|
|
||||||
(acl-dt-check!
|
|
||||||
"role subject direct grant"
|
|
||||||
(acl-permit? db3 (quote editors) (quote edit) (quote post1))
|
|
||||||
true))
|
|
||||||
(do
|
|
||||||
(acl/load!
|
|
||||||
(list
|
|
||||||
(acl-grant (quote carol) (quote moderate) (quote thread1))))
|
|
||||||
(acl-dt-check!
|
|
||||||
"api permit via current db"
|
|
||||||
(acl/permit? (quote carol) (quote moderate) (quote thread1))
|
|
||||||
true)
|
|
||||||
(acl-dt-check!
|
|
||||||
"api deny via current db"
|
|
||||||
(acl/permit? (quote carol) (quote read) (quote thread1))
|
|
||||||
false))
|
|
||||||
(do
|
|
||||||
(acl/load! (list))
|
|
||||||
(acl-dt-check!
|
|
||||||
"api reload clears prior grants"
|
|
||||||
(acl/permit? (quote carol) (quote moderate) (quote thread1))
|
|
||||||
false))
|
|
||||||
(acl-dt-check!
|
|
||||||
"schema grant arity valid"
|
|
||||||
(acl-fact-valid? (acl-grant (quote x) (quote read) (quote y)))
|
|
||||||
true)
|
|
||||||
(acl-dt-check!
|
|
||||||
"schema bad arity invalid"
|
|
||||||
(acl-fact-valid? (list (quote grant) (quote x)))
|
|
||||||
false)
|
|
||||||
(acl-dt-check!
|
|
||||||
"schema unknown predicate invalid"
|
|
||||||
(acl-fact-valid? (list (quote frobnicate) (quote x)))
|
|
||||||
false)
|
|
||||||
(acl-dt-check!
|
|
||||||
"schema subject kind known"
|
|
||||||
(acl-subject-kind? (quote service))
|
|
||||||
true)
|
|
||||||
(acl-dt-check!
|
|
||||||
"schema resource kind unknown"
|
|
||||||
(acl-resource-kind? (quote galaxy))
|
|
||||||
false)
|
|
||||||
(acl-dt-check!
|
|
||||||
"schema known action"
|
|
||||||
(acl-known-action? (quote moderate))
|
|
||||||
true)
|
|
||||||
(acl-dt-check!
|
|
||||||
"grant constructor shape"
|
|
||||||
(acl-grant (quote u) (quote read) (quote p))
|
|
||||||
(list (quote grant) (quote u) (quote read) (quote p)))
|
|
||||||
(acl-dt-check!
|
|
||||||
"actor constructor shape"
|
|
||||||
(acl-actor (quote u) (quote user))
|
|
||||||
(list (quote actor) (quote u) (quote user)))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-direct-tests-run!
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(do
|
|
||||||
(set! acl-dt-pass 0)
|
|
||||||
(set! acl-dt-fail 0)
|
|
||||||
(set! acl-dt-failures (list))
|
|
||||||
(acl-dt-run-all!)
|
|
||||||
{:failures acl-dt-failures :total (+ acl-dt-pass acl-dt-fail) :passed acl-dt-pass :failed acl-dt-fail})))
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
;; lib/acl/tests/explain.sx — Phase 3: proof correctness + audit completeness.
|
|
||||||
|
|
||||||
(define acl-et-pass 0)
|
|
||||||
(define acl-et-fail 0)
|
|
||||||
(define acl-et-failures (list))
|
|
||||||
|
|
||||||
;; Name-based deep equality. The host `=` compares symbols by interned
|
|
||||||
;; identity, which is unstable across substitution/saturation; comparing by
|
|
||||||
;; name (as the datalog suite does) makes structural assertions deterministic.
|
|
||||||
(define
|
|
||||||
acl-et-eq?
|
|
||||||
(fn
|
|
||||||
(a b)
|
|
||||||
(cond
|
|
||||||
((and (list? a) (list? b))
|
|
||||||
(and (= (len a) (len b)) (acl-et-eq-l? a b 0)))
|
|
||||||
((and (dict? a) (dict? b))
|
|
||||||
(let
|
|
||||||
((ka (keys a)) (kb (keys b)))
|
|
||||||
(and (= (len ka) (len kb)) (acl-et-eq-d? a b ka 0))))
|
|
||||||
((and (symbol? a) (symbol? b))
|
|
||||||
(= (symbol->string a) (symbol->string b)))
|
|
||||||
(else (= a b)))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-et-eq-l?
|
|
||||||
(fn
|
|
||||||
(a b i)
|
|
||||||
(cond
|
|
||||||
((>= i (len a)) true)
|
|
||||||
((not (acl-et-eq? (nth a i) (nth b i))) false)
|
|
||||||
(else (acl-et-eq-l? a b (+ i 1))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-et-eq-d?
|
|
||||||
(fn
|
|
||||||
(a b ka i)
|
|
||||||
(cond
|
|
||||||
((>= i (len ka)) true)
|
|
||||||
((let ((k (nth ka i))) (not (acl-et-eq? (get a k) (get b k))))
|
|
||||||
false)
|
|
||||||
(else (acl-et-eq-d? a b ka (+ i 1))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-et-check!
|
|
||||||
(fn
|
|
||||||
(name got expected)
|
|
||||||
(if
|
|
||||||
(acl-et-eq? got expected)
|
|
||||||
(set! acl-et-pass (+ acl-et-pass 1))
|
|
||||||
(do
|
|
||||||
(set! acl-et-fail (+ acl-et-fail 1))
|
|
||||||
(append!
|
|
||||||
acl-et-failures
|
|
||||||
(str name "\n expected: " expected "\n got: " got))))))
|
|
||||||
|
|
||||||
;; --- proof-tree walkers ---
|
|
||||||
|
|
||||||
;; True if EDB fact `target` appears as a base leaf anywhere in the proof.
|
|
||||||
(define
|
|
||||||
acl-et-has-leaf?
|
|
||||||
(fn
|
|
||||||
(node target)
|
|
||||||
(cond
|
|
||||||
((= node nil) false)
|
|
||||||
((and (dict? node) (has-key? node :via))
|
|
||||||
(acl-et-eq? (get node :fact) target))
|
|
||||||
((and (dict? node) (has-key? node :body))
|
|
||||||
(acl-et-any-leaf? (get node :body) target))
|
|
||||||
(else false))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-et-any-leaf?
|
|
||||||
(fn
|
|
||||||
(nodes target)
|
|
||||||
(cond
|
|
||||||
((= (len nodes) 0) false)
|
|
||||||
((acl-et-has-leaf? (first nodes) target) true)
|
|
||||||
(else (acl-et-any-leaf? (rest nodes) target)))))
|
|
||||||
|
|
||||||
;; True if the proof records a verified negation (deny did not fire).
|
|
||||||
(define
|
|
||||||
acl-et-has-negok?
|
|
||||||
(fn
|
|
||||||
(node)
|
|
||||||
(cond
|
|
||||||
((= node nil) false)
|
|
||||||
((and (dict? node) (has-key? node :neg-ok)) true)
|
|
||||||
((and (dict? node) (has-key? node :body))
|
|
||||||
(acl-et-any-negok? (get node :body)))
|
|
||||||
(else false))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-et-any-negok?
|
|
||||||
(fn
|
|
||||||
(nodes)
|
|
||||||
(cond
|
|
||||||
((= (len nodes) 0) false)
|
|
||||||
((acl-et-has-negok? (first nodes)) true)
|
|
||||||
(else (acl-et-any-negok? (rest nodes))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-et-run-all!
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(do
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-grant (quote u) (quote read) (quote p))))))
|
|
||||||
(let
|
|
||||||
((e (acl-explain db (quote u) (quote read) (quote p))))
|
|
||||||
(do
|
|
||||||
(acl-et-check! "direct: allowed?" (get e :allowed?) true)
|
|
||||||
(acl-et-check!
|
|
||||||
"direct: proof root fact"
|
|
||||||
(get (get e :proof) :fact)
|
|
||||||
(list (quote permit) (quote u) (quote read) (quote p)))
|
|
||||||
(acl-et-check!
|
|
||||||
"direct: grant leaf present"
|
|
||||||
(acl-et-has-leaf?
|
|
||||||
(get e :proof)
|
|
||||||
(list (quote grant) (quote u) (quote read) (quote p)))
|
|
||||||
true)
|
|
||||||
(acl-et-check!
|
|
||||||
"direct: negation verified"
|
|
||||||
(acl-et-has-negok? (get e :proof))
|
|
||||||
true)
|
|
||||||
(acl-et-check!
|
|
||||||
"direct: reason nil when allowed"
|
|
||||||
(get e :reason)
|
|
||||||
nil))))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-member-of (quote team) (quote org)) (acl-grant (quote org) (quote read) (quote doc))))))
|
|
||||||
(let
|
|
||||||
((e (acl-explain db (quote alice) (quote read) (quote doc))))
|
|
||||||
(do
|
|
||||||
(acl-et-check! "group: allowed?" (get e :allowed?) true)
|
|
||||||
(acl-et-check!
|
|
||||||
"group: member_of alice leaf"
|
|
||||||
(acl-et-has-leaf?
|
|
||||||
(get e :proof)
|
|
||||||
(list (quote member_of) (quote alice) (quote team)))
|
|
||||||
true)
|
|
||||||
(acl-et-check!
|
|
||||||
"group: member_of team leaf"
|
|
||||||
(acl-et-has-leaf?
|
|
||||||
(get e :proof)
|
|
||||||
(list (quote member_of) (quote team) (quote org)))
|
|
||||||
true)
|
|
||||||
(acl-et-check!
|
|
||||||
"group: grant org leaf at base"
|
|
||||||
(acl-et-has-leaf?
|
|
||||||
(get e :proof)
|
|
||||||
(list (quote grant) (quote org) (quote read) (quote doc)))
|
|
||||||
true))))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-child-of (quote sec) (quote book)) (acl-grant (quote u) (quote read) (quote book))))))
|
|
||||||
(let
|
|
||||||
((e (acl-explain db (quote u) (quote read) (quote sec))))
|
|
||||||
(do
|
|
||||||
(acl-et-check! "resource: allowed?" (get e :allowed?) true)
|
|
||||||
(acl-et-check!
|
|
||||||
"resource: child_of leaf"
|
|
||||||
(acl-et-has-leaf?
|
|
||||||
(get e :proof)
|
|
||||||
(list (quote child_of) (quote sec) (quote book)))
|
|
||||||
true)
|
|
||||||
(acl-et-check!
|
|
||||||
"resource: grant on parent leaf"
|
|
||||||
(acl-et-has-leaf?
|
|
||||||
(get e :proof)
|
|
||||||
(list (quote grant) (quote u) (quote read) (quote book)))
|
|
||||||
true))))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-member-of (quote bob) (quote editor)) (acl-role-grant (quote editor) (quote edit) (quote page1))))))
|
|
||||||
(let
|
|
||||||
((e (acl-explain db (quote bob) (quote edit) (quote page1))))
|
|
||||||
(do
|
|
||||||
(acl-et-check! "role: allowed?" (get e :allowed?) true)
|
|
||||||
(acl-et-check!
|
|
||||||
"role: member_of leaf"
|
|
||||||
(acl-et-has-leaf?
|
|
||||||
(get e :proof)
|
|
||||||
(list (quote member_of) (quote bob) (quote editor)))
|
|
||||||
true)
|
|
||||||
(acl-et-check!
|
|
||||||
"role: role_grant leaf"
|
|
||||||
(acl-et-has-leaf?
|
|
||||||
(get e :proof)
|
|
||||||
(list
|
|
||||||
(quote role_grant)
|
|
||||||
(quote editor)
|
|
||||||
(quote edit)
|
|
||||||
(quote page1)))
|
|
||||||
true))))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-grant (quote u) (quote edit) (quote p)) (acl-deny (quote u) (quote edit) (quote p))))))
|
|
||||||
(let
|
|
||||||
((e (acl-explain db (quote u) (quote edit) (quote p))))
|
|
||||||
(do
|
|
||||||
(acl-et-check! "deny: not allowed" (get e :allowed?) false)
|
|
||||||
(acl-et-check! "deny: no proof" (get e :proof) nil)
|
|
||||||
(acl-et-check!
|
|
||||||
"deny: reason root is eff_deny"
|
|
||||||
(get (get e :reason) :fact)
|
|
||||||
(list (quote eff_deny) (quote u) (quote edit) (quote p)))
|
|
||||||
(acl-et-check!
|
|
||||||
"deny: reason has deny leaf"
|
|
||||||
(acl-et-has-leaf?
|
|
||||||
(get e :reason)
|
|
||||||
(list (quote deny) (quote u) (quote edit) (quote p)))
|
|
||||||
true))))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote alice) (quote read) (quote doc)) (acl-deny (quote team) (quote read) (quote doc))))))
|
|
||||||
(let
|
|
||||||
((e (acl-explain db (quote alice) (quote read) (quote doc))))
|
|
||||||
(do
|
|
||||||
(acl-et-check!
|
|
||||||
"inherited deny: not allowed"
|
|
||||||
(get e :allowed?)
|
|
||||||
false)
|
|
||||||
(acl-et-check!
|
|
||||||
"inherited deny: reason has member_of leaf"
|
|
||||||
(acl-et-has-leaf?
|
|
||||||
(get e :reason)
|
|
||||||
(list (quote member_of) (quote alice) (quote team)))
|
|
||||||
true)
|
|
||||||
(acl-et-check!
|
|
||||||
"inherited deny: reason has group deny leaf"
|
|
||||||
(acl-et-has-leaf?
|
|
||||||
(get e :reason)
|
|
||||||
(list (quote deny) (quote team) (quote read) (quote doc)))
|
|
||||||
true))))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list))))
|
|
||||||
(let
|
|
||||||
((e (acl-explain db (quote u) (quote read) (quote p))))
|
|
||||||
(do
|
|
||||||
(acl-et-check! "no grant: not allowed" (get e :allowed?) false)
|
|
||||||
(acl-et-check! "no grant: proof nil" (get e :proof) nil)
|
|
||||||
(acl-et-check! "no grant: reason nil" (get e :reason) nil))))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-grant (quote u) (quote read) (quote p)) (acl-deny (quote u) (quote edit) (quote p))))))
|
|
||||||
(do
|
|
||||||
(acl-audit-clear!)
|
|
||||||
(acl-et-check! "audit: starts empty" (acl-audit-count) 0)
|
|
||||||
(acl-et-check!
|
|
||||||
"audit decide allowed returns true"
|
|
||||||
(acl-audit-decide! db (quote u) (quote read) (quote p))
|
|
||||||
true)
|
|
||||||
(acl-et-check!
|
|
||||||
"audit decide denied returns false"
|
|
||||||
(acl-audit-decide! db (quote u) (quote edit) (quote p))
|
|
||||||
false)
|
|
||||||
(acl-audit-decide! db (quote u) (quote comment) (quote p))
|
|
||||||
(acl-et-check!
|
|
||||||
"audit: count after three decisions"
|
|
||||||
(acl-audit-count)
|
|
||||||
3)
|
|
||||||
(acl-et-check!
|
|
||||||
"audit: tail size respects n"
|
|
||||||
(len (acl-audit-tail 2))
|
|
||||||
2)
|
|
||||||
(acl-et-check!
|
|
||||||
"audit: tail returns most recent"
|
|
||||||
(get (first (acl-audit-tail 1)) :act)
|
|
||||||
(quote comment))
|
|
||||||
(acl-et-check!
|
|
||||||
"audit: first record seq is 0"
|
|
||||||
(get (first (acl-audit-tail 3)) :seq)
|
|
||||||
0)
|
|
||||||
(acl-et-check!
|
|
||||||
"audit: allowed flag recorded"
|
|
||||||
(get (first (acl-audit-tail 3)) :allowed?)
|
|
||||||
true)
|
|
||||||
(acl-et-check!
|
|
||||||
"audit: serialize line count"
|
|
||||||
(len (acl-et-lines (acl-audit-serialize)))
|
|
||||||
3)
|
|
||||||
(acl-audit-clear!)
|
|
||||||
(acl-et-check!
|
|
||||||
"audit: clear resets count"
|
|
||||||
(acl-audit-count)
|
|
||||||
0))))))
|
|
||||||
|
|
||||||
;; count newline-terminated lines in a serialized log
|
|
||||||
(define acl-et-lines (fn (s) (acl-et-count-nl s 0 0)))
|
|
||||||
(define
|
|
||||||
acl-et-count-nl
|
|
||||||
(fn
|
|
||||||
(s i n)
|
|
||||||
(if
|
|
||||||
(>= i (len s))
|
|
||||||
(if (= n 0) (list) (acl-et-rangelist n))
|
|
||||||
(acl-et-count-nl
|
|
||||||
s
|
|
||||||
(+ i 1)
|
|
||||||
(if (= (slice s i (+ i 1)) "\n") (+ n 1) n)))))
|
|
||||||
(define
|
|
||||||
acl-et-rangelist
|
|
||||||
(fn
|
|
||||||
(n)
|
|
||||||
(if
|
|
||||||
(<= n 0)
|
|
||||||
(list)
|
|
||||||
(cons n (acl-et-rangelist (- n 1))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-explain-tests-run!
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(do
|
|
||||||
(set! acl-et-pass 0)
|
|
||||||
(set! acl-et-fail 0)
|
|
||||||
(set! acl-et-failures (list))
|
|
||||||
(acl-et-run-all!)
|
|
||||||
{:failures acl-et-failures :total (+ acl-et-pass acl-et-fail) :passed acl-et-pass :failed acl-et-fail})))
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
;; lib/acl/tests/fed.sx — Phase 4: federation (peer trust, delegation,
|
|
||||||
;; cross-instance chains, revocation). fed-sx transport is mocked as a dict.
|
|
||||||
|
|
||||||
(define acl-ft-pass 0)
|
|
||||||
(define acl-ft-fail 0)
|
|
||||||
(define acl-ft-failures (list))
|
|
||||||
|
|
||||||
;; Name-based deep equality (host `=` compares symbols by unstable interned
|
|
||||||
;; identity; see lib/acl/tests/explain.sx).
|
|
||||||
(define
|
|
||||||
acl-ft-eq?
|
|
||||||
(fn
|
|
||||||
(a b)
|
|
||||||
(cond
|
|
||||||
((and (list? a) (list? b))
|
|
||||||
(and (= (len a) (len b)) (acl-ft-eq-l? a b 0)))
|
|
||||||
((and (symbol? a) (symbol? b))
|
|
||||||
(= (symbol->string a) (symbol->string b)))
|
|
||||||
(else (= a b)))))
|
|
||||||
(define
|
|
||||||
acl-ft-eq-l?
|
|
||||||
(fn
|
|
||||||
(a b i)
|
|
||||||
(cond
|
|
||||||
((>= i (len a)) true)
|
|
||||||
((not (acl-ft-eq? (nth a i) (nth b i))) false)
|
|
||||||
(else (acl-ft-eq-l? a b (+ i 1))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-ft-check!
|
|
||||||
(fn
|
|
||||||
(name got expected)
|
|
||||||
(if
|
|
||||||
(acl-ft-eq? got expected)
|
|
||||||
(set! acl-ft-pass (+ acl-ft-pass 1))
|
|
||||||
(do
|
|
||||||
(set! acl-ft-fail (+ acl-ft-fail 1))
|
|
||||||
(append!
|
|
||||||
acl-ft-failures
|
|
||||||
(str name "\n expected: " expected "\n got: " got))))))
|
|
||||||
|
|
||||||
;; proof leaf walker (federated proofs reconstruct through the engine rule).
|
|
||||||
(define
|
|
||||||
acl-ft-has-leaf?
|
|
||||||
(fn
|
|
||||||
(node target)
|
|
||||||
(cond
|
|
||||||
((= node nil) false)
|
|
||||||
((and (dict? node) (has-key? node :via))
|
|
||||||
(acl-ft-eq? (get node :fact) target))
|
|
||||||
((and (dict? node) (has-key? node :body))
|
|
||||||
(acl-ft-any-leaf? (get node :body) target))
|
|
||||||
(else false))))
|
|
||||||
(define
|
|
||||||
acl-ft-any-leaf?
|
|
||||||
(fn
|
|
||||||
(nodes target)
|
|
||||||
(cond
|
|
||||||
((= (len nodes) 0) false)
|
|
||||||
((acl-ft-has-leaf? (first nodes) target) true)
|
|
||||||
(else (acl-ft-any-leaf? (rest nodes) target)))))
|
|
||||||
|
|
||||||
(define acl-ft-p? (fn (db s a r) (acl-permit? db s a r)))
|
|
||||||
|
|
||||||
;; A standard federation fixture: local trusts peer alpha at "readonly", which
|
|
||||||
;; covers read+comment. alpha delegates several capabilities to alice.
|
|
||||||
(define
|
|
||||||
acl-ft-fixture
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(acl-build-db
|
|
||||||
(list
|
|
||||||
(acl-trust (quote alpha) (quote readonly))
|
|
||||||
(acl-level-covers (quote readonly) (quote read))
|
|
||||||
(acl-level-covers (quote readonly) (quote comment))
|
|
||||||
(acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))
|
|
||||||
(acl-delegate (quote alpha) (quote alice) (quote edit) (quote doc))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-ft-run-all!
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(do
|
|
||||||
(let
|
|
||||||
((db (acl-ft-fixture)))
|
|
||||||
(do
|
|
||||||
(acl-ft-check!
|
|
||||||
"trusted delegate, level covers action -> permit"
|
|
||||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
true)
|
|
||||||
(acl-ft-check!
|
|
||||||
"trusted delegate, level does NOT cover action -> deny"
|
|
||||||
(acl-ft-p? db (quote alice) (quote edit) (quote doc))
|
|
||||||
false)
|
|
||||||
(acl-ft-check!
|
|
||||||
"delegated but action class uncovered (comment has no delegate)"
|
|
||||||
(acl-ft-p? db (quote alice) (quote comment) (quote doc))
|
|
||||||
false)))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-level-covers (quote readonly) (quote read)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
|
|
||||||
(acl-ft-check!
|
|
||||||
"untrusted peer delegate -> deny"
|
|
||||||
(acl-ft-p? db (quote bob) (quote read) (quote doc))
|
|
||||||
false))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote readonly)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
|
|
||||||
(acl-ft-check!
|
|
||||||
"trust but no level_covers -> deny"
|
|
||||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
false))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
|
|
||||||
(do
|
|
||||||
(acl-ft-check!
|
|
||||||
"trust is per-peer: alpha's delegate applies"
|
|
||||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
true)
|
|
||||||
(acl-ft-check!
|
|
||||||
"trust not transitive: beta's delegate does not apply"
|
|
||||||
(acl-ft-p? db (quote bob) (quote read) (quote doc))
|
|
||||||
false)))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc)) (acl-deny (quote alice) (quote read) (quote doc))))))
|
|
||||||
(acl-ft-check!
|
|
||||||
"local deny overrides federated grant"
|
|
||||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
false))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-member-of (quote alice) (quote team)) (acl-delegate (quote alpha) (quote team) (quote read) (quote doc))))))
|
|
||||||
(acl-ft-check!
|
|
||||||
"federated grant to group reaches member"
|
|
||||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
true))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-child-of (quote sec) (quote book)) (acl-delegate (quote alpha) (quote u) (quote read) (quote book))))))
|
|
||||||
(acl-ft-check!
|
|
||||||
"federated grant on parent resource reaches child"
|
|
||||||
(acl-ft-p? db (quote u) (quote read) (quote sec))
|
|
||||||
true))
|
|
||||||
(let
|
|
||||||
((transport {:gamma (list (acl-delegate (quote gamma) (quote carol) (quote read) (quote post))) :alpha (list (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc)))}))
|
|
||||||
(do
|
|
||||||
(acl-ft-check!
|
|
||||||
"fetch known peer returns its delegates"
|
|
||||||
(len (acl-fed-fetch transport (quote alpha)))
|
|
||||||
1)
|
|
||||||
(acl-ft-check!
|
|
||||||
"fetch unknown peer returns empty"
|
|
||||||
(len (acl-fed-fetch transport (quote delta)))
|
|
||||||
0)
|
|
||||||
(acl-ft-check!
|
|
||||||
"collect across peers"
|
|
||||||
(len
|
|
||||||
(acl-fed-collect transport (list (quote alpha) (quote gamma))))
|
|
||||||
2)
|
|
||||||
(let
|
|
||||||
((db (acl-fed-build-db (list (acl-trust (quote alpha) (quote readonly)) (acl-trust (quote gamma) (quote readonly)) (acl-level-covers (quote readonly) (quote read))) transport (list (quote alpha) (quote gamma)))))
|
|
||||||
(do
|
|
||||||
(acl-ft-check!
|
|
||||||
"fed-build-db: alpha delegate permits"
|
|
||||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
true)
|
|
||||||
(acl-ft-check!
|
|
||||||
"fed-build-db: gamma delegate permits"
|
|
||||||
(acl-ft-p? db (quote carol) (quote read) (quote post))
|
|
||||||
true)
|
|
||||||
(acl-ft-check!
|
|
||||||
"fed-build-db: untrusted action still denied"
|
|
||||||
(acl-ft-p? db (quote alice) (quote edit) (quote doc))
|
|
||||||
false)))))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
|
|
||||||
(do
|
|
||||||
(acl-ft-check!
|
|
||||||
"before revoke: permitted"
|
|
||||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
true)
|
|
||||||
(acl-revoke!
|
|
||||||
db
|
|
||||||
(acl-delegate
|
|
||||||
(quote alpha)
|
|
||||||
(quote alice)
|
|
||||||
(quote read)
|
|
||||||
(quote doc)))
|
|
||||||
(acl-ft-check!
|
|
||||||
"after delegate revoked: denied"
|
|
||||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
false)))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
|
|
||||||
(do
|
|
||||||
(acl-ft-check!
|
|
||||||
"before trust revoke: permitted"
|
|
||||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
true)
|
|
||||||
(acl-revoke! db (acl-trust (quote alpha) (quote full)))
|
|
||||||
(acl-ft-check!
|
|
||||||
"after trust revoked: denied"
|
|
||||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
false)))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
|
|
||||||
(do
|
|
||||||
(acl-ft-check!
|
|
||||||
"delegate without trust: denied"
|
|
||||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
false)
|
|
||||||
(acl-fed-assert! db (acl-trust (quote alpha) (quote full)))
|
|
||||||
(acl-ft-check!
|
|
||||||
"trust ingested then re-checked: permitted"
|
|
||||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
true)))
|
|
||||||
(let
|
|
||||||
((db (acl-ft-fixture)))
|
|
||||||
(let
|
|
||||||
((e (acl-explain db (quote alice) (quote read) (quote doc))))
|
|
||||||
(do
|
|
||||||
(acl-ft-check! "federated proof allowed?" (get e :allowed?) true)
|
|
||||||
(acl-ft-check!
|
|
||||||
"federated proof has delegate leaf"
|
|
||||||
(acl-ft-has-leaf?
|
|
||||||
(get e :proof)
|
|
||||||
(list
|
|
||||||
(quote delegate)
|
|
||||||
(quote alpha)
|
|
||||||
(quote alice)
|
|
||||||
(quote read)
|
|
||||||
(quote doc)))
|
|
||||||
true)
|
|
||||||
(acl-ft-check!
|
|
||||||
"federated proof has trust leaf"
|
|
||||||
(acl-ft-has-leaf?
|
|
||||||
(get e :proof)
|
|
||||||
(list (quote trust) (quote alpha) (quote readonly)))
|
|
||||||
true)
|
|
||||||
(acl-ft-check!
|
|
||||||
"federated proof has level_covers leaf"
|
|
||||||
(acl-ft-has-leaf?
|
|
||||||
(get e :proof)
|
|
||||||
(list (quote level_covers) (quote readonly) (quote read)))
|
|
||||||
true))))
|
|
||||||
(acl-ft-check!
|
|
||||||
"schema delegate arity valid"
|
|
||||||
(acl-fact-valid?
|
|
||||||
(acl-delegate (quote p) (quote s) (quote a) (quote r)))
|
|
||||||
true)
|
|
||||||
(acl-ft-check!
|
|
||||||
"schema trust arity valid"
|
|
||||||
(acl-fact-valid? (acl-trust (quote p) (quote l)))
|
|
||||||
true)
|
|
||||||
(acl-ft-check!
|
|
||||||
"schema peer arity valid"
|
|
||||||
(acl-fact-valid? (acl-peer (quote p) (quote peer)))
|
|
||||||
true)
|
|
||||||
(acl-ft-check!
|
|
||||||
"schema level_covers arity valid"
|
|
||||||
(acl-fact-valid? (acl-level-covers (quote l) (quote read)))
|
|
||||||
true)
|
|
||||||
(acl-ft-check!
|
|
||||||
"schema delegate bad arity invalid"
|
|
||||||
(acl-fact-valid? (list (quote delegate) (quote p) (quote s)))
|
|
||||||
false))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-fed-tests-run!
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(do
|
|
||||||
(set! acl-ft-pass 0)
|
|
||||||
(set! acl-ft-fail 0)
|
|
||||||
(set! acl-ft-failures (list))
|
|
||||||
(acl-ft-run-all!)
|
|
||||||
{:failures acl-ft-failures :total (+ acl-ft-pass acl-ft-fail) :passed acl-ft-pass :failed acl-ft-fail})))
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
;; lib/acl/tests/harden.sx — adversarial / cross-phase hardening.
|
|
||||||
;;
|
|
||||||
;; Diamond hierarchies, conflict resolution where deny must win through every
|
|
||||||
;; path, chain inheritance, cycle termination, multi-peer delegation, fact
|
|
||||||
;; validation, and audit save/restore.
|
|
||||||
;;
|
|
||||||
;; PROVER-FREE BY DESIGN: this suite calls only acl-permit? (which runs in
|
|
||||||
;; compiled Datalog, safe at any depth) plus pure data ops — never acl-explain /
|
|
||||||
;; acl-prove-d. The SX-side proof reconstructor recurses, and once the kernel
|
|
||||||
;; JIT-compiles it (after the explain/fed suites warm the process) it loops on
|
|
||||||
;; chains deeper than ~3 (substrate JIT bug — see plan Blockers). Proof
|
|
||||||
;; reconstruction is covered by tests/explain.sx (and federated proofs by
|
|
||||||
;; tests/fed.sx), both of which stay under the warm-process depth threshold.
|
|
||||||
|
|
||||||
(define acl-hd-pass 0)
|
|
||||||
(define acl-hd-fail 0)
|
|
||||||
(define acl-hd-failures (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-hd-check!
|
|
||||||
(fn
|
|
||||||
(name got expected)
|
|
||||||
(if
|
|
||||||
(= got expected)
|
|
||||||
(set! acl-hd-pass (+ acl-hd-pass 1))
|
|
||||||
(do
|
|
||||||
(set! acl-hd-fail (+ acl-hd-fail 1))
|
|
||||||
(append!
|
|
||||||
acl-hd-failures
|
|
||||||
(str name "\n expected: " expected "\n got: " got))))))
|
|
||||||
|
|
||||||
(define acl-hd-p? (fn (db s a r) (acl-permit? db s a r)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-hd-run-all!
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(do
|
|
||||||
(let
|
|
||||||
((grant-deny (acl-build-db (list (acl-child-of (quote r) (quote p1)) (acl-child-of (quote r) (quote p2)) (acl-grant (quote u) (quote read) (quote p1)) (acl-deny (quote u) (quote read) (quote p2)))))
|
|
||||||
(both-grant
|
|
||||||
(acl-build-db
|
|
||||||
(list
|
|
||||||
(acl-child-of (quote r) (quote p1))
|
|
||||||
(acl-child-of (quote r) (quote p2))
|
|
||||||
(acl-grant (quote u) (quote read) (quote p1))
|
|
||||||
(acl-grant (quote u) (quote read) (quote p2))))))
|
|
||||||
(do
|
|
||||||
(acl-hd-check!
|
|
||||||
"diamond resource: grant+deny parents -> deny wins"
|
|
||||||
(acl-hd-p? grant-deny (quote u) (quote read) (quote r))
|
|
||||||
false)
|
|
||||||
(acl-hd-check!
|
|
||||||
"diamond resource: both grant -> permit"
|
|
||||||
(acl-hd-p? both-grant (quote u) (quote read) (quote r))
|
|
||||||
true)
|
|
||||||
(acl-hd-check!
|
|
||||||
"diamond resource: deny does not leak to other parent"
|
|
||||||
(acl-hd-p? grant-deny (quote u) (quote read) (quote p1))
|
|
||||||
true)))
|
|
||||||
(let
|
|
||||||
((grant-deny (acl-build-db (list (acl-member-of (quote alice) (quote g1)) (acl-member-of (quote alice) (quote g2)) (acl-grant (quote g1) (quote read) (quote doc)) (acl-deny (quote g2) (quote read) (quote doc)))))
|
|
||||||
(both-grant
|
|
||||||
(acl-build-db
|
|
||||||
(list
|
|
||||||
(acl-member-of (quote alice) (quote g1))
|
|
||||||
(acl-member-of (quote alice) (quote g2))
|
|
||||||
(acl-grant (quote g1) (quote read) (quote doc))
|
|
||||||
(acl-grant (quote g2) (quote read) (quote doc))))))
|
|
||||||
(do
|
|
||||||
(acl-hd-check!
|
|
||||||
"diamond group: grant+deny groups -> deny wins"
|
|
||||||
(acl-hd-p? grant-deny (quote alice) (quote read) (quote doc))
|
|
||||||
false)
|
|
||||||
(acl-hd-check!
|
|
||||||
"diamond group: both grant -> permit"
|
|
||||||
(acl-hd-p? both-grant (quote alice) (quote read) (quote doc))
|
|
||||||
true)))
|
|
||||||
(let
|
|
||||||
((chain (acl-build-db (list (acl-member-of (quote a0) (quote a1)) (acl-member-of (quote a1) (quote a2)) (acl-member-of (quote a2) (quote a3)) (acl-member-of (quote a3) (quote a4)) (acl-grant (quote a4) (quote read) (quote res)))))
|
|
||||||
(chain-deny
|
|
||||||
(acl-build-db
|
|
||||||
(list
|
|
||||||
(acl-member-of (quote a0) (quote a1))
|
|
||||||
(acl-member-of (quote a1) (quote a2))
|
|
||||||
(acl-member-of (quote a2) (quote a3))
|
|
||||||
(acl-member-of (quote a3) (quote a4))
|
|
||||||
(acl-grant (quote a4) (quote read) (quote res))
|
|
||||||
(acl-deny (quote a0) (quote read) (quote res))))))
|
|
||||||
(do
|
|
||||||
(acl-hd-check!
|
|
||||||
"chain: top-group grant reaches leaf member"
|
|
||||||
(acl-hd-p? chain (quote a0) (quote read) (quote res))
|
|
||||||
true)
|
|
||||||
(acl-hd-check!
|
|
||||||
"chain: intermediate also covered"
|
|
||||||
(acl-hd-p? chain (quote a2) (quote read) (quote res))
|
|
||||||
true)
|
|
||||||
(acl-hd-check!
|
|
||||||
"chain: leaf-member deny overrides top grant"
|
|
||||||
(acl-hd-p? chain-deny (quote a0) (quote read) (quote res))
|
|
||||||
false)
|
|
||||||
(acl-hd-check!
|
|
||||||
"chain: deny on leaf does not block sibling level"
|
|
||||||
(acl-hd-p? chain-deny (quote a1) (quote read) (quote res))
|
|
||||||
true)))
|
|
||||||
(let
|
|
||||||
((self-member (acl-build-db (list (acl-member-of (quote a) (quote a)) (acl-grant (quote a) (quote read) (quote r)))))
|
|
||||||
(self-child
|
|
||||||
(acl-build-db
|
|
||||||
(list
|
|
||||||
(acl-child-of (quote r) (quote r))
|
|
||||||
(acl-grant (quote u) (quote read) (quote r)))))
|
|
||||||
(two-cycle
|
|
||||||
(acl-build-db
|
|
||||||
(list
|
|
||||||
(acl-member-of (quote x) (quote y))
|
|
||||||
(acl-member-of (quote y) (quote x))
|
|
||||||
(acl-grant (quote y) (quote read) (quote r))))))
|
|
||||||
(do
|
|
||||||
(acl-hd-check!
|
|
||||||
"self-membership cycle terminates and grants"
|
|
||||||
(acl-hd-p? self-member (quote a) (quote read) (quote r))
|
|
||||||
true)
|
|
||||||
(acl-hd-check!
|
|
||||||
"self-child cycle terminates and grants"
|
|
||||||
(acl-hd-p? self-child (quote u) (quote read) (quote r))
|
|
||||||
true)
|
|
||||||
(acl-hd-check!
|
|
||||||
"two-node membership cycle terminates"
|
|
||||||
(acl-hd-p? two-cycle (quote x) (quote read) (quote r))
|
|
||||||
true)))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-member-of (quote alice) (quote team)) (acl-delegate (quote alpha) (quote team) (quote read) (quote doc)) (acl-deny (quote alice) (quote read) (quote doc))))))
|
|
||||||
(acl-hd-check!
|
|
||||||
"federated group grant, local member deny -> deny wins"
|
|
||||||
(acl-hd-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
false))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote bob) (quote read) (quote doc)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
|
|
||||||
(acl-hd-check!
|
|
||||||
"two peers delegate, one trusted -> permit"
|
|
||||||
(acl-hd-p? db (quote bob) (quote read) (quote doc))
|
|
||||||
true))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-trust (quote beta) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote bob) (quote read) (quote doc)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
|
|
||||||
(acl-hd-check!
|
|
||||||
"two peers both trusted -> permit"
|
|
||||||
(acl-hd-p? db (quote bob) (quote read) (quote doc))
|
|
||||||
true))
|
|
||||||
(let
|
|
||||||
((empty (acl-build-db (list))))
|
|
||||||
(acl-hd-check!
|
|
||||||
"empty db: nothing permitted"
|
|
||||||
(acl-hd-p? empty (quote u) (quote read) (quote r))
|
|
||||||
false))
|
|
||||||
(do
|
|
||||||
(acl-hd-check!
|
|
||||||
"validate: clean set has no bad facts"
|
|
||||||
(len
|
|
||||||
(acl-validate-facts
|
|
||||||
(list
|
|
||||||
(acl-grant (quote u) (quote read) (quote p))
|
|
||||||
(acl-member-of (quote u) (quote g))
|
|
||||||
(acl-delegate (quote pe) (quote u) (quote read) (quote p)))))
|
|
||||||
0)
|
|
||||||
(acl-hd-check!
|
|
||||||
"validate: facts-valid? true on clean set"
|
|
||||||
(acl-facts-valid?
|
|
||||||
(list (acl-grant (quote u) (quote read) (quote p))))
|
|
||||||
true)
|
|
||||||
(acl-hd-check!
|
|
||||||
"validate: surfaces wrong-arity and unknown predicate"
|
|
||||||
(len
|
|
||||||
(acl-validate-facts
|
|
||||||
(list
|
|
||||||
(acl-grant (quote u) (quote read) (quote p))
|
|
||||||
(list (quote grant) (quote u))
|
|
||||||
(list (quote bogus) (quote x) (quote y)))))
|
|
||||||
2)
|
|
||||||
(acl-hd-check!
|
|
||||||
"validate: empty set is valid"
|
|
||||||
(acl-facts-valid? (list))
|
|
||||||
true))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-grant (quote u) (quote read) (quote p)) (acl-deny (quote u) (quote edit) (quote p))))))
|
|
||||||
(do
|
|
||||||
(acl-audit-clear!)
|
|
||||||
(acl-audit-decide! db (quote u) (quote read) (quote p))
|
|
||||||
(acl-audit-decide! db (quote u) (quote edit) (quote p))
|
|
||||||
(let
|
|
||||||
((snap (acl-audit-snapshot)))
|
|
||||||
(do
|
|
||||||
(acl-audit-clear!)
|
|
||||||
(acl-hd-check!
|
|
||||||
"audit: cleared count is 0"
|
|
||||||
(acl-audit-count)
|
|
||||||
0)
|
|
||||||
(acl-audit-restore! snap)
|
|
||||||
(acl-hd-check!
|
|
||||||
"audit: restored count"
|
|
||||||
(acl-audit-count)
|
|
||||||
2)
|
|
||||||
(acl-hd-check!
|
|
||||||
"audit: restored last act"
|
|
||||||
(get (first (acl-audit-tail 1)) :act)
|
|
||||||
(quote edit))
|
|
||||||
(acl-audit-decide! db (quote u) (quote comment) (quote p))
|
|
||||||
(acl-hd-check!
|
|
||||||
"audit: seq continues after restore"
|
|
||||||
(get (first (acl-audit-tail 1)) :seq)
|
|
||||||
2)
|
|
||||||
(acl-hd-check!
|
|
||||||
"audit: snapshot is an immutable copy"
|
|
||||||
(len (get snap :entries))
|
|
||||||
2)
|
|
||||||
(acl-audit-clear!))))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-harden-tests-run!
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(do
|
|
||||||
(set! acl-hd-pass 0)
|
|
||||||
(set! acl-hd-fail 0)
|
|
||||||
(set! acl-hd-failures (list))
|
|
||||||
(acl-hd-run-all!)
|
|
||||||
{:failures acl-hd-failures :total (+ acl-hd-pass acl-hd-fail) :passed acl-hd-pass :failed acl-hd-fail})))
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
;; lib/acl/tests/inherit.sx — Phase 2: inheritance (groups, resource trees,
|
|
||||||
;; role expansion) with deny-overrides.
|
|
||||||
|
|
||||||
(define acl-it-pass 0)
|
|
||||||
(define acl-it-fail 0)
|
|
||||||
(define acl-it-failures (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-it-check!
|
|
||||||
(fn
|
|
||||||
(name got expected)
|
|
||||||
(if
|
|
||||||
(= got expected)
|
|
||||||
(set! acl-it-pass (+ acl-it-pass 1))
|
|
||||||
(do
|
|
||||||
(set! acl-it-fail (+ acl-it-fail 1))
|
|
||||||
(append!
|
|
||||||
acl-it-failures
|
|
||||||
(str name "\n expected: " expected "\n got: " got))))))
|
|
||||||
|
|
||||||
(define acl-it-p? (fn (db s a r) (acl-permit? db s a r)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-it-run-all!
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(do
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote team) (quote read) (quote doc))))))
|
|
||||||
(do
|
|
||||||
(acl-it-check!
|
|
||||||
"group grant reaches member"
|
|
||||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
true)
|
|
||||||
(acl-it-check!
|
|
||||||
"group grant: non-member excluded"
|
|
||||||
(acl-it-p? db (quote bob) (quote read) (quote doc))
|
|
||||||
false)
|
|
||||||
(acl-it-check!
|
|
||||||
"group grant: wrong action"
|
|
||||||
(acl-it-p? db (quote alice) (quote edit) (quote doc))
|
|
||||||
false)))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-member-of (quote team) (quote org)) (acl-member-of (quote org) (quote company)) (acl-grant (quote company) (quote read) (quote doc))))))
|
|
||||||
(do
|
|
||||||
(acl-it-check!
|
|
||||||
"deep nested group grant reaches leaf member"
|
|
||||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
true)
|
|
||||||
(acl-it-check!
|
|
||||||
"intermediate group also covered"
|
|
||||||
(acl-it-p? db (quote team) (quote read) (quote doc))
|
|
||||||
true)
|
|
||||||
(acl-it-check!
|
|
||||||
"mid group org covered"
|
|
||||||
(acl-it-p? db (quote org) (quote read) (quote doc))
|
|
||||||
true)))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-member-of (quote a) (quote b)) (acl-member-of (quote b) (quote a)) (acl-grant (quote b) (quote read) (quote r))))))
|
|
||||||
(do
|
|
||||||
(acl-it-check!
|
|
||||||
"cyclic membership terminates and grants"
|
|
||||||
(acl-it-p? db (quote a) (quote read) (quote r))
|
|
||||||
true)
|
|
||||||
(acl-it-check!
|
|
||||||
"cyclic membership covers both"
|
|
||||||
(acl-it-p? db (quote b) (quote read) (quote r))
|
|
||||||
true)))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-child-of (quote sec) (quote chap)) (acl-child-of (quote chap) (quote book)) (acl-grant (quote u) (quote read) (quote book))))))
|
|
||||||
(do
|
|
||||||
(acl-it-check!
|
|
||||||
"parent grant reaches direct child"
|
|
||||||
(acl-it-p? db (quote u) (quote read) (quote chap))
|
|
||||||
true)
|
|
||||||
(acl-it-check!
|
|
||||||
"parent grant reaches deep descendant"
|
|
||||||
(acl-it-p? db (quote u) (quote read) (quote sec))
|
|
||||||
true)
|
|
||||||
(acl-it-check!
|
|
||||||
"parent grant covers parent itself"
|
|
||||||
(acl-it-p? db (quote u) (quote read) (quote book))
|
|
||||||
true)
|
|
||||||
(acl-it-check!
|
|
||||||
"child grant does not climb to parent"
|
|
||||||
(acl-it-p?
|
|
||||||
(acl-build-db
|
|
||||||
(list
|
|
||||||
(acl-child-of (quote sec) (quote book))
|
|
||||||
(acl-grant (quote u) (quote read) (quote sec))))
|
|
||||||
(quote u)
|
|
||||||
(quote read)
|
|
||||||
(quote book))
|
|
||||||
false)))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-child-of (quote post1) (quote board)) (acl-grant (quote team) (quote comment) (quote board))))))
|
|
||||||
(do
|
|
||||||
(acl-it-check!
|
|
||||||
"group + resource: member on child resource"
|
|
||||||
(acl-it-p? db (quote alice) (quote comment) (quote post1))
|
|
||||||
true)
|
|
||||||
(acl-it-check!
|
|
||||||
"group + resource: member on parent resource"
|
|
||||||
(acl-it-p? db (quote alice) (quote comment) (quote board))
|
|
||||||
true)))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-member-of (quote bob) (quote editor)) (acl-role-grant (quote editor) (quote edit) (quote page1)) (acl-role-grant (quote editor) (quote read) (quote page1))))))
|
|
||||||
(do
|
|
||||||
(acl-it-check!
|
|
||||||
"role confers edit to member"
|
|
||||||
(acl-it-p? db (quote bob) (quote edit) (quote page1))
|
|
||||||
true)
|
|
||||||
(acl-it-check!
|
|
||||||
"role confers read to member"
|
|
||||||
(acl-it-p? db (quote bob) (quote read) (quote page1))
|
|
||||||
true)
|
|
||||||
(acl-it-check!
|
|
||||||
"role: capability not in tuple denied"
|
|
||||||
(acl-it-p? db (quote bob) (quote moderate) (quote page1))
|
|
||||||
false)
|
|
||||||
(acl-it-check!
|
|
||||||
"role: non-member excluded"
|
|
||||||
(acl-it-p? db (quote eve) (quote edit) (quote page1))
|
|
||||||
false)))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-member-of (quote bob) (quote editor)) (acl-child-of (quote draft) (quote page1)) (acl-role-grant (quote editor) (quote edit) (quote page1))))))
|
|
||||||
(acl-it-check!
|
|
||||||
"role grant flows to child resource"
|
|
||||||
(acl-it-p? db (quote bob) (quote edit) (quote draft))
|
|
||||||
true))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote team) (quote read) (quote doc)) (acl-deny (quote alice) (quote read) (quote doc))))))
|
|
||||||
(acl-it-check!
|
|
||||||
"explicit deny beats inherited group allow"
|
|
||||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
false))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote alice) (quote read) (quote doc)) (acl-deny (quote team) (quote read) (quote doc))))))
|
|
||||||
(do
|
|
||||||
(acl-it-check!
|
|
||||||
"group deny inherits and overrides direct grant"
|
|
||||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
false)
|
|
||||||
(acl-it-check!
|
|
||||||
"group deny: another member also blocked"
|
|
||||||
(acl-it-p? db (quote team) (quote read) (quote doc))
|
|
||||||
false)))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-child-of (quote sec) (quote book)) (acl-grant (quote u) (quote read) (quote sec)) (acl-deny (quote u) (quote read) (quote book))))))
|
|
||||||
(acl-it-check!
|
|
||||||
"ancestor deny overrides descendant grant"
|
|
||||||
(acl-it-p? db (quote u) (quote read) (quote sec))
|
|
||||||
false))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote team) (quote read) (quote doc)) (acl-grant (quote team) (quote edit) (quote doc)) (acl-deny (quote alice) (quote edit) (quote doc))))))
|
|
||||||
(do
|
|
||||||
(acl-it-check!
|
|
||||||
"deny on edit leaves inherited read intact"
|
|
||||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
true)
|
|
||||||
(acl-it-check!
|
|
||||||
"deny on edit blocks edit"
|
|
||||||
(acl-it-p? db (quote alice) (quote edit) (quote doc))
|
|
||||||
false)))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-deny (quote team) (quote read) (quote doc))))))
|
|
||||||
(acl-it-check!
|
|
||||||
"inherited deny, no grant: denied"
|
|
||||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
|
||||||
false))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-child-of (quote a) (quote root)) (acl-child-of (quote b) (quote root)) (acl-grant (quote u) (quote read) (quote root)) (acl-deny (quote u) (quote read) (quote a))))))
|
|
||||||
(do
|
|
||||||
(acl-it-check!
|
|
||||||
"deny on sibling a blocks a"
|
|
||||||
(acl-it-p? db (quote u) (quote read) (quote a))
|
|
||||||
false)
|
|
||||||
(acl-it-check!
|
|
||||||
"deny on sibling a leaves b permitted"
|
|
||||||
(acl-it-p? db (quote u) (quote read) (quote b))
|
|
||||||
true)
|
|
||||||
(acl-it-check!
|
|
||||||
"root itself still permitted"
|
|
||||||
(acl-it-p? db (quote u) (quote read) (quote root))
|
|
||||||
true)))
|
|
||||||
(let
|
|
||||||
((db (acl-build-db (list (acl-grant (quote x) (quote read) (quote y))))))
|
|
||||||
(acl-it-check!
|
|
||||||
"direct grant under inheritance ruleset"
|
|
||||||
(acl-it-p? db (quote x) (quote read) (quote y))
|
|
||||||
true)))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
acl-inherit-tests-run!
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(do
|
|
||||||
(set! acl-it-pass 0)
|
|
||||||
(set! acl-it-fail 0)
|
|
||||||
(set! acl-it-failures (list))
|
|
||||||
(acl-it-run-all!)
|
|
||||||
{:failures acl-it-failures :total (+ acl-it-pass acl-it-fail) :passed acl-it-pass :failed acl-it-fail})))
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# APL conformance config — sourced by lib/guest/conformance.sh.
|
|
||||||
|
|
||||||
LANG_NAME=apl
|
|
||||||
MODE=counters
|
|
||||||
COUNTERS_PASS=apl-test-pass
|
|
||||||
COUNTERS_FAIL=apl-test-fail
|
|
||||||
TIMEOUT_PER_SUITE=300
|
|
||||||
|
|
||||||
PRELOADS=(
|
|
||||||
spec/stdlib.sx
|
|
||||||
lib/r7rs.sx
|
|
||||||
lib/apl/runtime.sx
|
|
||||||
lib/apl/tokenizer.sx
|
|
||||||
lib/apl/parser.sx
|
|
||||||
lib/apl/transpile.sx
|
|
||||||
lib/apl/test-harness.sx
|
|
||||||
)
|
|
||||||
|
|
||||||
SUITES=(
|
|
||||||
"structural:lib/apl/tests/structural.sx"
|
|
||||||
"operators:lib/apl/tests/operators.sx"
|
|
||||||
"dfn:lib/apl/tests/dfn.sx"
|
|
||||||
"tradfn:lib/apl/tests/tradfn.sx"
|
|
||||||
"valence:lib/apl/tests/valence.sx"
|
|
||||||
"programs:lib/apl/tests/programs.sx"
|
|
||||||
"system:lib/apl/tests/system.sx"
|
|
||||||
"idioms:lib/apl/tests/idioms.sx"
|
|
||||||
"eval-ops:lib/apl/tests/eval-ops.sx"
|
|
||||||
"pipeline:lib/apl/tests/pipeline.sx"
|
|
||||||
)
|
|
||||||
|
|
||||||
emit_scoreboard_json() {
|
|
||||||
local n=${#GC_NAMES[@]} i sep
|
|
||||||
printf '{\n'
|
|
||||||
printf ' "suites": {\n'
|
|
||||||
for ((i=0; i<n; i++)); do
|
|
||||||
sep=","; [ $i -eq $((n-1)) ] && sep=""
|
|
||||||
printf ' "%s": {"pass": %d, "fail": %d}%s\n' \
|
|
||||||
"${GC_NAMES[$i]}" "${GC_PASS[$i]}" "${GC_FAIL[$i]}" "$sep"
|
|
||||||
done
|
|
||||||
printf ' },\n'
|
|
||||||
printf ' "total_pass": %d,\n' "$GC_TOTAL_PASS"
|
|
||||||
printf ' "total_fail": %d,\n' "$GC_TOTAL_FAIL"
|
|
||||||
printf ' "total": %d\n' "$GC_TOTAL"
|
|
||||||
printf '}\n'
|
|
||||||
}
|
|
||||||
|
|
||||||
emit_scoreboard_md() {
|
|
||||||
local n=${#GC_NAMES[@]} i
|
|
||||||
printf '# APL Conformance Scoreboard\n\n'
|
|
||||||
printf '_Generated by `lib/apl/conformance.sh`_\n\n'
|
|
||||||
printf '| Suite | Pass | Fail | Total |\n'
|
|
||||||
printf '|-------|-----:|-----:|------:|\n'
|
|
||||||
for ((i=0; i<n; i++)); do
|
|
||||||
printf '| %s | %d | %d | %d |\n' \
|
|
||||||
"${GC_NAMES[$i]}" "${GC_PASS[$i]}" "${GC_FAIL[$i]}" "${GC_TOTAL_S[$i]}"
|
|
||||||
done
|
|
||||||
printf '| **Total** | **%d** | **%d** | **%d** |\n' "$GC_TOTAL_PASS" "$GC_TOTAL_FAIL" "$GC_TOTAL"
|
|
||||||
printf '\n'
|
|
||||||
printf '## Notes\n\n'
|
|
||||||
printf '%s\n' '- Suites use the standard `apl-test name got expected` framework loaded against `lib/apl/runtime.sx` + `lib/apl/transpile.sx`.'
|
|
||||||
printf '%s\n' '- `lib/apl/tests/parse.sx` and `lib/apl/tests/scalar.sx` use their own self-contained frameworks and are excluded from this scoreboard.'
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,116 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# lib/apl/conformance.sh — APL conformance via the shared guest driver.
|
# lib/apl/conformance.sh — run APL test suites, emit scoreboard.json + scoreboard.md.
|
||||||
# Config lives in lib/apl/conformance.conf (MODE=counters). Override the binary
|
|
||||||
# with SX_SERVER=path/to/sx_server.exe bash lib/apl/conformance.sh
|
set -uo pipefail
|
||||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
SX_SERVER="${SX_SERVER:-/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe}"
|
||||||
|
if [ ! -x "$SX_SERVER" ]; then
|
||||||
|
SX_SERVER="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
|
||||||
|
|
||||||
|
SUITES=(structural operators dfn tradfn valence programs system idioms eval-ops pipeline)
|
||||||
|
|
||||||
|
OUT_JSON="lib/apl/scoreboard.json"
|
||||||
|
OUT_MD="lib/apl/scoreboard.md"
|
||||||
|
|
||||||
|
run_suite() {
|
||||||
|
local suite=$1
|
||||||
|
local file="lib/apl/tests/${suite}.sx"
|
||||||
|
local TMP
|
||||||
|
TMP=$(mktemp)
|
||||||
|
cat > "$TMP" << EPOCHS
|
||||||
|
(epoch 1)
|
||||||
|
(load "spec/stdlib.sx")
|
||||||
|
(load "lib/r7rs.sx")
|
||||||
|
(load "lib/apl/runtime.sx")
|
||||||
|
(load "lib/apl/tokenizer.sx")
|
||||||
|
(load "lib/apl/parser.sx")
|
||||||
|
(load "lib/apl/transpile.sx")
|
||||||
|
(epoch 2)
|
||||||
|
(eval "(define apl-test-pass 0)")
|
||||||
|
(eval "(define apl-test-fail 0)")
|
||||||
|
(eval "(define apl-test (fn (name got expected) (if (= got expected) (set! apl-test-pass (+ apl-test-pass 1)) (set! apl-test-fail (+ apl-test-fail 1)))))")
|
||||||
|
(epoch 3)
|
||||||
|
(load "${file}")
|
||||||
|
(epoch 4)
|
||||||
|
(eval "(list apl-test-pass apl-test-fail)")
|
||||||
|
EPOCHS
|
||||||
|
|
||||||
|
local OUTPUT
|
||||||
|
OUTPUT=$(timeout 300 "$SX_SERVER" < "$TMP" 2>/dev/null)
|
||||||
|
rm -f "$TMP"
|
||||||
|
|
||||||
|
local LINE
|
||||||
|
LINE=$(echo "$OUTPUT" | awk '/^\(ok-len 4 / {getline; print; exit}')
|
||||||
|
if [ -z "$LINE" ]; then
|
||||||
|
LINE=$(echo "$OUTPUT" | grep -E '^\(ok 4 \([0-9]+ [0-9]+\)\)' | tail -1 \
|
||||||
|
| sed -E 's/^\(ok 4 //; s/\)$//')
|
||||||
|
fi
|
||||||
|
|
||||||
|
local P F
|
||||||
|
P=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\1/')
|
||||||
|
F=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\2/')
|
||||||
|
P=${P:-0}
|
||||||
|
F=${F:-0}
|
||||||
|
echo "${P} ${F}"
|
||||||
|
}
|
||||||
|
|
||||||
|
declare -A SUITE_PASS
|
||||||
|
declare -A SUITE_FAIL
|
||||||
|
TOTAL_PASS=0
|
||||||
|
TOTAL_FAIL=0
|
||||||
|
|
||||||
|
echo "Running APL conformance suite..." >&2
|
||||||
|
for s in "${SUITES[@]}"; do
|
||||||
|
read -r p f < <(run_suite "$s")
|
||||||
|
SUITE_PASS[$s]=$p
|
||||||
|
SUITE_FAIL[$s]=$f
|
||||||
|
TOTAL_PASS=$((TOTAL_PASS + p))
|
||||||
|
TOTAL_FAIL=$((TOTAL_FAIL + f))
|
||||||
|
printf " %-12s %d/%d\n" "$s" "$p" "$((p+f))" >&2
|
||||||
|
done
|
||||||
|
|
||||||
|
# scoreboard.json
|
||||||
|
{
|
||||||
|
printf '{\n'
|
||||||
|
printf ' "suites": {\n'
|
||||||
|
first=1
|
||||||
|
for s in "${SUITES[@]}"; do
|
||||||
|
if [ $first -eq 0 ]; then printf ',\n'; fi
|
||||||
|
printf ' "%s": {"pass": %d, "fail": %d}' "$s" "${SUITE_PASS[$s]}" "${SUITE_FAIL[$s]}"
|
||||||
|
first=0
|
||||||
|
done
|
||||||
|
printf '\n },\n'
|
||||||
|
printf ' "total_pass": %d,\n' "$TOTAL_PASS"
|
||||||
|
printf ' "total_fail": %d,\n' "$TOTAL_FAIL"
|
||||||
|
printf ' "total": %d\n' "$((TOTAL_PASS + TOTAL_FAIL))"
|
||||||
|
printf '}\n'
|
||||||
|
} > "$OUT_JSON"
|
||||||
|
|
||||||
|
# scoreboard.md
|
||||||
|
{
|
||||||
|
printf '# APL Conformance Scoreboard\n\n'
|
||||||
|
printf '_Generated by `lib/apl/conformance.sh`_\n\n'
|
||||||
|
printf '| Suite | Pass | Fail | Total |\n'
|
||||||
|
printf '|-------|-----:|-----:|------:|\n'
|
||||||
|
for s in "${SUITES[@]}"; do
|
||||||
|
p=${SUITE_PASS[$s]}
|
||||||
|
f=${SUITE_FAIL[$s]}
|
||||||
|
printf '| %s | %d | %d | %d |\n' "$s" "$p" "$f" "$((p+f))"
|
||||||
|
done
|
||||||
|
printf '| **Total** | **%d** | **%d** | **%d** |\n' "$TOTAL_PASS" "$TOTAL_FAIL" "$((TOTAL_PASS + TOTAL_FAIL))"
|
||||||
|
printf '\n'
|
||||||
|
printf '## Notes\n\n'
|
||||||
|
printf '%s\n' '- Suites use the standard `apl-test name got expected` framework loaded against `lib/apl/runtime.sx` + `lib/apl/transpile.sx`.'
|
||||||
|
printf '%s\n' '- `lib/apl/tests/parse.sx` and `lib/apl/tests/scalar.sx` use their own self-contained frameworks and are excluded from this scoreboard.'
|
||||||
|
} > "$OUT_MD"
|
||||||
|
|
||||||
|
echo "Wrote $OUT_JSON and $OUT_MD" >&2
|
||||||
|
echo "Total: $TOTAL_PASS pass, $TOTAL_FAIL fail" >&2
|
||||||
|
|
||||||
|
[ "$TOTAL_FAIL" -eq 0 ]
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
"system": {"pass": 13, "fail": 0},
|
"system": {"pass": 13, "fail": 0},
|
||||||
"idioms": {"pass": 64, "fail": 0},
|
"idioms": {"pass": 64, "fail": 0},
|
||||||
"eval-ops": {"pass": 14, "fail": 0},
|
"eval-ops": {"pass": 14, "fail": 0},
|
||||||
"pipeline": {"pass": 152, "fail": 0}
|
"pipeline": {"pass": 40, "fail": 0}
|
||||||
},
|
},
|
||||||
"total_pass": 562,
|
"total_pass": 450,
|
||||||
"total_fail": 0,
|
"total_fail": 0,
|
||||||
"total": 562
|
"total": 450
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ _Generated by `lib/apl/conformance.sh`_
|
|||||||
| system | 13 | 0 | 13 |
|
| system | 13 | 0 | 13 |
|
||||||
| idioms | 64 | 0 | 64 |
|
| idioms | 64 | 0 | 64 |
|
||||||
| eval-ops | 14 | 0 | 14 |
|
| eval-ops | 14 | 0 | 14 |
|
||||||
| pipeline | 152 | 0 | 152 |
|
| pipeline | 40 | 0 | 40 |
|
||||||
| **Total** | **562** | **0** | **562** |
|
| **Total** | **450** | **0** | **450** |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
; lib/apl/test-harness.sx — counters + assertion fn for the shared conformance
|
|
||||||
; driver (lib/guest/conformance.sh, MODE=counters). Loaded as a PRELOAD so each
|
|
||||||
; suite starts from a fresh 0/0; suites call (apl-test name got expected).
|
|
||||||
|
|
||||||
(define apl-test-pass 0)
|
|
||||||
(define apl-test-fail 0)
|
|
||||||
|
|
||||||
(define
|
|
||||||
apl-test
|
|
||||||
(fn
|
|
||||||
(name got expected)
|
|
||||||
(if
|
|
||||||
(= got expected)
|
|
||||||
(set! apl-test-pass (+ apl-test-pass 1))
|
|
||||||
(set! apl-test-fail (+ apl-test-fail 1)))))
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
# flow — durable DAG workflows on Scheme
|
|
||||||
|
|
||||||
`flow` is a workflow engine for rose-ash: content pipelines (write → review →
|
|
||||||
publish → federate), scheduled jobs, and multi-step user flows (signup, confirm,
|
|
||||||
onboard) that **survive process restarts**. It is a thin Scheme prelude over the
|
|
||||||
Scheme-on-SX guest (`lib/scheme/`); a flow runs *inside* the interpreter.
|
|
||||||
|
|
||||||
Run the suite: `bash lib/flow/conformance.sh` → **151/151 across 10 suites**.
|
|
||||||
|
|
||||||
## Model
|
|
||||||
|
|
||||||
A **flow** is just a Scheme procedure of one argument — the upstream value:
|
|
||||||
|
|
||||||
```
|
|
||||||
node : input -> output
|
|
||||||
```
|
|
||||||
|
|
||||||
Combinators build composite nodes out of child nodes. A node that ignores its
|
|
||||||
argument is effectively a thunk. There is no separate "graph" object: composition
|
|
||||||
*is* function composition, so flows are values you can name, pass, and nest.
|
|
||||||
|
|
||||||
```scheme
|
|
||||||
(defflow publish
|
|
||||||
(sequence
|
|
||||||
(lambda (draft) (string-append draft "!"))
|
|
||||||
(branch (lambda (post) (>= (string-length post) 3))
|
|
||||||
(remote-node 'fed 'publish)
|
|
||||||
(flow-const 'rejected))))
|
|
||||||
|
|
||||||
(flow/start publish "hello") ; => federated, or a (flow-suspended id tag) state
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building blocks (`spec.sx`)
|
|
||||||
|
|
||||||
| Combinator | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| `(flow-node f)` / `(flow-id x)` / `(flow-const v)` | leaf nodes |
|
|
||||||
| `(sequence n ...)` | thread input left-to-right |
|
|
||||||
| `(parallel n ...)` | fan input to every child, join results into a list (sequential eval) |
|
|
||||||
| `(map-flow node)` | run `node` over each item of a list input, join results |
|
|
||||||
| `(flow-while pred body max)` / `(flow-until ...)` | bounded iteration (cap `max` steps) |
|
|
||||||
| `(defflow name body)` | bind + register a named flow (so it survives restart) |
|
|
||||||
|
|
||||||
## Control flow + errors (`spec.sx`)
|
|
||||||
|
|
||||||
| Combinator | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| `(branch pred then else)` | `pred` on input selects `then`/`else` (`cond` is a Scheme special form) |
|
|
||||||
| `(retry n node)` | re-run on a *raised exception*, up to `n` attempts |
|
|
||||||
| `(timeout budget node)` | cooperative **step budget**: nodes call `(tick)`; the `(budget+1)`-th tick raises `flow-timeout` |
|
|
||||||
| `(try-catch node handler)` | catch a raised exception → `(handler error)` |
|
|
||||||
| `(fail reason)` / `(failed? x)` / `(fail-reason x)` | explicit failure *values* (flow downstream as data) |
|
|
||||||
| `(recover node handler)` | the fail-VALUE counterpart of try-catch |
|
|
||||||
| `(attempt n ...)` | railway sequence: stop at the first node returning a `(fail ...)` |
|
|
||||||
| `(tap effect)` | run a side effect, return input unchanged |
|
|
||||||
|
|
||||||
**Two error channels, on purpose.** Raised exceptions are for *bugs/transients*
|
|
||||||
(caught by `retry`/`try-catch`). `(fail reason)` values are for *expected business
|
|
||||||
outcomes* (validation rejected, declined) and compose via `attempt`/`recover`.
|
|
||||||
|
|
||||||
## Suspend / resume — the durable core (`spec.sx`, `store.sx`)
|
|
||||||
|
|
||||||
The guest Scheme's `call/cc` is **escape-only** — re-invoking a captured
|
|
||||||
continuation after it returns *hangs* the runtime. So flow does **not** serialize
|
|
||||||
continuations. Instead it uses **deterministic replay**:
|
|
||||||
|
|
||||||
- `(suspend tag)` — if `tag` is already in the replay log, return its logged value;
|
|
||||||
otherwise escape to the driver as `(flow-suspended tag)`.
|
|
||||||
- `resume` appends `(tag value)` to the log and **re-runs the flow from the start**.
|
|
||||||
Already-resolved suspends replay their values; the first unresolved one escapes
|
|
||||||
again (or the flow completes).
|
|
||||||
|
|
||||||
The entire persisted state is the replay log — plain data. No live continuation is
|
|
||||||
ever stored, so flows survive process restarts and even moves between instances.
|
|
||||||
|
|
||||||
> **Author contract:** suspend `tag`s must be unique and deterministic across
|
|
||||||
> replays, and **all** non-determinism / side effects must go through suspend
|
|
||||||
> points (so their results are logged) — otherwise they re-run on every replay.
|
|
||||||
|
|
||||||
### Lifecycle (`store.sx`)
|
|
||||||
|
|
||||||
```scheme
|
|
||||||
(flow/start flow input) ; raw result if it completes, else (flow-suspended id tag)
|
|
||||||
(flow/resume id value) ; inject value at the waiting tag, continue
|
|
||||||
(flow/cancel id) ; terminate; a later resume is rejected
|
|
||||||
```
|
|
||||||
|
|
||||||
### Introspection & hygiene
|
|
||||||
|
|
||||||
```scheme
|
|
||||||
(flow/status id) ; done | suspended | cancelled | unknown
|
|
||||||
(flow/result id) ; result if done, else (flow-error reason)
|
|
||||||
(flow/list) ; ((id status) ...)
|
|
||||||
(flow/pending) ; ((id waiting-tag) ...) — what each suspended flow awaits
|
|
||||||
(flow/gc) ; drop terminal records, keep live ones; returns count removed
|
|
||||||
(flow/forget id) ; drop one terminal record (refuses live flows)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Crash recovery
|
|
||||||
|
|
||||||
```scheme
|
|
||||||
(flow-store-export) ; the store as plain data (live procs nulled)
|
|
||||||
(flow-store-import! d) ; restore the store from exported data
|
|
||||||
(flow-resumable-ids) ; ids of suspended flows to wake on restart
|
|
||||||
```
|
|
||||||
|
|
||||||
On restart the flow definitions are reloaded (`defflow` re-registers names) and the
|
|
||||||
exported store reimported; `resume` re-resolves each flow's procedure **by name**.
|
|
||||||
|
|
||||||
## Distribution via fed-sx (`remote.sx`)
|
|
||||||
|
|
||||||
```scheme
|
|
||||||
(flow-peer-register! addr table) ; mock a peer's exposed functions (fed-sx boundary)
|
|
||||||
(remote-node addr fn) ; run a node on a peer
|
|
||||||
(remote-failover addrs fn local) ; try peers in order, fall through to a local node
|
|
||||||
(flow-replicate-to addr) ; copy this store to a peer's replica slot
|
|
||||||
(flow-restore-from addr) ; import a peer's replica (handoff)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Handoff** is crash recovery across instances: replicate → local instance dies →
|
|
||||||
peer restores the (plain-data) store and resumes. The replay log carries over, so
|
|
||||||
all resolved suspends survive the move.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
| File | Contents |
|
|
||||||
|---|---|
|
|
||||||
| `spec.sx` | combinators (flow-combinators-src / flow-control-src / flow-suspend-src) |
|
|
||||||
| `store.sx` | durable store, lifecycle, crash recovery, introspection, hygiene |
|
|
||||||
| `remote.sx` | fed-sx transport (mock peer registry), failover, replication |
|
|
||||||
| `api.sx` | `flow-make-env` / `flow-run` SX helpers (one cached env, per-test reset) |
|
|
||||||
| `tests/*.sx` | 10 suites, 151 cases |
|
|
||||||
| `conformance.sh` | loads substrate + flow layer, runs every suite |
|
|
||||||
|
|
||||||
## Notes on the substrate
|
|
||||||
|
|
||||||
The guest Scheme (`lib/scheme/`, imported read-only) lacks dotted-rest params
|
|
||||||
`(a . rest)` and named `let`; combinators use `(lambda args ...)` variadics + top-
|
|
||||||
level recursion. `cons` is list-only (no dotted pairs), so log/assoc entries are
|
|
||||||
2-element lists. Strings box as `{:scm-string "..."}`. Timeout is a step budget
|
|
||||||
because there is no wall clock; `parallel` is sequential for the same reason.
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
;; lib/flow/api.sx — flow runtime entry points.
|
|
||||||
;;
|
|
||||||
;; Builds a Scheme env preloaded with the flow combinators (lib/flow/spec.sx),
|
|
||||||
;; the durable store + lifecycle (lib/flow/store.sx), the fed-sx remote layer
|
|
||||||
;; (lib/flow/remote.sx), and the host integration ABI (lib/flow/host.sx), and
|
|
||||||
;; provides SX helpers to run flow programs.
|
|
||||||
;;
|
|
||||||
;; Scheme-level API (available inside flow programs):
|
|
||||||
;; (flow/start flow input) — run a flow; raw result if it completes, else
|
|
||||||
;; (flow-suspended id tag). Defined in store.sx.
|
|
||||||
;; (flow/resume id value) — resume a suspended flow (store.sx)
|
|
||||||
;; (flow/cancel id) — cancel a flow (store.sx)
|
|
||||||
;; (suspend tag) — suspension point (spec.sx)
|
|
||||||
;; (request kind payload) — host request envelope over suspend (host.sx)
|
|
||||||
;; (remote-node addr fn) — node executed on a federation peer (remote.sx)
|
|
||||||
;;
|
|
||||||
;; SX-level helpers (for hosts and tests):
|
|
||||||
;; (flow-make-env) — fresh standard env + combinators + store + remote + host
|
|
||||||
;; (flow-run src) — eval a Scheme program string in a reset shared env
|
|
||||||
;; (flow-run-in env src) — eval a Scheme program string in a given env
|
|
||||||
;;
|
|
||||||
;; flow-run reuses ONE env (building the full standard env is expensive) and
|
|
||||||
;; resets the mutable flow globals before each program, so tests stay isolated
|
|
||||||
;; without paying for a fresh standard env each time. flow-registry persists (it
|
|
||||||
;; models reloaded flow definitions surviving a restart).
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-make-env
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(let
|
|
||||||
((env (scheme-standard-env)))
|
|
||||||
(flow-load-combinators! env)
|
|
||||||
(flow-load-store! env)
|
|
||||||
(flow-load-remote! env)
|
|
||||||
(flow-load-host! env)
|
|
||||||
env)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-run-in
|
|
||||||
(fn (env src) (scheme-eval-program (scheme-parse-all src) env)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-reset-src
|
|
||||||
"(set! flow-store (list)) (set! flow-next-id 0) (set! flow-replay-log (list)) (set! flow-suspend-k #f) (set! flow-timeout-budget -1) (set! flow-peers (list)) (set! flow-replicas (list))")
|
|
||||||
|
|
||||||
(define flow-env-cache false)
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-shared-env
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(begin
|
|
||||||
(if flow-env-cache nil (set! flow-env-cache (flow-make-env)))
|
|
||||||
flow-env-cache)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-run
|
|
||||||
(fn
|
|
||||||
(src)
|
|
||||||
(let
|
|
||||||
((env (flow-shared-env)))
|
|
||||||
(begin
|
|
||||||
(scheme-eval-program (scheme-parse-all flow-reset-src) env)
|
|
||||||
(scheme-eval-program (scheme-parse-all src) env)))))
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# flow-on-sx conformance runner — runs all flow test suites in one sx_server process.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# bash lib/flow/conformance.sh # run all suites
|
|
||||||
# bash lib/flow/conformance.sh -v # verbose (list each 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:-}"
|
|
||||||
|
|
||||||
# Suites: NAME RUNNER-FN PATH
|
|
||||||
SUITES=(
|
|
||||||
"basic flow-basic-tests-run! lib/flow/tests/basic.sx"
|
|
||||||
"control flow-ctl-tests-run! lib/flow/tests/control.sx"
|
|
||||||
"suspend flow-sus-tests-run! lib/flow/tests/suspend.sx"
|
|
||||||
"recovery flow-rec-tests-run! lib/flow/tests/recovery.sx"
|
|
||||||
"distributed flow-dist-tests-run! lib/flow/tests/distributed.sx"
|
|
||||||
"api flow-api-tests-run! lib/flow/tests/api.sx"
|
|
||||||
"combinators flow-cmb-tests-run! lib/flow/tests/combinators.sx"
|
|
||||||
"railway flow-rail-tests-run! lib/flow/tests/railway.sx"
|
|
||||||
"integration flow-int-tests-run! lib/flow/tests/integration.sx"
|
|
||||||
"hygiene flow-hyg-tests-run! lib/flow/tests/hygiene.sx"
|
|
||||||
"host flow-hst-tests-run! lib/flow/tests/host.sx"
|
|
||||||
)
|
|
||||||
|
|
||||||
TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT
|
|
||||||
EPOCH=1
|
|
||||||
|
|
||||||
emit_load () { echo "(epoch $EPOCH)"; echo "(load \"$1\")"; EPOCH=$((EPOCH+1)); }
|
|
||||||
emit_eval () { echo "(epoch $EPOCH)"; echo "(eval \"$1\")"; EPOCH=$((EPOCH+1)); }
|
|
||||||
|
|
||||||
{
|
|
||||||
emit_load "lib/guest/lex.sx"
|
|
||||||
emit_load "lib/guest/reflective/env.sx"
|
|
||||||
emit_load "lib/guest/reflective/quoting.sx"
|
|
||||||
emit_load "lib/scheme/parser.sx"
|
|
||||||
emit_load "lib/scheme/eval.sx"
|
|
||||||
emit_load "lib/scheme/runtime.sx"
|
|
||||||
emit_load "lib/flow/spec.sx"
|
|
||||||
emit_load "lib/flow/store.sx"
|
|
||||||
emit_load "lib/flow/remote.sx"
|
|
||||||
emit_load "lib/flow/host.sx"
|
|
||||||
emit_load "lib/flow/api.sx"
|
|
||||||
for SUITE in "${SUITES[@]}"; do
|
|
||||||
read -r _NAME _RUNNER FILE <<< "$SUITE"
|
|
||||||
emit_load "$FILE"
|
|
||||||
emit_eval "($_RUNNER)"
|
|
||||||
done
|
|
||||||
} > "$TMPFILE"
|
|
||||||
|
|
||||||
OUTPUT=$(timeout 540 "$SX_SERVER" < "$TMPFILE" 2>&1 || true)
|
|
||||||
|
|
||||||
TOTAL_PASS=0
|
|
||||||
TOTAL_FAIL=0
|
|
||||||
FAILED_SUITES=()
|
|
||||||
|
|
||||||
LAST_DICT_LINES=$(echo "$OUTPUT" | grep -E '^\{:' || true)
|
|
||||||
|
|
||||||
I=0
|
|
||||||
while read -r LINE; do
|
|
||||||
[ -z "$LINE" ] && continue
|
|
||||||
P=$(echo "$LINE" | grep -oE ':passed [0-9]+' | awk '{print $2}')
|
|
||||||
F=$(echo "$LINE" | grep -oE ':failed [0-9]+' | awk '{print $2}')
|
|
||||||
[ -z "$P" ] && P=0
|
|
||||||
[ -z "$F" ] && F=0
|
|
||||||
SUITE_INFO="${SUITES[$I]}"
|
|
||||||
SUITE_NAME=$(echo "$SUITE_INFO" | awk '{print $1}')
|
|
||||||
TOTAL_PASS=$((TOTAL_PASS + P))
|
|
||||||
TOTAL_FAIL=$((TOTAL_FAIL + F))
|
|
||||||
if [ "$F" -gt 0 ]; then
|
|
||||||
FAILED_SUITES+=("$SUITE_NAME: $P/$((P+F))")
|
|
||||||
printf 'X %-12s %d/%d\n' "$SUITE_NAME" "$P" "$((P+F))"
|
|
||||||
echo "$LINE" | grep -oE ':name "[^"]*"' | sed 's/:name / fail: /'
|
|
||||||
elif [ "$VERBOSE" = "-v" ]; then
|
|
||||||
printf 'ok %-12s %d passed\n' "$SUITE_NAME" "$P"
|
|
||||||
fi
|
|
||||||
I=$((I+1))
|
|
||||||
done <<< "$LAST_DICT_LINES"
|
|
||||||
|
|
||||||
TOTAL=$((TOTAL_PASS + TOTAL_FAIL))
|
|
||||||
if [ "$TOTAL" -eq 0 ]; then
|
|
||||||
echo "ERROR: no suite results parsed. Raw output:" >&2
|
|
||||||
echo "$OUTPUT" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ $TOTAL_FAIL -eq 0 ]; then
|
|
||||||
echo "ok $TOTAL_PASS/$TOTAL flow-on-sx tests passed (${#SUITES[@]} suites)"
|
|
||||||
else
|
|
||||||
echo "FAIL $TOTAL_PASS/$TOTAL passed, $TOTAL_FAIL failed:"
|
|
||||||
for S in "${FAILED_SUITES[@]}"; do echo " $S"; done
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
;; lib/flow/host.sx — the host integration ABI (Phase 8).
|
|
||||||
;;
|
|
||||||
;; `suspend` is flow's seam to the outside world, but a bare (suspend tag) is just a
|
|
||||||
;; signal — every author would invent their own tag shape. This layer defines a
|
|
||||||
;; stable request/response contract so a host (e.g. an art-dag driver, or a human
|
|
||||||
;; review UI) can hook in WITHOUT reverse-engineering ad-hoc tags.
|
|
||||||
;;
|
|
||||||
;; A flow asks the host to do something and waits for the answer:
|
|
||||||
;; (request kind payload) — suspend with a typed envelope (flow-request kind
|
|
||||||
;; payload); evaluates to the host's resume value.
|
|
||||||
;; (await-human prompt) — request kind=human (a decision point)
|
|
||||||
;; (await-render recipe) — request kind=render (e.g. an art-dag job)
|
|
||||||
;; (await-effect kind p) — request of an arbitrary kind
|
|
||||||
;;
|
|
||||||
;; The host drives flows by polling its work queue and resuming:
|
|
||||||
;; (flow-host-requests) — ((id kind payload) ...) for every SUSPENDED flow whose
|
|
||||||
;; waiting tag is a host request. The host dispatches by kind (render -> submit a
|
|
||||||
;; Celery job; human -> show UI), then calls (flow/resume id answer).
|
|
||||||
;; (request? tag) / (request-kind tag) / (request-payload tag) — parse one tag.
|
|
||||||
;;
|
|
||||||
;; Reference driver — the host only supplies `dispatch`, a (kind payload) -> answer:
|
|
||||||
;; (flow-drive-host dispatch) — one tick: service every CURRENTLY pending
|
|
||||||
;; request (snapshot), resuming each with (dispatch kind payload); returns the
|
|
||||||
;; count serviced. Resumes may create new requests — serviced on the next tick.
|
|
||||||
;; (flow-run-host dispatch maxticks) — tick until quiescent (no pending requests)
|
|
||||||
;; or maxticks reached; returns total requests serviced. Bounded for determinism.
|
|
||||||
;;
|
|
||||||
;; Contract: the host owns IO and persistence. flow stays deterministic — a flow
|
|
||||||
;; never performs IO itself, it only `request`s; the host performs the effect and
|
|
||||||
;; feeds the result back via resume (which the replay log records, so the effect is
|
|
||||||
;; not re-run on recovery). Persist with flow-store-export after each transition and
|
|
||||||
;; flow-store-import! on boot.
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-host-src
|
|
||||||
"(define (request kind payload) (suspend (list (quote flow-request) kind payload)))\n (define (request? tag) (and (pair? tag) (eq? (car tag) (quote flow-request))))\n (define (request-kind tag) (car (cdr tag)))\n (define (request-payload tag) (car (cdr (cdr tag))))\n (define (await-human prompt) (request (quote human) prompt))\n (define (await-render recipe) (request (quote render) recipe))\n (define (await-effect kind payload) (request kind payload))\n (define (flow-host-req-step pend)\n (if (null? pend)\n (list)\n (let ((id (car (car pend))) (tag (car (cdr (car pend)))))\n (if (request? tag)\n (cons (list id (request-kind tag) (request-payload tag))\n (flow-host-req-step (cdr pend)))\n (flow-host-req-step (cdr pend))))))\n (define (flow-host-requests) (flow-host-req-step (flow/pending)))\n (define (flow-drive-host-step reqs dispatch)\n (if (null? reqs)\n 0\n (begin\n (flow/resume (car (car reqs)) (dispatch (car (cdr (car reqs))) (car (cdr (cdr (car reqs))))))\n (+ 1 (flow-drive-host-step (cdr reqs) dispatch)))))\n (define (flow-drive-host dispatch) (flow-drive-host-step (flow-host-requests) dispatch))\n (define (flow-run-host dispatch maxticks)\n (if (<= maxticks 0)\n 0\n (let ((n (flow-drive-host dispatch)))\n (if (= n 0) 0 (+ n (flow-run-host dispatch (- maxticks 1)))))))")
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-load-host!
|
|
||||||
(fn
|
|
||||||
(env)
|
|
||||||
(begin (scheme-eval-program (scheme-parse-all flow-host-src) env) env)))
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
;; lib/flow/remote.sx — distributed nodes via fed-sx (Phase 4).
|
|
||||||
;;
|
|
||||||
;; A node can execute on a federation peer. The transport is the fed-sx boundary;
|
|
||||||
;; it is MOCKED in tests by a peer registry mapping addr -> function table. In
|
|
||||||
;; production flow-transport would issue a fed-sx call; here it dispatches locally.
|
|
||||||
;;
|
|
||||||
;; (flow-peer-register! addr table) — register a mock peer. table is a list of
|
|
||||||
;; (fn-name proc) entries — the functions that peer exposes.
|
|
||||||
;; (flow-transport addr fn input) — invoke fn on the peer with input. Raises
|
|
||||||
;; (flow-remote-unreachable) if the addr is unknown, (flow-remote-no-fn) if the
|
|
||||||
;; peer does not expose fn.
|
|
||||||
;; (remote-node addr fn) — a node that runs fn on the peer at addr.
|
|
||||||
;; (remote-failover addrs fn local) — try fn on each peer in addrs in order; on a
|
|
||||||
;; raised error move to the next peer; if every peer fails, run the `local`
|
|
||||||
;; node as a fallback.
|
|
||||||
;;
|
|
||||||
;; Persistence across instances + handoff. Each instance runs the same flow
|
|
||||||
;; definitions, so the only thing that needs to cross the wire is the (plain-data)
|
|
||||||
;; store — exactly flow-store-export from store.sx. Replication pushes that export
|
|
||||||
;; to a peer's replica slot; handoff = restore the replica on the peer and resume.
|
|
||||||
;;
|
|
||||||
;; (flow-replicate-to addr) — copy this instance's store to peer addr's replica
|
|
||||||
;; (flow-restore-from addr) — import the replica from peer addr (#t / #f)
|
|
||||||
;; (flow-replica-get addr) — the raw replicated store at addr (or #f)
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-remote-src
|
|
||||||
"(define flow-peers (list))\n (define (flow-assoc key alist)\n (if (null? alist)\n #f\n (if (eq? (car (car alist)) key) (car (cdr (car alist))) (flow-assoc key (cdr alist)))))\n (define (flow-peer-register! addr table) (set! flow-peers (cons (list addr table) flow-peers)))\n (define (flow-transport addr fn input)\n (let ((table (flow-assoc addr flow-peers)))\n (if table\n (let ((proc (flow-assoc fn table)))\n (if proc (proc input) (raise (quote flow-remote-no-fn))))\n (raise (quote flow-remote-unreachable)))))\n (define (remote-node addr fn) (lambda (input) (flow-transport addr fn input)))\n (define (flow-failover-step addrs fn input local)\n (if (null? addrs)\n (local input)\n (guard (e (#t (flow-failover-step (cdr addrs) fn input local)))\n (flow-transport (car addrs) fn input))))\n (define (remote-failover addrs fn local)\n (lambda (input) (flow-failover-step addrs fn input local)))\n\n (define flow-replicas (list))\n (define (flow-replicas-remove addr reps)\n (if (null? reps)\n (list)\n (if (eq? (car (car reps)) addr)\n (flow-replicas-remove addr (cdr reps))\n (cons (car reps) (flow-replicas-remove addr (cdr reps))))))\n (define (flow-replicate-to addr)\n (set! flow-replicas (cons (list addr (flow-store-export)) (flow-replicas-remove addr flow-replicas))))\n (define (flow-replica-get addr) (flow-assoc addr flow-replicas))\n (define (flow-restore-from addr)\n (let ((data (flow-replica-get addr)))\n (if data (begin (flow-store-import! data) #t) #f)))")
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-load-remote!
|
|
||||||
(fn
|
|
||||||
(env)
|
|
||||||
(begin (scheme-eval-program (scheme-parse-all flow-remote-src) env) env)))
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"total": 166,
|
|
||||||
"passed": 166,
|
|
||||||
"failed": 0,
|
|
||||||
"suites": {
|
|
||||||
"basic": { "passed": 18, "total": 18 },
|
|
||||||
"control": { "passed": 31, "total": 31 },
|
|
||||||
"suspend": { "passed": 17, "total": 17 },
|
|
||||||
"recovery": { "passed": 8, "total": 8 },
|
|
||||||
"distributed": { "passed": 19, "total": 19 },
|
|
||||||
"api": { "passed": 12, "total": 12 },
|
|
||||||
"combinators": { "passed": 17, "total": 17 },
|
|
||||||
"railway": { "passed": 10, "total": 10 },
|
|
||||||
"integration": { "passed": 10, "total": 10 },
|
|
||||||
"hygiene": { "passed": 9, "total": 9 },
|
|
||||||
"host": { "passed": 15, "total": 15 }
|
|
||||||
},
|
|
||||||
"phases": { "phase1": "done", "phase2": "done", "phase3": "done", "phase4": "done", "phase5": "done", "phase6": "done", "phase7": "done", "phase8": "done" }
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# flow-on-sx Scoreboard
|
|
||||||
|
|
||||||
**All tests pass: 166 / 166 across 11 suites. Phases 1-8 complete.**
|
|
||||||
|
|
||||||
`bash lib/flow/conformance.sh`
|
|
||||||
|
|
||||||
## Per-suite breakdown
|
|
||||||
|
|
||||||
| Suite | Passing | Covers |
|
|
||||||
|-------|--------:|--------|
|
|
||||||
| basic | 18 | Phase 1: single nodes, linear sequence, data-flow threading, defflow, parallel fan/join, nested composition, publish-shaped flow |
|
|
||||||
| control | 31 | Phase 2: `branch` (6); error model `fail`/`failed?`/`fail-reason` (6); `try-catch` (6); `retry n` (6); `timeout` cooperative step budget (7) |
|
|
||||||
| suspend | 17 | Phase 3: suspend/resume/cancel via deterministic replay; multi-step, replay determinism, lifecycle guards, suspend-in-branch |
|
|
||||||
| recovery | 8 | Phase 3: crash recovery — store export/import, resumable scan, restart-at-every-step, replay-log survival |
|
|
||||||
| distributed | 19 | Phase 4: `remote-node` (7); `remote-failover` (6); replication + handoff across instances (6) |
|
|
||||||
| api | 12 | Phase 5: introspection — `flow/status`, `flow/result`, `flow/list`, `flow/pending` |
|
|
||||||
| combinators | 17 | Phase 5: `tap`, `recover` (fail-value), `map-flow` fan-over-list, `flow-while`/`flow-until` bounded iteration |
|
|
||||||
| railway | 10 | Phase 6: `attempt` — fail-value short-circuiting sequence + recover rejoin |
|
|
||||||
| integration | 10 | Phase 7: end-to-end order + onboarding flows composing every phase (suspend, branch, federation, crash recovery, handoff, introspection) |
|
|
||||||
| hygiene | 9 | Phase 5: `flow/gc` (prune terminal flows), `flow/forget` (drop one terminal record) |
|
|
||||||
| host | 15 | Phase 8: host ABI — `request`/`await-human`/`await-render`, `flow-host-requests` queue, `flow-run-host` reference driver; art-dag-shaped render→review→publish loop |
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Flow combinators are a **Scheme prelude** (`lib/flow/spec.sx`) loaded onto
|
|
||||||
`scheme-standard-env`. A flow is a Scheme procedure `input -> output`. The whole
|
|
||||||
flow executes inside the Scheme interpreter, so Phase 3's `suspend` (call/cc) will
|
|
||||||
capture the flow continuation directly.
|
|
||||||
|
|
||||||
- `lib/flow/spec.sx` — combinators: `flow-node`, `flow-id`, `flow-const`,
|
|
||||||
`sequence`, `parallel`, `defflow`; `flow-load-combinators!`.
|
|
||||||
- `lib/flow/api.sx` — `flow/start` (Scheme); `flow-make-env`, `flow-run`,
|
|
||||||
`flow-run-in` (SX helpers).
|
|
||||||
- `lib/flow/tests/basic.sx` — 18 cases.
|
|
||||||
- `lib/flow/conformance.sh` — loads substrate + flow layer, runs suites.
|
|
||||||
|
|
||||||
## Semantics notes
|
|
||||||
|
|
||||||
- **node** = 1-arg Scheme procedure; the upstream value is the argument. A node
|
|
||||||
ignoring its argument is effectively a thunk.
|
|
||||||
- **sequence** threads left-to-right; empty sequence = identity.
|
|
||||||
- **parallel** fans the same input to every branch and joins results into a list.
|
|
||||||
Evaluation is **sequential** for now; true concurrency arrives in Phase 3.
|
|
||||||
|
|
||||||
## Phases
|
|
||||||
|
|
||||||
- [x] Phase 1 — Declarative DAG + sequential execution (combinators + 18 tests, `flow/start`)
|
|
||||||
- [x] Phase 2 — Control flow + error handling (branch, error model, try-catch, retry, timeout)
|
|
||||||
- [x] Phase 3 — Suspend/resume (suspend/resume/cancel + crash recovery via deterministic replay)
|
|
||||||
- [x] Phase 4 — Distributed nodes via fed-sx (remote-node, failover, replication + handoff)
|
|
||||||
- [x] Phase 5 — Operational API + combinators (introspection, tap, recover, map-flow)
|
|
||||||
- [ ] Phase 3 — Suspend / resume (the showcase)
|
|
||||||
- [ ] Phase 4 — Distributed nodes via fed-sx
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
;; lib/flow/spec.sx — flow combinators as a Scheme prelude.
|
|
||||||
;;
|
|
||||||
;; A flow is a Scheme procedure of one argument: the upstream value.
|
|
||||||
;; node : input -> output
|
|
||||||
;; A leaf node ignoring its argument is effectively a thunk. Combinators
|
|
||||||
;; build composite nodes out of child nodes. The whole flow runs INSIDE the
|
|
||||||
;; Scheme interpreter.
|
|
||||||
;;
|
|
||||||
;; Phase 1 combinators (flow-combinators-src):
|
|
||||||
;; flow-node / flow-id / flow-const / sequence / parallel / defflow
|
|
||||||
;; defflow both binds the flow and registers it by name (flow-register!, in
|
|
||||||
;; store.sx) so it can be re-resolved after a process restart.
|
|
||||||
;; map-flow (Phase 5): run a node over each item of a list input, join results.
|
|
||||||
;; flow-while / flow-until (Phase 5): bounded iteration — re-run body, threading
|
|
||||||
;; the value, while/until pred holds, up to `max` steps (deterministic bound; no
|
|
||||||
;; unbounded loops in pure SX).
|
|
||||||
;;
|
|
||||||
;; Phase 2 combinators (flow-control-src):
|
|
||||||
;; branch / fail / failed? / fail-reason / try-catch / retry / timeout / tick
|
|
||||||
;; tap (Phase 5): side-effecting pass-through (returns input unchanged).
|
|
||||||
;; recover (Phase 5): the fail-VALUE counterpart of try-catch.
|
|
||||||
;; attempt (Phase 6): railway sequence — thread nodes left-to-right but stop at
|
|
||||||
;; the first node that returns a (fail ...) value, returning that failure.
|
|
||||||
;;
|
|
||||||
;; Phase 3 suspend core (flow-suspend-src):
|
|
||||||
;; The guest Scheme's call/cc is ESCAPE-ONLY (re-invoking a captured k after it
|
|
||||||
;; returns hangs the runtime), so suspend/resume CANNOT re-enter a continuation.
|
|
||||||
;; Instead, durability uses DETERMINISTIC REPLAY: a flow re-runs from the start
|
|
||||||
;; on each resume; suspend points that have already been resolved replay their
|
|
||||||
;; logged value, and the first unresolved suspend escapes back to the driver.
|
|
||||||
;; The entire persisted state is the replay log (plain (tag value) data), which
|
|
||||||
;; survives process restart — no live continuation is ever serialized.
|
|
||||||
;;
|
|
||||||
;; (suspend tag) — if tag is in the replay log, return its value; else escape
|
|
||||||
;; to the driver as (flow-suspended tag). tags must be unique & deterministic
|
|
||||||
;; across replays. ALL effects/non-determinism must go through suspend so their
|
|
||||||
;; results are logged (otherwise they re-run on every replay).
|
|
||||||
;; (flow-drive flow input log) — run flow with the given replay log; returns
|
|
||||||
;; (flow-done result) or (flow-suspended tag).
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-combinators-src
|
|
||||||
"(define (flow-node f) f)\n (define (flow-id input) input)\n (define (flow-const v) (lambda (input) v))\n (define (flow-seq-step ns v)\n (if (null? ns) v (flow-seq-step (cdr ns) ((car ns) v))))\n (define sequence (lambda ns (lambda (input) (flow-seq-step ns input))))\n (define parallel (lambda ns (lambda (input) (map (lambda (n) (n input)) ns))))\n (define (map-flow node) (lambda (items) (map node items)))\n (define (flow-while-step pred body input n)\n (if (<= n 0)\n input\n (if (pred input) (flow-while-step pred body (body input) (- n 1)) input)))\n (define (flow-while pred body max) (lambda (input) (flow-while-step pred body input max)))\n (define (flow-until-step pred body input n)\n (if (<= n 0)\n input\n (if (pred input) input (flow-until-step pred body (body input) (- n 1)))))\n (define (flow-until pred body max) (lambda (input) (flow-until-step pred body input max)))\n (define-syntax defflow\n (syntax-rules ()\n ((defflow nm body)\n (begin (define nm body) (flow-register! (quote nm) nm)))))")
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-control-src
|
|
||||||
"(define (branch pred then else)\n (lambda (input) (if (pred input) (then input) (else input))))\n (define (fail reason) (list (quote flow-fail) reason))\n (define (failed? x) (and (pair? x) (eq? (car x) (quote flow-fail))))\n (define (fail-reason x) (car (cdr x)))\n (define (recover node handler)\n (lambda (input)\n (let ((r (node input)))\n (if (failed? r) (handler (fail-reason r)) r))))\n (define (tap effect)\n (lambda (input) (begin (effect input) input)))\n (define (flow-attempt-step ns v)\n (if (failed? v)\n v\n (if (null? ns) v (flow-attempt-step (cdr ns) ((car ns) v)))))\n (define attempt (lambda ns (lambda (input) (flow-attempt-step ns input))))\n (define (try-catch node handler)\n (lambda (input) (guard (e (#t (handler e))) (node input))))\n (define (flow-retry-step n node input)\n (guard (e (#t (if (<= n 1) (raise e) (flow-retry-step (- n 1) node input))))\n (node input)))\n (define (retry n node) (lambda (input) (flow-retry-step n node input)))\n (define flow-timeout-budget -1)\n (define (tick)\n (if (< flow-timeout-budget 0)\n 0\n (begin\n (set! flow-timeout-budget (- flow-timeout-budget 1))\n (if (< flow-timeout-budget 0)\n (raise (quote flow-timeout))\n flow-timeout-budget))))\n (define (timeout budget node)\n (lambda (input)\n (let ((saved flow-timeout-budget))\n (set! flow-timeout-budget budget)\n (guard (e (#t (begin (set! flow-timeout-budget saved) (raise e))))\n (let ((result (node input)))\n (set! flow-timeout-budget saved)\n result)))))")
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-suspend-src
|
|
||||||
"(define flow-replay-log (list))\n (define flow-suspend-k #f)\n (define (flow-log-lookup tag log)\n (if (null? log)\n (list #f #f)\n (if (eq? (car (car log)) tag)\n (list #t (car (cdr (car log))))\n (flow-log-lookup tag (cdr log)))))\n (define (suspend tag)\n (let ((hit (flow-log-lookup tag flow-replay-log)))\n (if (car hit)\n (car (cdr hit))\n (flow-suspend-k (list (quote flow-suspended) tag)))))\n (define (flow-drive flow input log)\n (set! flow-replay-log log)\n (call/cc\n (lambda (k)\n (set! flow-suspend-k k)\n (list (quote flow-done) (flow input)))))")
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-load-combinators!
|
|
||||||
(fn
|
|
||||||
(env)
|
|
||||||
(begin
|
|
||||||
(scheme-eval-program (scheme-parse-all flow-combinators-src) env)
|
|
||||||
(scheme-eval-program (scheme-parse-all flow-control-src) env)
|
|
||||||
(scheme-eval-program (scheme-parse-all flow-suspend-src) env)
|
|
||||||
env)))
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,79 +0,0 @@
|
|||||||
;; lib/flow/tests/api.sx — Phase 5: operational introspection API.
|
|
||||||
|
|
||||||
(define flow-api-pass 0)
|
|
||||||
(define flow-api-fail 0)
|
|
||||||
(define flow-api-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-api-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! flow-api-pass (+ flow-api-pass 1))
|
|
||||||
(begin
|
|
||||||
(set! flow-api-fail (+ flow-api-fail 1))
|
|
||||||
(append! flow-api-fails {:name name :expected expected :actual actual})))))
|
|
||||||
|
|
||||||
(define flow-a (fn (src) (flow-run src)))
|
|
||||||
|
|
||||||
;; ── flow/status ─────────────────────────────────────────────────
|
|
||||||
(flow-api-test "status: unknown id" (flow-a "(flow/status 999)") "unknown")
|
|
||||||
(flow-api-test
|
|
||||||
"status: suspended flow"
|
|
||||||
(flow-a
|
|
||||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/status id)")
|
|
||||||
"suspended")
|
|
||||||
(flow-api-test
|
|
||||||
"status: completed flow"
|
|
||||||
(flow-a
|
|
||||||
"(defflow w (sequence (lambda (x) (suspend (quote q))) (lambda (v) v))) (define id (car (cdr (flow/start w 0)))) (flow/resume id 5) (flow/status id)")
|
|
||||||
"done")
|
|
||||||
(flow-api-test
|
|
||||||
"status: cancelled flow"
|
|
||||||
(flow-a
|
|
||||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/cancel id) (flow/status id)")
|
|
||||||
"cancelled")
|
|
||||||
|
|
||||||
;; ── flow/result ─────────────────────────────────────────────────
|
|
||||||
(flow-api-test
|
|
||||||
"result: returns the value of a completed flow"
|
|
||||||
(flow-a
|
|
||||||
"(defflow w (sequence (lambda (x) (suspend (quote q))) (lambda (v) (list (quote got) v)))) (define id (car (cdr (flow/start w 0)))) (flow/resume id 9) (flow/result id)")
|
|
||||||
(list "got" 9))
|
|
||||||
(flow-api-test
|
|
||||||
"result: a still-suspended flow has no result"
|
|
||||||
(flow-a
|
|
||||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/result id)")
|
|
||||||
(list "flow-error" "not-done"))
|
|
||||||
(flow-api-test
|
|
||||||
"result: unknown id errors"
|
|
||||||
(flow-a "(flow/result 999)")
|
|
||||||
(list "flow-error" "no-such-flow"))
|
|
||||||
|
|
||||||
;; ── flow/list ───────────────────────────────────────────────────
|
|
||||||
(flow-api-test "list: empty store" (flow-a "(flow/list)") (list))
|
|
||||||
(flow-api-test
|
|
||||||
"list: reports id + status for each flow (newest first)"
|
|
||||||
(flow-a
|
|
||||||
"(defflow w (lambda (x) (suspend (quote q)))) (flow/start w 0) (flow/start (lambda (x) (* x 2)) 5) (flow/list)")
|
|
||||||
(list (list 2 "done") (list 1 "suspended")))
|
|
||||||
|
|
||||||
;; ── flow/pending ────────────────────────────────────────────────
|
|
||||||
(flow-api-test
|
|
||||||
"pending: lists suspended flows with their waiting tag"
|
|
||||||
(flow-a
|
|
||||||
"(defflow w (lambda (x) (suspend (quote review)))) (flow/start w 0) (flow/pending)")
|
|
||||||
(list (list 1 "review")))
|
|
||||||
(flow-api-test
|
|
||||||
"pending: excludes completed and cancelled flows"
|
|
||||||
(flow-a
|
|
||||||
"(defflow w (lambda (x) (suspend (quote q)))) (defflow v (sequence (lambda (x) (suspend (quote r))) (lambda (y) y))) (define i1 (car (cdr (flow/start w 0)))) (define i2 (car (cdr (flow/start v 0)))) (define i3 (car (cdr (flow/start w 0)))) (flow/resume i2 1) (flow/cancel i3) (flow/pending)")
|
|
||||||
(list (list 1 "q")))
|
|
||||||
(flow-api-test
|
|
||||||
"pending: operator can drain all pending flows"
|
|
||||||
(flow-a
|
|
||||||
"(defflow w (sequence (lambda (x) (suspend (quote q))) (lambda (v) (* v 10)))) (flow/start w 0) (flow/start w 0) (define ps (flow/pending)) (flow/resume (car (car ps)) 1) (flow/resume (car (car (cdr ps))) 2) (flow/list)")
|
|
||||||
(list (list 1 "done") (list 2 "done")))
|
|
||||||
|
|
||||||
(define flow-api-tests-run! (fn () {:total (+ flow-api-pass flow-api-fail) :passed flow-api-pass :failed flow-api-fail :fails flow-api-fails}))
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
;; lib/flow/tests/basic.sx — Phase 1: declarative DAG + sequential execution.
|
|
||||||
|
|
||||||
(define flow-basic-pass 0)
|
|
||||||
(define flow-basic-fail 0)
|
|
||||||
(define flow-basic-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-basic-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! flow-basic-pass (+ flow-basic-pass 1))
|
|
||||||
(begin
|
|
||||||
(set! flow-basic-fail (+ flow-basic-fail 1))
|
|
||||||
(append! flow-basic-fails {:name name :expected expected :actual actual})))))
|
|
||||||
|
|
||||||
;; Run a Scheme flow-program string and return its final value.
|
|
||||||
(define flow-b (fn (src) (flow-run src)))
|
|
||||||
|
|
||||||
;; Scheme strings are boxed as {:scm-string "..."}; unwrap to a host string.
|
|
||||||
(define flow-bs (fn (src) (get (flow-run src) :scm-string)))
|
|
||||||
|
|
||||||
;; ── single node ─────────────────────────────────────────────────
|
|
||||||
(flow-basic-test
|
|
||||||
"node: identity passes input through"
|
|
||||||
(flow-b "(flow/start flow-id 7)")
|
|
||||||
7)
|
|
||||||
(flow-basic-test
|
|
||||||
"node: const ignores input"
|
|
||||||
(flow-b "(flow/start (flow-const 99) 1)")
|
|
||||||
99)
|
|
||||||
(flow-basic-test
|
|
||||||
"node: bare lambda is a node"
|
|
||||||
(flow-b "(flow/start (lambda (x) (* x x)) 6)")
|
|
||||||
36)
|
|
||||||
|
|
||||||
;; ── linear sequence ─────────────────────────────────────────────
|
|
||||||
(flow-basic-test
|
|
||||||
"sequence: empty is identity"
|
|
||||||
(flow-b "(flow/start (sequence) 42)")
|
|
||||||
42)
|
|
||||||
(flow-basic-test
|
|
||||||
"sequence: single child"
|
|
||||||
(flow-b "(flow/start (sequence (lambda (x) (+ x 1))) 41)")
|
|
||||||
42)
|
|
||||||
(flow-basic-test
|
|
||||||
"sequence: two children thread"
|
|
||||||
(flow-b
|
|
||||||
"(flow/start (sequence (lambda (x) (+ x 1)) (lambda (x) (* x 10))) 4)")
|
|
||||||
50)
|
|
||||||
(flow-basic-test
|
|
||||||
"sequence: three children thread"
|
|
||||||
(flow-b
|
|
||||||
"(flow/start (sequence (lambda (x) (+ x 1)) (lambda (x) (* x 2)) (lambda (x) (- x 3))) 5)")
|
|
||||||
9)
|
|
||||||
|
|
||||||
;; ── data flow between nodes ─────────────────────────────────────
|
|
||||||
(flow-basic-test
|
|
||||||
"data flow: string accumulation"
|
|
||||||
(flow-bs
|
|
||||||
"(flow/start (sequence (lambda (s) (string-append s \"-a\")) (lambda (s) (string-append s \"-b\"))) \"x\")")
|
|
||||||
"x-a-b")
|
|
||||||
(flow-basic-test
|
|
||||||
"data flow: list build"
|
|
||||||
(flow-b
|
|
||||||
"(flow/start (sequence (lambda (x) (cons x (list))) (lambda (xs) (cons 0 xs))) 7)")
|
|
||||||
(list 0 7))
|
|
||||||
|
|
||||||
;; ── defflow ─────────────────────────────────────────────────────
|
|
||||||
(flow-basic-test
|
|
||||||
"defflow: names a flow"
|
|
||||||
(flow-b
|
|
||||||
"(defflow inc2 (sequence (lambda (x) (+ x 1)) (lambda (x) (+ x 1)))) (flow/start inc2 40)")
|
|
||||||
42)
|
|
||||||
(flow-basic-test
|
|
||||||
"defflow: reusable"
|
|
||||||
(flow-b
|
|
||||||
"(defflow dbl (lambda (x) (* x 2))) (+ (flow/start dbl 3) (flow/start dbl 10))")
|
|
||||||
26)
|
|
||||||
|
|
||||||
;; ── parallel (sequential semantics, join into list) ─────────────
|
|
||||||
(flow-basic-test
|
|
||||||
"parallel: fans input to all branches"
|
|
||||||
(flow-b
|
|
||||||
"(flow/start (parallel (lambda (x) (+ x 1)) (lambda (x) (* x 2)) (lambda (x) (- x 3))) 10)")
|
|
||||||
(list 11 20 7))
|
|
||||||
(flow-basic-test
|
|
||||||
"parallel: empty joins to empty list"
|
|
||||||
(flow-b "(flow/start (parallel) 5)")
|
|
||||||
(list))
|
|
||||||
(flow-basic-test
|
|
||||||
"parallel: single branch"
|
|
||||||
(flow-b "(flow/start (parallel (lambda (x) (* x x))) 9)")
|
|
||||||
(list 81))
|
|
||||||
|
|
||||||
;; ── nested composition ──────────────────────────────────────────
|
|
||||||
(flow-basic-test
|
|
||||||
"nested: sequence of sequences"
|
|
||||||
(flow-b
|
|
||||||
"(flow/start (sequence (sequence (lambda (x) (+ x 1)) (lambda (x) (+ x 1))) (sequence (lambda (x) (* x 3)))) 0)")
|
|
||||||
6)
|
|
||||||
(flow-basic-test
|
|
||||||
"nested: parallel inside sequence, join then reduce"
|
|
||||||
(flow-b
|
|
||||||
"(flow/start (sequence (parallel (lambda (x) (+ x 1)) (lambda (x) (* x 2))) (lambda (xs) (apply + xs))) 10)")
|
|
||||||
31)
|
|
||||||
(flow-basic-test
|
|
||||||
"nested: sequence inside parallel branch"
|
|
||||||
(flow-b
|
|
||||||
"(flow/start (parallel (sequence (lambda (x) (+ x 1)) (lambda (x) (* x 2))) (lambda (x) x)) 5)")
|
|
||||||
(list 12 5))
|
|
||||||
|
|
||||||
;; ── publish-shaped flow (the architecture sketch) ───────────────
|
|
||||||
(flow-basic-test
|
|
||||||
"publish: write -> (review | spell) -> join lengths"
|
|
||||||
(flow-b
|
|
||||||
"(defflow publish (sequence (lambda (draft) (string-append draft \"!\")) (parallel (lambda (c) (string-length c)) (lambda (c) (string-length (string-append c \"?\")))))) (flow/start publish \"hi\")")
|
|
||||||
(list 3 4))
|
|
||||||
|
|
||||||
(define flow-basic-tests-run! (fn () {:total (+ flow-basic-pass flow-basic-fail) :passed flow-basic-pass :failed flow-basic-fail :fails flow-basic-fails}))
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
;; lib/flow/tests/combinators.sx — Phase 5: combinator library (tap, recover, map-flow, iteration).
|
|
||||||
|
|
||||||
(define flow-cmb-pass 0)
|
|
||||||
(define flow-cmb-fail 0)
|
|
||||||
(define flow-cmb-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-cmb-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! flow-cmb-pass (+ flow-cmb-pass 1))
|
|
||||||
(begin
|
|
||||||
(set! flow-cmb-fail (+ flow-cmb-fail 1))
|
|
||||||
(append! flow-cmb-fails {:name name :expected expected :actual actual})))))
|
|
||||||
|
|
||||||
(define flow-m (fn (src) (flow-run src)))
|
|
||||||
|
|
||||||
;; ── tap (side-effecting pass-through) ───────────────────────────
|
|
||||||
(flow-cmb-test
|
|
||||||
"tap: returns input unchanged"
|
|
||||||
(flow-m "(flow/start (tap (lambda (x) (* x 999))) 7)")
|
|
||||||
7)
|
|
||||||
(flow-cmb-test
|
|
||||||
"tap: runs the side effect"
|
|
||||||
(flow-m
|
|
||||||
"(define seen 0) (flow/start (tap (lambda (x) (set! seen x))) 42) seen")
|
|
||||||
42)
|
|
||||||
(flow-cmb-test
|
|
||||||
"tap: value flows on while the effect observes it"
|
|
||||||
(flow-m
|
|
||||||
"(define log 0) (flow/start (sequence (lambda (x) (+ x 1)) (tap (lambda (x) (set! log x))) (lambda (x) (* x 2))) 10) (list log (flow/result 1))")
|
|
||||||
(list 11 22))
|
|
||||||
|
|
||||||
;; ── recover (fail-value counterpart of try-catch) ───────────────
|
|
||||||
(flow-cmb-test
|
|
||||||
"recover: passes a non-fail value through"
|
|
||||||
(flow-m "(flow/start (recover (lambda (x) (* x 2)) (lambda (r) -1)) 5)")
|
|
||||||
10)
|
|
||||||
(flow-cmb-test
|
|
||||||
"recover: handles a fail value via the reason"
|
|
||||||
(flow-m
|
|
||||||
"(flow/start (recover (lambda (x) (fail (quote too-small))) (lambda (r) (list (quote recovered) r))) 1)")
|
|
||||||
(list "recovered" "too-small"))
|
|
||||||
(flow-cmb-test
|
|
||||||
"recover: handler can supply a default value"
|
|
||||||
(flow-m
|
|
||||||
"(flow/start (sequence (recover (lambda (x) (if (> x 0) x (fail (quote neg))) ) (flow-const 0)) (lambda (x) (* x 10))) -3)")
|
|
||||||
0)
|
|
||||||
(flow-cmb-test
|
|
||||||
"recover: does not catch raised exceptions (those are try-catch's job)"
|
|
||||||
(flow-m
|
|
||||||
"(flow/start (try-catch (recover (lambda (x) (raise (quote boom))) (flow-const 0)) (lambda (e) e)) 1)")
|
|
||||||
"boom")
|
|
||||||
|
|
||||||
;; ── map-flow (run a node over a list, join) ─────────────────────
|
|
||||||
(flow-cmb-test
|
|
||||||
"map-flow: applies the node to each item"
|
|
||||||
(flow-m "(flow/start (map-flow (lambda (x) (* x x))) (list 1 2 3 4))")
|
|
||||||
(list 1 4 9 16))
|
|
||||||
(flow-cmb-test
|
|
||||||
"map-flow: empty list joins to empty"
|
|
||||||
(flow-m "(flow/start (map-flow (lambda (x) (+ x 1))) (list))")
|
|
||||||
(list))
|
|
||||||
(flow-cmb-test
|
|
||||||
"map-flow: each item runs an independent sub-flow"
|
|
||||||
(flow-m
|
|
||||||
"(flow/start (map-flow (sequence (lambda (x) (+ x 1)) (lambda (x) (* x 2)))) (list 0 4 9))")
|
|
||||||
(list 2 10 20))
|
|
||||||
(flow-cmb-test
|
|
||||||
"map-flow: composes — fan over a list then reduce the join"
|
|
||||||
(flow-m
|
|
||||||
"(flow/start (sequence (map-flow (lambda (x) (* x 10))) (lambda (xs) (apply + xs))) (list 1 2 3))")
|
|
||||||
60)
|
|
||||||
|
|
||||||
;; ── flow-while / flow-until (bounded iteration) ─────────────────
|
|
||||||
(flow-cmb-test
|
|
||||||
"flow-while: iterates while the predicate holds"
|
|
||||||
(flow-m
|
|
||||||
"(flow/start (flow-while (lambda (x) (< x 10)) (lambda (x) (+ x 1)) 100) 0)")
|
|
||||||
10)
|
|
||||||
(flow-cmb-test
|
|
||||||
"flow-while: a false predicate leaves input unchanged"
|
|
||||||
(flow-m
|
|
||||||
"(flow/start (flow-while (lambda (x) (< x 0)) (lambda (x) (+ x 1)) 100) 5)")
|
|
||||||
5)
|
|
||||||
(flow-cmb-test
|
|
||||||
"flow-while: respects the max-iteration bound"
|
|
||||||
(flow-m "(flow/start (flow-while (lambda (x) #t) (lambda (x) (+ x 1)) 3) 0)")
|
|
||||||
3)
|
|
||||||
(flow-cmb-test
|
|
||||||
"flow-while: doubles until past a threshold"
|
|
||||||
(flow-m
|
|
||||||
"(flow/start (flow-while (lambda (x) (< x 50)) (lambda (x) (* x 2)) 100) 3)")
|
|
||||||
96)
|
|
||||||
(flow-cmb-test
|
|
||||||
"flow-until: iterates until the predicate becomes true"
|
|
||||||
(flow-m
|
|
||||||
"(flow/start (flow-until (lambda (x) (>= x 10)) (lambda (x) (+ x 3)) 100) 0)")
|
|
||||||
12)
|
|
||||||
(flow-cmb-test
|
|
||||||
"flow-until: composes inside a sequence"
|
|
||||||
(flow-m
|
|
||||||
"(flow/start (sequence (flow-until (lambda (x) (> x 100)) (lambda (x) (* x 3)) 100) (lambda (x) (- x 100))) 5)")
|
|
||||||
35)
|
|
||||||
|
|
||||||
(define flow-cmb-tests-run! (fn () {:total (+ flow-cmb-pass flow-cmb-fail) :passed flow-cmb-pass :failed flow-cmb-fail :fails flow-cmb-fails}))
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
;; lib/flow/tests/control.sx — Phase 2: control flow + error handling.
|
|
||||||
|
|
||||||
(define flow-ctl-pass 0)
|
|
||||||
(define flow-ctl-fail 0)
|
|
||||||
(define flow-ctl-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-ctl-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! flow-ctl-pass (+ flow-ctl-pass 1))
|
|
||||||
(begin
|
|
||||||
(set! flow-ctl-fail (+ flow-ctl-fail 1))
|
|
||||||
(append! flow-ctl-fails {:name name :expected expected :actual actual})))))
|
|
||||||
|
|
||||||
(define flow-c (fn (src) (flow-run src)))
|
|
||||||
(define flow-cs (fn (src) (get (flow-run src) :scm-string)))
|
|
||||||
|
|
||||||
;; ── branch ──────────────────────────────────────────────────────
|
|
||||||
(flow-ctl-test
|
|
||||||
"branch: true selects then"
|
|
||||||
(flow-c
|
|
||||||
"(flow/start (branch (lambda (x) (> x 0)) (lambda (x) (* x 100)) (lambda (x) (- 0 x))) 5)")
|
|
||||||
500)
|
|
||||||
(flow-ctl-test
|
|
||||||
"branch: false selects else"
|
|
||||||
(flow-c
|
|
||||||
"(flow/start (branch (lambda (x) (> x 0)) (lambda (x) (* x 100)) (lambda (x) (- 0 x))) -3)")
|
|
||||||
3)
|
|
||||||
(flow-ctl-test
|
|
||||||
"branch: predicate sees the threaded input"
|
|
||||||
(flow-c
|
|
||||||
"(flow/start (sequence (lambda (x) (+ x 1)) (branch (lambda (x) (> x 3)) (flow-const 100) (flow-const 0))) 3)")
|
|
||||||
100)
|
|
||||||
(flow-ctl-test
|
|
||||||
"branch: branches are full nodes (sequence inside)"
|
|
||||||
(flow-c
|
|
||||||
"(flow/start (branch (lambda (x) (< x 10)) (sequence (lambda (x) (+ x 1)) (lambda (x) (* x 2))) (flow-const 0)) 4)")
|
|
||||||
10)
|
|
||||||
(flow-ctl-test
|
|
||||||
"branch: nested branch (3-way sign)"
|
|
||||||
(flow-c
|
|
||||||
"(defflow sign (branch (lambda (x) (> x 0)) (flow-const 1) (branch (lambda (x) (< x 0)) (flow-const -1) (flow-const 0)))) (list (flow/start sign 7) (flow/start sign -7) (flow/start sign 0))")
|
|
||||||
(list 1 -1 0))
|
|
||||||
(flow-ctl-test
|
|
||||||
"branch: publish-shaped approval gate"
|
|
||||||
(flow-cs
|
|
||||||
"(defflow publish (branch (lambda (post) (>= (string-length post) 3)) (lambda (post) (string-append post \" [published]\")) (lambda (post) (string-append post \" [rejected]\")))) (flow/start publish \"ok\")")
|
|
||||||
"ok [rejected]")
|
|
||||||
|
|
||||||
;; ── error model — explicit (fail reason) values ─────────────────
|
|
||||||
(flow-ctl-test
|
|
||||||
"fail: failed? is true for a failure value"
|
|
||||||
(flow-c "(failed? (fail 404))")
|
|
||||||
true)
|
|
||||||
(flow-ctl-test
|
|
||||||
"fail: fail-reason extracts the reason"
|
|
||||||
(flow-c "(fail-reason (fail 404))")
|
|
||||||
404)
|
|
||||||
(flow-ctl-test
|
|
||||||
"fail: failed? is false for a plain value"
|
|
||||||
(flow-c "(failed? 7)")
|
|
||||||
false)
|
|
||||||
(flow-ctl-test
|
|
||||||
"fail: failed? is false for an ordinary list"
|
|
||||||
(flow-c "(failed? (list 1 2 3))")
|
|
||||||
false)
|
|
||||||
(flow-ctl-test
|
|
||||||
"fail: a node may emit a failure as data"
|
|
||||||
(flow-c
|
|
||||||
"(defflow validate (lambda (s) (if (>= (string-length s) 3) s (fail (quote too-short))))) (failed? (flow/start validate \"hi\"))")
|
|
||||||
true)
|
|
||||||
(flow-ctl-test
|
|
||||||
"fail: failure flows downstream, branch recovers"
|
|
||||||
(flow-c
|
|
||||||
"(defflow guarded (sequence (lambda (s) (if (>= (string-length s) 3) (string-length s) (fail (quote too-short)))) (branch failed? (lambda (f) (list (quote recovered) (fail-reason f))) (lambda (n) (list (quote ok) n))))) (flow/start guarded \"hi\")")
|
|
||||||
(list "recovered" "too-short"))
|
|
||||||
|
|
||||||
;; ── try-catch — reify raised exceptions ─────────────────────────
|
|
||||||
(flow-ctl-test
|
|
||||||
"try-catch: no exception returns node result"
|
|
||||||
(flow-c "(flow/start (try-catch (lambda (x) (* x 2)) (lambda (e) -1)) 5)")
|
|
||||||
10)
|
|
||||||
(flow-ctl-test
|
|
||||||
"try-catch: handler runs on raise"
|
|
||||||
(flow-c
|
|
||||||
"(flow/start (try-catch (lambda (x) (raise (quote boom))) (flow-const 99)) 1)")
|
|
||||||
99)
|
|
||||||
(flow-ctl-test
|
|
||||||
"try-catch: handler receives the reified error"
|
|
||||||
(flow-c "(flow/start (try-catch (lambda (x) (raise 42)) (lambda (e) e)) 0)")
|
|
||||||
42)
|
|
||||||
(flow-ctl-test
|
|
||||||
"try-catch: catches exception from deep inside a sequence"
|
|
||||||
(flow-c
|
|
||||||
"(flow/start (try-catch (sequence (lambda (x) (+ x 1)) (lambda (x) (raise (quote deep)))) (flow-const -99)) 5)")
|
|
||||||
-99)
|
|
||||||
(flow-ctl-test
|
|
||||||
"try-catch: handler may convert to a failure value"
|
|
||||||
(flow-c
|
|
||||||
"(failed? (flow/start (try-catch (lambda (x) (raise (quote bad))) (lambda (e) (fail e))) 0))")
|
|
||||||
true)
|
|
||||||
(flow-ctl-test
|
|
||||||
"try-catch: composes — recover then continue"
|
|
||||||
(flow-c
|
|
||||||
"(flow/start (sequence (try-catch (lambda (x) (raise (quote x))) (flow-const 10)) (lambda (n) (* n 5))) 0)")
|
|
||||||
50)
|
|
||||||
|
|
||||||
;; ── retry — re-run on raised exceptions ─────────────────────────
|
|
||||||
(flow-ctl-test
|
|
||||||
"retry: succeeds after transient failures"
|
|
||||||
(flow-c
|
|
||||||
"(define ctr 0) (defflow flaky (lambda (x) (set! ctr (+ ctr 1)) (if (< ctr 3) (raise (quote nope)) (* x 10)))) (list (flow/start (retry 5 flaky) 7) ctr)")
|
|
||||||
(list 70 3))
|
|
||||||
(flow-ctl-test
|
|
||||||
"retry: exhausted re-raises (caught by try-catch)"
|
|
||||||
(flow-c
|
|
||||||
"(flow/start (try-catch (retry 2 (lambda (x) (raise (quote always)))) (flow-const (quote gaveup))) 0)")
|
|
||||||
"gaveup")
|
|
||||||
(flow-ctl-test
|
|
||||||
"retry: n=1 means a single attempt"
|
|
||||||
(flow-c
|
|
||||||
"(define ctr 0) (flow/start (try-catch (retry 1 (lambda (x) (set! ctr (+ ctr 1)) (raise (quote bad)))) (lambda (e) ctr)) 0)")
|
|
||||||
1)
|
|
||||||
(flow-ctl-test
|
|
||||||
"retry: success on first attempt does not re-run"
|
|
||||||
(flow-c
|
|
||||||
"(define ctr 0) (flow/start (sequence (retry 5 (lambda (x) (set! ctr (+ ctr 1)) (* x 2))) (lambda (n) ctr)) 21)")
|
|
||||||
1)
|
|
||||||
(flow-ctl-test
|
|
||||||
"retry: does not retry explicit failure values"
|
|
||||||
(flow-c
|
|
||||||
"(define ctr 0) (failed? (flow/start (retry 5 (lambda (x) (set! ctr (+ ctr 1)) (fail (quote bad)))) 0))")
|
|
||||||
true)
|
|
||||||
(flow-ctl-test
|
|
||||||
"retry: failure-value path runs node exactly once"
|
|
||||||
(flow-c
|
|
||||||
"(define ctr 0) (flow/start (sequence (retry 5 (lambda (x) (set! ctr (+ ctr 1)) (fail (quote bad)))) (lambda (f) ctr)) 0)")
|
|
||||||
1)
|
|
||||||
|
|
||||||
;; ── timeout — cooperative step budget ───────────────────────────
|
|
||||||
(flow-ctl-test
|
|
||||||
"timeout: work within budget completes"
|
|
||||||
(flow-c
|
|
||||||
"(define (cd n) (if (<= n 0) 99 (begin (tick) (cd (- n 1))))) (flow/start (try-catch (timeout 10 (lambda (x) (cd x))) (flow-const (quote timed-out))) 5)")
|
|
||||||
99)
|
|
||||||
(flow-ctl-test
|
|
||||||
"timeout: work exceeding budget raises flow-timeout"
|
|
||||||
(flow-c
|
|
||||||
"(define (cd n) (if (<= n 0) 99 (begin (tick) (cd (- n 1))))) (flow/start (try-catch (timeout 10 (lambda (x) (cd x))) (flow-const (quote timed-out))) 20)")
|
|
||||||
"timed-out")
|
|
||||||
(flow-ctl-test
|
|
||||||
"timeout: exact budget boundary completes"
|
|
||||||
(flow-c
|
|
||||||
"(define (cd n) (if (<= n 0) 99 (begin (tick) (cd (- n 1))))) (flow/start (try-catch (timeout 5 (lambda (x) (cd x))) (flow-const (quote timed-out))) 5)")
|
|
||||||
99)
|
|
||||||
(flow-ctl-test
|
|
||||||
"timeout: one tick over the budget raises"
|
|
||||||
(flow-c
|
|
||||||
"(define (cd n) (if (<= n 0) 99 (begin (tick) (cd (- n 1))))) (flow/start (try-catch (timeout 5 (lambda (x) (cd x))) (flow-const (quote timed-out))) 6)")
|
|
||||||
"timed-out")
|
|
||||||
(flow-ctl-test
|
|
||||||
"timeout: the raised error is identifiable"
|
|
||||||
(flow-c
|
|
||||||
"(define (cd n) (if (<= n 0) 99 (begin (tick) (cd (- n 1))))) (flow/start (try-catch (timeout 2 (lambda (x) (cd x))) (lambda (e) e)) 9)")
|
|
||||||
"flow-timeout")
|
|
||||||
(flow-ctl-test
|
|
||||||
"timeout: a node that never ticks is unbounded"
|
|
||||||
(flow-c "(flow/start (timeout 0 (lambda (x) (* x 2))) 5)")
|
|
||||||
10)
|
|
||||||
(flow-ctl-test
|
|
||||||
"timeout: budget is restored across sequential timeouts"
|
|
||||||
(flow-c
|
|
||||||
"(define (cd n) (if (<= n 0) 1 (begin (tick) (cd (- n 1))))) (flow/start (sequence (timeout 4 (lambda (x) (cd x))) (timeout 4 (lambda (x) (cd 3))) (lambda (x) (begin (tick) (+ x 100)))) 3)")
|
|
||||||
101)
|
|
||||||
|
|
||||||
(define flow-ctl-tests-run! (fn () {:total (+ flow-ctl-pass flow-ctl-fail) :passed flow-ctl-pass :failed flow-ctl-fail :fails flow-ctl-fails}))
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
;; lib/flow/tests/distributed.sx — Phase 4: distributed nodes via fed-sx (mocked).
|
|
||||||
|
|
||||||
(define flow-dist-pass 0)
|
|
||||||
(define flow-dist-fail 0)
|
|
||||||
(define flow-dist-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-dist-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! flow-dist-pass (+ flow-dist-pass 1))
|
|
||||||
(begin
|
|
||||||
(set! flow-dist-fail (+ flow-dist-fail 1))
|
|
||||||
(append! flow-dist-fails {:name name :expected expected :actual actual})))))
|
|
||||||
|
|
||||||
(define flow-d (fn (src) (flow-run src)))
|
|
||||||
|
|
||||||
;; ── remote-node ─────────────────────────────────────────────────
|
|
||||||
(flow-dist-test
|
|
||||||
"remote: a node executes on a peer"
|
|
||||||
(flow-d
|
|
||||||
"(flow-peer-register! (quote edge) (list (list (quote double) (lambda (x) (* x 2))))) (flow/start (remote-node (quote edge) (quote double)) 21)")
|
|
||||||
42)
|
|
||||||
(flow-dist-test
|
|
||||||
"remote: remote nodes compose in a sequence"
|
|
||||||
(flow-d
|
|
||||||
"(flow-peer-register! (quote edge) (list (list (quote inc) (lambda (x) (+ x 1))) (list (quote double) (lambda (x) (* x 2))))) (flow/start (sequence (remote-node (quote edge) (quote inc)) (remote-node (quote edge) (quote double))) 4)")
|
|
||||||
10)
|
|
||||||
(flow-dist-test
|
|
||||||
"remote: a remote node mixes with local nodes"
|
|
||||||
(flow-d
|
|
||||||
"(flow-peer-register! (quote edge) (list (list (quote double) (lambda (x) (* x 2))))) (flow/start (sequence (lambda (x) (+ x 5)) (remote-node (quote edge) (quote double)) (lambda (x) (- x 1))) 10)")
|
|
||||||
29)
|
|
||||||
(flow-dist-test
|
|
||||||
"remote: unreachable peer raises flow-remote-unreachable"
|
|
||||||
(flow-d
|
|
||||||
"(flow/start (try-catch (remote-node (quote ghost) (quote double)) (lambda (e) e)) 1)")
|
|
||||||
"flow-remote-unreachable")
|
|
||||||
(flow-dist-test
|
|
||||||
"remote: unknown function on a peer raises flow-remote-no-fn"
|
|
||||||
(flow-d
|
|
||||||
"(flow-peer-register! (quote edge) (list (list (quote double) (lambda (x) (* x 2))))) (flow/start (try-catch (remote-node (quote edge) (quote missing)) (lambda (e) e)) 1)")
|
|
||||||
"flow-remote-no-fn")
|
|
||||||
(flow-dist-test
|
|
||||||
"remote: a remote node can suspend the flow (peer returns control)"
|
|
||||||
(flow-d
|
|
||||||
"(flow-peer-register! (quote edge) (list (list (quote review) (lambda (x) x)))) (flow/start (sequence (remote-node (quote edge) (quote review)) (lambda (x) (suspend (quote human))) (lambda (v) (list (quote published) v))) 7)")
|
|
||||||
(list "flow-suspended" 1 "human"))
|
|
||||||
(flow-dist-test
|
|
||||||
"remote: a transient remote failure is recoverable with retry"
|
|
||||||
(flow-d
|
|
||||||
"(define hits 0) (flow-peer-register! (quote edge) (list (list (quote flaky) (lambda (x) (begin (set! hits (+ hits 1)) (if (< hits 2) (raise (quote down)) (* x 3))))))) (list (flow/start (retry 3 (remote-node (quote edge) (quote flaky))) 7) hits)")
|
|
||||||
(list 21 2))
|
|
||||||
|
|
||||||
;; ── failover (retry on a different peer, fall through to local) ──
|
|
||||||
(flow-dist-test
|
|
||||||
"failover: first reachable peer serves the request"
|
|
||||||
(flow-d
|
|
||||||
"(flow-peer-register! (quote p2) (list (list (quote f) (lambda (x) (+ x 100))))) (flow/start (remote-failover (list (quote p2) (quote down)) (quote f) (flow-const (quote local))) 5)")
|
|
||||||
105)
|
|
||||||
(flow-dist-test
|
|
||||||
"failover: skips an unreachable peer to the next one"
|
|
||||||
(flow-d
|
|
||||||
"(flow-peer-register! (quote p2) (list (list (quote f) (lambda (x) (+ x 100))))) (flow/start (remote-failover (list (quote down) (quote p2)) (quote f) (flow-const (quote local))) 5)")
|
|
||||||
105)
|
|
||||||
(flow-dist-test
|
|
||||||
"failover: skips a peer whose function raises"
|
|
||||||
(flow-d
|
|
||||||
"(flow-peer-register! (quote bad) (list (list (quote f) (lambda (x) (raise (quote boom)))))) (flow-peer-register! (quote good) (list (list (quote f) (lambda (x) (* x 10))))) (flow/start (remote-failover (list (quote bad) (quote good)) (quote f) (flow-const 0)) 4)")
|
|
||||||
40)
|
|
||||||
(flow-dist-test
|
|
||||||
"failover: all peers fail, the local fallback runs"
|
|
||||||
(flow-d
|
|
||||||
"(flow/start (remote-failover (list (quote down1) (quote down2)) (quote f) (lambda (x) (* x -1))) 9)")
|
|
||||||
-9)
|
|
||||||
(flow-dist-test
|
|
||||||
"failover: threads the input through to the chosen peer"
|
|
||||||
(flow-d
|
|
||||||
"(flow-peer-register! (quote p) (list (list (quote f) (lambda (x) (list (quote got) x))))) (flow/start (sequence (lambda (x) (+ x 1)) (remote-failover (list (quote p)) (quote f) (flow-const 0))) 41)")
|
|
||||||
(list "got" 42))
|
|
||||||
(flow-dist-test
|
|
||||||
"failover: composes inside a larger sequence"
|
|
||||||
(flow-d
|
|
||||||
"(flow-peer-register! (quote p) (list (list (quote f) (lambda (x) (* x 2))))) (flow/start (sequence (remote-failover (list (quote down) (quote p)) (quote f) (flow-const 1)) (lambda (x) (+ x 3))) 5)")
|
|
||||||
13)
|
|
||||||
|
|
||||||
;; ── replication + handoff ───────────────────────────────────────
|
|
||||||
(flow-dist-test
|
|
||||||
"replicate: a peer holds the exported store"
|
|
||||||
(flow-d
|
|
||||||
"(defflow w (lambda (x) (suspend (quote q)))) (flow/start w 10) (flow-replicate-to (quote peerB)) (if (flow-replica-get (quote peerB)) (quote replicated) (quote missing))")
|
|
||||||
"replicated")
|
|
||||||
(flow-dist-test
|
|
||||||
"handoff: a peer resumes a flow after the local instance dies"
|
|
||||||
(flow-d
|
|
||||||
"(defflow w (sequence (lambda (x) (suspend (quote q))) (lambda (v) (list (quote done) v)))) (define id (car (cdr (flow/start w 10)))) (flow-replicate-to (quote peerB)) (set! flow-store (list)) (flow-restore-from (quote peerB)) (flow/resume id 55)")
|
|
||||||
(list "done" 55))
|
|
||||||
(flow-dist-test
|
|
||||||
"handoff: restored peer reports the flow as resumable"
|
|
||||||
(flow-d
|
|
||||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 10)))) (flow-replicate-to (quote peerB)) (set! flow-store (list)) (flow-restore-from (quote peerB)) (flow-resumable-ids)")
|
|
||||||
(list 1))
|
|
||||||
(flow-dist-test
|
|
||||||
"handoff: without restore the dead instance has lost the flow"
|
|
||||||
(flow-d
|
|
||||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 10)))) (flow-replicate-to (quote peerB)) (set! flow-store (list)) (flow/resume id 1)")
|
|
||||||
(list "flow-error" "no-such-flow"))
|
|
||||||
(flow-dist-test
|
|
||||||
"restore: from an unknown peer yields false"
|
|
||||||
(flow-d "(flow-restore-from (quote nowhere))")
|
|
||||||
false)
|
|
||||||
(flow-dist-test
|
|
||||||
"handoff: replication preserves the replay log across the move"
|
|
||||||
(flow-d
|
|
||||||
"(defflow two (sequence (lambda (x) (suspend (quote a))) (lambda (x) (suspend (quote b))) (lambda (x) (list x)))) (define id (car (cdr (flow/start two 0)))) (flow/resume id 11) (flow-replicate-to (quote peerB)) (set! flow-store (list)) (flow-restore-from (quote peerB)) (flow/resume id 22)")
|
|
||||||
(list 22))
|
|
||||||
|
|
||||||
(define flow-dist-tests-run! (fn () {:total (+ flow-dist-pass flow-dist-fail) :passed flow-dist-pass :failed flow-dist-fail :fails flow-dist-fails}))
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
;; lib/flow/tests/host.sx — Phase 8: host integration ABI (request/await/host-queue/driver).
|
|
||||||
|
|
||||||
(define flow-hst-pass 0)
|
|
||||||
(define flow-hst-fail 0)
|
|
||||||
(define flow-hst-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-hst-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! flow-hst-pass (+ flow-hst-pass 1))
|
|
||||||
(begin
|
|
||||||
(set! flow-hst-fail (+ flow-hst-fail 1))
|
|
||||||
(append! flow-hst-fails {:name name :expected expected :actual actual})))))
|
|
||||||
|
|
||||||
(define flow-hst (fn (src) (flow-run src)))
|
|
||||||
|
|
||||||
;; ── request envelope ────────────────────────────────────────────
|
|
||||||
(flow-hst-test
|
|
||||||
"request: suspends with a typed envelope"
|
|
||||||
(flow-hst
|
|
||||||
"(car (cdr (cdr (flow/start (lambda (x) (request (quote render) x)) 5))))")
|
|
||||||
(list "flow-request" "render" 5))
|
|
||||||
(flow-hst-test
|
|
||||||
"request?: recognizes an envelope"
|
|
||||||
(flow-hst "(request? (list (quote flow-request) (quote human) 1))")
|
|
||||||
true)
|
|
||||||
(flow-hst-test
|
|
||||||
"request?: a plain tag is not a request"
|
|
||||||
(flow-hst "(request? (list (quote review) 1))")
|
|
||||||
false)
|
|
||||||
(flow-hst-test
|
|
||||||
"request-kind / request-payload: parse the envelope"
|
|
||||||
(flow-hst
|
|
||||||
"(define t (list (quote flow-request) (quote render) (list (quote recipe) 7))) (list (request-kind t) (request-payload t))")
|
|
||||||
(list "render" (list "recipe" 7)))
|
|
||||||
|
|
||||||
;; ── named decision points ───────────────────────────────────────
|
|
||||||
(flow-hst-test
|
|
||||||
"await-human: is a request of kind human"
|
|
||||||
(flow-hst
|
|
||||||
"(car (cdr (cdr (flow/start (lambda (x) (await-human x)) (quote approve?)))))")
|
|
||||||
(list "flow-request" "human" "approve?"))
|
|
||||||
(flow-hst-test
|
|
||||||
"await-render: is a request of kind render"
|
|
||||||
(flow-hst
|
|
||||||
"(car (cdr (cdr (flow/start (lambda (x) (await-render x)) (quote recipe)))))")
|
|
||||||
(list "flow-request" "render" "recipe"))
|
|
||||||
(flow-hst-test
|
|
||||||
"request: the host's resume value flows back into the flow"
|
|
||||||
(flow-hst
|
|
||||||
"(defflow f (sequence (lambda (x) (await-render x)) (lambda (art) (list (quote got) art)))) (define id (car (cdr (flow/start f 1)))) (flow/resume id (quote the-artifact))")
|
|
||||||
(list "got" "the-artifact"))
|
|
||||||
|
|
||||||
;; ── host work queue ─────────────────────────────────────────────
|
|
||||||
(flow-hst-test
|
|
||||||
"flow-host-requests: lists (id kind payload) for pending requests"
|
|
||||||
(flow-hst
|
|
||||||
"(flow/start (lambda (x) (await-render x)) 99) (flow-host-requests)")
|
|
||||||
(list (list 1 "render" 99)))
|
|
||||||
(flow-hst-test
|
|
||||||
"flow-host-requests: excludes bare (non-request) suspends"
|
|
||||||
(flow-hst
|
|
||||||
"(defflow a (lambda (x) (await-render x))) (defflow b (lambda (x) (suspend (quote plain)))) (flow/start a 1) (flow/start b 2) (flow-host-requests)")
|
|
||||||
(list (list 1 "render" 1)))
|
|
||||||
|
|
||||||
;; ── the art-dag-shaped host driver loop (manual resumes) ────────
|
|
||||||
(flow-hst-test
|
|
||||||
"host driver: render then human-review then publish"
|
|
||||||
(flow-hst
|
|
||||||
"(defflow pipeline (sequence (lambda (recipe) (await-render recipe)) (lambda (art) (await-human (list (quote review) art))) (branch (lambda (d) (eq? d (quote approve))) (flow-const (quote published)) (flow-const (fail (quote rejected)))))) (define id (car (cdr (flow/start pipeline 99)))) (define r1 (flow-host-requests)) (flow/resume id (list (quote art) 99)) (define r2 (flow-host-requests)) (flow/resume id (quote approve)) (list r1 r2 (flow/status id) (flow/result id))")
|
|
||||||
(list
|
|
||||||
(list (list 1 "render" 99))
|
|
||||||
(list (list 1 "human" (list "review" (list "art" 99))))
|
|
||||||
"done"
|
|
||||||
"published"))
|
|
||||||
(flow-hst-test
|
|
||||||
"host driver: rejection at the human gate yields a failure"
|
|
||||||
(flow-hst
|
|
||||||
"(defflow pipeline (sequence (lambda (recipe) (await-render recipe)) (lambda (art) (await-human (list (quote review) art))) (branch (lambda (d) (eq? d (quote approve))) (flow-const (quote published)) (flow-const (fail (quote rejected)))))) (define id (car (cdr (flow/start pipeline 1)))) (flow/resume id (quote artifact)) (failed? (flow/resume id (quote reject)))")
|
|
||||||
true)
|
|
||||||
|
|
||||||
;; ── reference driver: host supplies only a dispatch fn ──────────
|
|
||||||
(flow-hst-test
|
|
||||||
"flow-drive-host: one tick services every pending request"
|
|
||||||
(flow-hst
|
|
||||||
"(flow/start (lambda (x) (await-render x)) 5) (define n (flow-drive-host (lambda (k p) (list (quote done) p)))) (list n (flow/status 1) (flow/result 1))")
|
|
||||||
(list 1 "done" (list "done" 5)))
|
|
||||||
(flow-hst-test
|
|
||||||
"flow-run-host: drives a render -> human pipeline to completion"
|
|
||||||
(flow-hst
|
|
||||||
"(defflow pipeline (sequence (lambda (recipe) (await-render recipe)) (lambda (art) (await-human (list (quote review) art))) (branch (lambda (d) (eq? d (quote approve))) (flow-const (quote published)) (flow-const (fail (quote rejected)))))) (define id (car (cdr (flow/start pipeline 99)))) (define serviced (flow-run-host (lambda (kind payload) (if (eq? kind (quote render)) (list (quote art) payload) (quote approve))) 10)) (list serviced (flow/status id) (flow/result id))")
|
|
||||||
(list 2 "done" "published"))
|
|
||||||
(flow-hst-test
|
|
||||||
"flow-run-host: returns 0 when nothing is pending"
|
|
||||||
(flow-hst "(flow-run-host (lambda (k p) p) 5)")
|
|
||||||
0)
|
|
||||||
(flow-hst-test
|
|
||||||
"flow-run-host: respects the maxticks bound"
|
|
||||||
(flow-hst
|
|
||||||
"(defflow pipe2 (sequence (lambda (r) (await-render r)) (lambda (a) (await-human a)) (lambda (d) d))) (define id (car (cdr (flow/start pipe2 1)))) (define serviced (flow-run-host (lambda (k p) p) 1)) (list serviced (flow/status id))")
|
|
||||||
(list 1 "suspended"))
|
|
||||||
|
|
||||||
(define flow-hst-tests-run! (fn () {:total (+ flow-hst-pass flow-hst-fail) :passed flow-hst-pass :failed flow-hst-fail :fails flow-hst-fails}))
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
;; lib/flow/tests/hygiene.sx — Phase 5: store hygiene (flow/gc, flow/forget).
|
|
||||||
|
|
||||||
(define flow-hyg-pass 0)
|
|
||||||
(define flow-hyg-fail 0)
|
|
||||||
(define flow-hyg-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-hyg-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! flow-hyg-pass (+ flow-hyg-pass 1))
|
|
||||||
(begin
|
|
||||||
(set! flow-hyg-fail (+ flow-hyg-fail 1))
|
|
||||||
(append! flow-hyg-fails {:name name :expected expected :actual actual})))))
|
|
||||||
|
|
||||||
(define flow-h (fn (src) (flow-run src)))
|
|
||||||
|
|
||||||
;; ── flow/gc ─────────────────────────────────────────────────────
|
|
||||||
(flow-hyg-test
|
|
||||||
"gc: empty store removes nothing"
|
|
||||||
(flow-h "(flow/gc)")
|
|
||||||
0)
|
|
||||||
(flow-hyg-test
|
|
||||||
"gc: removes a done flow, keeps a suspended one"
|
|
||||||
(flow-h
|
|
||||||
"(defflow w (lambda (x) (suspend (quote q)))) (flow/start w 0) (flow/start (lambda (x) x) 5) (define removed (flow/gc)) (list removed (flow/list))")
|
|
||||||
(list 1 (list (list 1 "suspended"))))
|
|
||||||
(flow-hyg-test
|
|
||||||
"gc: removes a cancelled flow"
|
|
||||||
(flow-h
|
|
||||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/cancel id) (flow/gc)")
|
|
||||||
1)
|
|
||||||
(flow-hyg-test
|
|
||||||
"gc: a kept suspended flow is still resumable"
|
|
||||||
(flow-h
|
|
||||||
"(defflow w (sequence (lambda (x) (suspend (quote q))) (lambda (v) (* v 2)))) (define id (car (cdr (flow/start w 0)))) (flow/start (lambda (x) x) 1) (flow/gc) (flow/resume id 21)")
|
|
||||||
42)
|
|
||||||
(flow-hyg-test
|
|
||||||
"gc: counts every terminal flow it drops"
|
|
||||||
(flow-h
|
|
||||||
"(flow/start (lambda (x) x) 1) (flow/start (lambda (x) x) 2) (defflow w (lambda (x) (suspend (quote q)))) (flow/start w 0) (flow/gc)")
|
|
||||||
2)
|
|
||||||
|
|
||||||
;; ── flow/forget ─────────────────────────────────────────────────
|
|
||||||
(flow-hyg-test
|
|
||||||
"forget: drops a completed flow"
|
|
||||||
(flow-h
|
|
||||||
"(defflow w (sequence (lambda (x) (suspend (quote q))) (lambda (v) v))) (define id (car (cdr (flow/start w 0)))) (flow/resume id 7) (list (flow/forget id) (flow/status id))")
|
|
||||||
(list true "unknown"))
|
|
||||||
(flow-hyg-test
|
|
||||||
"forget: refuses to drop a live (suspended) flow"
|
|
||||||
(flow-h
|
|
||||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (list (flow/forget id) (flow/status id))")
|
|
||||||
(list false "suspended"))
|
|
||||||
(flow-hyg-test
|
|
||||||
"forget: drops a cancelled flow"
|
|
||||||
(flow-h
|
|
||||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/cancel id) (list (flow/forget id) (flow/status id))")
|
|
||||||
(list true "unknown"))
|
|
||||||
(flow-hyg-test
|
|
||||||
"forget: unknown id yields false"
|
|
||||||
(flow-h "(flow/forget 999)")
|
|
||||||
false)
|
|
||||||
|
|
||||||
(define flow-hyg-tests-run! (fn () {:total (+ flow-hyg-pass flow-hyg-fail) :passed flow-hyg-pass :failed flow-hyg-fail :fails flow-hyg-fails}))
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
;; lib/flow/tests/integration.sx — Phase 7: end-to-end flows composing every phase.
|
|
||||||
|
|
||||||
(define flow-int-pass 0)
|
|
||||||
(define flow-int-fail 0)
|
|
||||||
(define flow-int-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-int-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! flow-int-pass (+ flow-int-pass 1))
|
|
||||||
(begin
|
|
||||||
(set! flow-int-fail (+ flow-int-fail 1))
|
|
||||||
(append! flow-int-fails {:name name :expected expected :actual actual})))))
|
|
||||||
|
|
||||||
(define flow-i (fn (src) (flow-run src)))
|
|
||||||
|
|
||||||
;; The order-processing flow, defined once per program via this prelude string:
|
|
||||||
;; validate amount (attempt: fail if <= 0)
|
|
||||||
;; -> suspend for payment confirmation (resume value = confirmed amount)
|
|
||||||
;; -> branch: confirmed>0 ? record on the ledger peer : declined failure
|
|
||||||
(define
|
|
||||||
order-prelude
|
|
||||||
"(flow-peer-register! (quote ledger) (list (list (quote record) (lambda (amt) (list (quote recorded) amt)))))\n (defflow order\n (attempt\n (lambda (amt) (if (> amt 0) amt (fail (quote invalid-amount))))\n (lambda (amt) (suspend (quote await-payment)))\n (branch (lambda (amt) (> amt 0))\n (remote-node (quote ledger) (quote record))\n (flow-const (fail (quote declined))))))")
|
|
||||||
|
|
||||||
;; ── happy path through every phase ──────────────────────────────
|
|
||||||
(flow-int-test
|
|
||||||
"order: validate -> suspend -> resume -> branch -> federate"
|
|
||||||
(flow-i
|
|
||||||
(str
|
|
||||||
order-prelude
|
|
||||||
"(define id (car (cdr (flow/start order 100)))) (flow/resume id 250)"))
|
|
||||||
(list "recorded" 250))
|
|
||||||
(flow-int-test
|
|
||||||
"order: starting suspends awaiting payment"
|
|
||||||
(flow-i
|
|
||||||
(str
|
|
||||||
order-prelude
|
|
||||||
"(define s (flow/start order 100)) (list (car s) (car (cdr (cdr s))))"))
|
|
||||||
(list "flow-suspended" "await-payment"))
|
|
||||||
(flow-int-test
|
|
||||||
"order: invalid amount fails up front and never suspends"
|
|
||||||
(flow-i
|
|
||||||
(str
|
|
||||||
order-prelude
|
|
||||||
"(define r (flow/start order -5)) (list (failed? r) (fail-reason r))"))
|
|
||||||
(list true "invalid-amount"))
|
|
||||||
(flow-int-test
|
|
||||||
"order: a declined payment yields a failure value"
|
|
||||||
(flow-i
|
|
||||||
(str
|
|
||||||
order-prelude
|
|
||||||
"(define id (car (cdr (flow/start order 100)))) (failed? (flow/resume id 0))"))
|
|
||||||
true)
|
|
||||||
|
|
||||||
;; ── crash recovery mid-flow ─────────────────────────────────────
|
|
||||||
(flow-int-test
|
|
||||||
"order: survives a simulated crash between suspend and resume"
|
|
||||||
(flow-i
|
|
||||||
(str
|
|
||||||
order-prelude
|
|
||||||
"(define id (car (cdr (flow/start order 100)))) (define saved (flow-store-export)) (set! flow-store (list)) (flow-store-import! saved) (flow/resume id 250)"))
|
|
||||||
(list "recorded" 250))
|
|
||||||
|
|
||||||
;; ── handoff to a peer mid-flow ──────────────────────────────────
|
|
||||||
(flow-int-test
|
|
||||||
"order: hands off to a peer that resumes and completes"
|
|
||||||
(flow-i
|
|
||||||
(str
|
|
||||||
order-prelude
|
|
||||||
"(define id (car (cdr (flow/start order 100)))) (flow-replicate-to (quote nodeB)) (set! flow-store (list)) (flow-restore-from (quote nodeB)) (flow/resume id 250)"))
|
|
||||||
(list "recorded" 250))
|
|
||||||
|
|
||||||
;; ── introspection during the flow's life ────────────────────────
|
|
||||||
(flow-int-test
|
|
||||||
"order: pending shows what the flow awaits, then result after resume"
|
|
||||||
(flow-i
|
|
||||||
(str
|
|
||||||
order-prelude
|
|
||||||
"(define id (car (cdr (flow/start order 100)))) (define p (flow/pending)) (flow/resume id 250) (list p (flow/status id) (flow/result id))"))
|
|
||||||
(list
|
|
||||||
(list (list 1 "await-payment"))
|
|
||||||
"done"
|
|
||||||
(list "recorded" 250)))
|
|
||||||
|
|
||||||
;; ── onboarding: two human steps + cancellation ──────────────────
|
|
||||||
(define
|
|
||||||
onboard-prelude
|
|
||||||
"(defflow onboard\n (sequence\n (lambda (user) (+ user 1))\n (lambda (x) (suspend (quote confirm-email)))\n (lambda (x) (suspend (quote complete-profile)))\n (lambda (x) (list (quote onboarded) x))))")
|
|
||||||
|
|
||||||
(flow-int-test
|
|
||||||
"onboard: two suspends resume in order to completion"
|
|
||||||
(flow-i
|
|
||||||
(str
|
|
||||||
onboard-prelude
|
|
||||||
"(define id (car (cdr (flow/start onboard 0)))) (flow/resume id 7) (flow/resume id 9)"))
|
|
||||||
(list "onboarded" 9))
|
|
||||||
(flow-int-test
|
|
||||||
"onboard: the second pending tag appears after the first resume"
|
|
||||||
(flow-i
|
|
||||||
(str
|
|
||||||
onboard-prelude
|
|
||||||
"(define id (car (cdr (flow/start onboard 0)))) (flow/resume id 7) (car (cdr (car (flow/pending))))"))
|
|
||||||
"complete-profile")
|
|
||||||
(flow-int-test
|
|
||||||
"onboard: cancelling abandons the flow"
|
|
||||||
(flow-i
|
|
||||||
(str
|
|
||||||
onboard-prelude
|
|
||||||
"(define id (car (cdr (flow/start onboard 0)))) (flow/cancel id) (list (flow/status id) (car (flow/resume id 7)))"))
|
|
||||||
(list "cancelled" "flow-error"))
|
|
||||||
|
|
||||||
(define flow-int-tests-run! (fn () {:total (+ flow-int-pass flow-int-fail) :passed flow-int-pass :failed flow-int-fail :fails flow-int-fails}))
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
;; lib/flow/tests/railway.sx — Phase 6: railway-oriented composition (attempt).
|
|
||||||
|
|
||||||
(define flow-rail-pass 0)
|
|
||||||
(define flow-rail-fail 0)
|
|
||||||
(define flow-rail-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-rail-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! flow-rail-pass (+ flow-rail-pass 1))
|
|
||||||
(begin
|
|
||||||
(set! flow-rail-fail (+ flow-rail-fail 1))
|
|
||||||
(append! flow-rail-fails {:name name :expected expected :actual actual})))))
|
|
||||||
|
|
||||||
(define flow-r (fn (src) (flow-run src)))
|
|
||||||
|
|
||||||
;; ── attempt — short-circuit on the first (fail ...) ─────────────
|
|
||||||
(flow-rail-test
|
|
||||||
"attempt: threads like sequence when nothing fails"
|
|
||||||
(flow-r
|
|
||||||
"(flow/start (attempt (lambda (x) (+ x 1)) (lambda (x) (* x 10))) 4)")
|
|
||||||
50)
|
|
||||||
(flow-rail-test
|
|
||||||
"attempt: empty is identity"
|
|
||||||
(flow-r "(flow/start (attempt) 7)")
|
|
||||||
7)
|
|
||||||
(flow-rail-test
|
|
||||||
"attempt: returns the first failure"
|
|
||||||
(flow-r
|
|
||||||
"(failed? (flow/start (attempt (lambda (x) (fail (quote bad))) (lambda (x) (* x 10))) 4))")
|
|
||||||
true)
|
|
||||||
(flow-rail-test
|
|
||||||
"attempt: the failure carries its reason"
|
|
||||||
(flow-r
|
|
||||||
"(fail-reason (flow/start (attempt (lambda (x) x) (lambda (x) (fail (quote rejected)))) 4))")
|
|
||||||
"rejected")
|
|
||||||
(flow-rail-test
|
|
||||||
"attempt: nodes after a failure do not run"
|
|
||||||
(flow-r
|
|
||||||
"(define ran 0) (flow/start (attempt (lambda (x) (fail (quote stop))) (lambda (x) (begin (set! ran (+ ran 1)) x))) 0) ran")
|
|
||||||
0)
|
|
||||||
(flow-rail-test
|
|
||||||
"attempt: a failed input short-circuits immediately"
|
|
||||||
(flow-r
|
|
||||||
"(define ran 0) (fail-reason (flow/start (attempt (lambda (x) (begin (set! ran (+ ran 1)) x))) (fail (quote pre))))")
|
|
||||||
"pre")
|
|
||||||
(flow-rail-test
|
|
||||||
"attempt: middle failure halts the chain"
|
|
||||||
(flow-r
|
|
||||||
"(define ran 0) (flow/start (attempt (lambda (x) (+ x 1)) (lambda (x) (fail (quote mid))) (lambda (x) (begin (set! ran (+ ran 1)) x))) 5) ran")
|
|
||||||
0)
|
|
||||||
|
|
||||||
;; ── attempt + recover (rejoin the happy track) ──────────────────
|
|
||||||
(flow-rail-test
|
|
||||||
"attempt + recover: recover turns a failure into a value"
|
|
||||||
(flow-r
|
|
||||||
"(flow/start (recover (attempt (lambda (x) (if (> x 0) x (fail (quote non-positive)))) (lambda (x) (* x 2))) (flow-const 0)) -5)")
|
|
||||||
0)
|
|
||||||
(flow-rail-test
|
|
||||||
"attempt + recover: happy path passes recover through"
|
|
||||||
(flow-r
|
|
||||||
"(flow/start (recover (attempt (lambda (x) (if (> x 0) x (fail (quote non-positive)))) (lambda (x) (* x 2))) (flow-const 0)) 5)")
|
|
||||||
10)
|
|
||||||
(flow-rail-test
|
|
||||||
"attempt: validation pipeline reports the failing stage"
|
|
||||||
(flow-r
|
|
||||||
"(defflow validate (attempt (lambda (s) (if (>= (string-length s) 3) s (fail (quote too-short)))) (lambda (s) (if (<= (string-length s) 8) s (fail (quote too-long)))) (lambda (s) (list (quote ok) (string-length s))))) (list (fail-reason (flow/start validate \"hi\")) (flow/start validate \"hello\"))")
|
|
||||||
(list "too-short" (list "ok" 5)))
|
|
||||||
|
|
||||||
(define flow-rail-tests-run! (fn () {:total (+ flow-rail-pass flow-rail-fail) :passed flow-rail-pass :failed flow-rail-fail :fails flow-rail-fails}))
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
;; lib/flow/tests/recovery.sx — Phase 3: crash recovery (store export/import + restart).
|
|
||||||
;;
|
|
||||||
;; "restart" is simulated within one program: (set! flow-store (list)) wipes the
|
|
||||||
;; in-memory store (process death), while flow-registry persists as it would after
|
|
||||||
;; reloading flow definitions. Recovery = import the exported (plain-data) store and
|
|
||||||
;; resume; the flow proc is re-resolved by name.
|
|
||||||
|
|
||||||
(define flow-rec-pass 0)
|
|
||||||
(define flow-rec-fail 0)
|
|
||||||
(define flow-rec-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-rec-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! flow-rec-pass (+ flow-rec-pass 1))
|
|
||||||
(begin
|
|
||||||
(set! flow-rec-fail (+ flow-rec-fail 1))
|
|
||||||
(append! flow-rec-fails {:name name :expected expected :actual actual})))))
|
|
||||||
|
|
||||||
(define flow-r (fn (src) (flow-run src)))
|
|
||||||
|
|
||||||
;; ── export / wipe / import ──────────────────────────────────────
|
|
||||||
(flow-rec-test
|
|
||||||
"export nulls the live procedure"
|
|
||||||
(flow-r
|
|
||||||
"(defflow w (lambda (x) (suspend (quote await)))) (flow/start w 10) (car (cdr (car (cdr (car (flow-store-export))))))")
|
|
||||||
false)
|
|
||||||
(flow-rec-test
|
|
||||||
"a wiped store loses the flow (process death)"
|
|
||||||
(flow-r
|
|
||||||
"(defflow w (lambda (x) (suspend (quote await)))) (define id (car (cdr (flow/start w 10)))) (set! flow-store (list)) (flow/resume id 1)")
|
|
||||||
(list "flow-error" "no-such-flow"))
|
|
||||||
(flow-rec-test
|
|
||||||
"import restores a wiped store and resume completes"
|
|
||||||
(flow-r
|
|
||||||
"(defflow w (sequence (lambda (x) (suspend (quote await))) (lambda (c) (list (quote done) c)))) (define id (car (cdr (flow/start w 10)))) (define saved (flow-store-export)) (set! flow-store (list)) (flow-store-import! saved) (flow/resume id 777)")
|
|
||||||
(list "done" 777))
|
|
||||||
|
|
||||||
;; ── resumable scan ──────────────────────────────────────────────
|
|
||||||
(flow-rec-test
|
|
||||||
"resumable-ids lists the suspended flow after import"
|
|
||||||
(flow-r
|
|
||||||
"(defflow w (lambda (x) (suspend (quote await)))) (define id (car (cdr (flow/start w 10)))) (define saved (flow-store-export)) (set! flow-store (list)) (flow-store-import! saved) (flow-resumable-ids)")
|
|
||||||
(list 1))
|
|
||||||
(flow-rec-test
|
|
||||||
"resumable-ids excludes completed flows"
|
|
||||||
(flow-r
|
|
||||||
"(defflow w (sequence (lambda (x) (suspend (quote await))) (lambda (c) c))) (define id (car (cdr (flow/start w 10)))) (flow/resume id 5) (flow-resumable-ids)")
|
|
||||||
(list))
|
|
||||||
(flow-rec-test
|
|
||||||
"resumable-ids excludes cancelled flows after import"
|
|
||||||
(flow-r
|
|
||||||
"(defflow w (lambda (x) (suspend (quote await)))) (define id (car (cdr (flow/start w 10)))) (flow/cancel id) (define saved (flow-store-export)) (set! flow-store (list)) (flow-store-import! saved) (flow-resumable-ids)")
|
|
||||||
(list))
|
|
||||||
|
|
||||||
;; ── restart at every step ───────────────────────────────────────
|
|
||||||
(flow-rec-test
|
|
||||||
"two suspends survive a restart between each step"
|
|
||||||
(flow-r
|
|
||||||
"(defflow two (sequence (lambda (x) (suspend (quote a))) (lambda (x) (suspend (quote b))) (lambda (x) (list (quote end) x)))) (define id (car (cdr (flow/start two 0)))) (define s1 (flow-store-export)) (set! flow-store (list)) (flow-store-import! s1) (flow/resume id 100) (define s2 (flow-store-export)) (set! flow-store (list)) (flow-store-import! s2) (flow/resume id 200)")
|
|
||||||
(list "end" 200))
|
|
||||||
(flow-rec-test
|
|
||||||
"import preserves the replay log (earlier value survives restart)"
|
|
||||||
(flow-r
|
|
||||||
"(defflow two (sequence (lambda (x) (suspend (quote a))) (lambda (x) (suspend (quote b))) (lambda (x) (list x)))) (define id (car (cdr (flow/start two 0)))) (flow/resume id 11) (define saved (flow-store-export)) (set! flow-store (list)) (flow-store-import! saved) (flow/resume id 22)")
|
|
||||||
(list 22))
|
|
||||||
|
|
||||||
(define flow-rec-tests-run! (fn () {:total (+ flow-rec-pass flow-rec-fail) :passed flow-rec-pass :failed flow-rec-fail :fails flow-rec-fails}))
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
;; lib/flow/tests/suspend.sx — Phase 3: suspend / resume / cancel (deterministic replay).
|
|
||||||
|
|
||||||
(define flow-sus-pass 0)
|
|
||||||
(define flow-sus-fail 0)
|
|
||||||
(define flow-sus-fails (list))
|
|
||||||
|
|
||||||
(define
|
|
||||||
flow-sus-test
|
|
||||||
(fn
|
|
||||||
(name actual expected)
|
|
||||||
(if
|
|
||||||
(= actual expected)
|
|
||||||
(set! flow-sus-pass (+ flow-sus-pass 1))
|
|
||||||
(begin
|
|
||||||
(set! flow-sus-fail (+ flow-sus-fail 1))
|
|
||||||
(append! flow-sus-fails {:name name :expected expected :actual actual})))))
|
|
||||||
|
|
||||||
(define flow-s (fn (src) (flow-run src)))
|
|
||||||
|
|
||||||
;; ── flow/start ──────────────────────────────────────────────────
|
|
||||||
(flow-sus-test
|
|
||||||
"start: non-suspending flow returns the raw result"
|
|
||||||
(flow-s "(flow/start (lambda (x) (* x 2)) 5)")
|
|
||||||
10)
|
|
||||||
(flow-sus-test
|
|
||||||
"start: a suspending flow returns a flow-suspended state"
|
|
||||||
(flow-s
|
|
||||||
"(defflow w (sequence (lambda (x) (+ x 1)) (lambda (g) (suspend (quote await))) (lambda (c) c))) (car (flow/start w 10))")
|
|
||||||
"flow-suspended")
|
|
||||||
(flow-sus-test
|
|
||||||
"start: suspended state carries a numeric id"
|
|
||||||
(flow-s
|
|
||||||
"(defflow w (lambda (x) (suspend (quote await)))) (car (cdr (flow/start w 10)))")
|
|
||||||
1)
|
|
||||||
(flow-sus-test
|
|
||||||
"start: suspended state carries the suspend tag"
|
|
||||||
(flow-s
|
|
||||||
"(defflow w (lambda (x) (suspend (quote await)))) (car (cdr (cdr (flow/start w 10))))")
|
|
||||||
"await")
|
|
||||||
|
|
||||||
;; ── flow/resume ─────────────────────────────────────────────────
|
|
||||||
(flow-sus-test
|
|
||||||
"resume: injects the value and completes"
|
|
||||||
(flow-s
|
|
||||||
"(defflow w (sequence (lambda (x) (+ x 1)) (lambda (g) (suspend (quote await))) (lambda (c) (list (quote done) c)))) (define s (flow/start w 10)) (flow/resume (car (cdr s)) 777)")
|
|
||||||
(list "done" 777))
|
|
||||||
(flow-sus-test
|
|
||||||
"resume: injected value threads into the next node"
|
|
||||||
(flow-s
|
|
||||||
"(defflow w (sequence (lambda (x) (suspend (quote v))) (lambda (n) (* n 3)))) (define s (flow/start w 0)) (flow/resume (car (cdr s)) 14)")
|
|
||||||
42)
|
|
||||||
(flow-sus-test
|
|
||||||
"resume: replays earlier suspends (recompute is deterministic)"
|
|
||||||
(flow-s
|
|
||||||
"(define runs 0) (defflow w (sequence (lambda (x) (begin (set! runs (+ runs 1)) (+ x 1))) (lambda (g) (suspend (quote await))) (lambda (c) c))) (define s (flow/start w 10)) (flow/resume (car (cdr s)) 99) runs")
|
|
||||||
2)
|
|
||||||
|
|
||||||
;; ── multi-step suspension ───────────────────────────────────────
|
|
||||||
(flow-sus-test
|
|
||||||
"multi: first resume suspends at the next tag"
|
|
||||||
(flow-s
|
|
||||||
"(defflow two (sequence (lambda (x) (suspend (quote a))) (lambda (x) (suspend (quote b))) (lambda (x) (list (quote end) x)))) (define s (flow/start two 0)) (define s2 (flow/resume (car (cdr s)) 100)) (car (cdr (cdr s2)))")
|
|
||||||
"b")
|
|
||||||
(flow-sus-test
|
|
||||||
"multi: second resume completes with the latest value"
|
|
||||||
(flow-s
|
|
||||||
"(defflow two (sequence (lambda (x) (suspend (quote a))) (lambda (x) (suspend (quote b))) (lambda (x) (list (quote end) x)))) (define id (car (cdr (flow/start two 0)))) (flow/resume id 100) (flow/resume id 200)")
|
|
||||||
(list "end" 200))
|
|
||||||
|
|
||||||
;; ── error / lifecycle guards ────────────────────────────────────
|
|
||||||
(flow-sus-test
|
|
||||||
"resume: completed flow cannot be resumed again"
|
|
||||||
(flow-s
|
|
||||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/resume id 1) (flow/resume id 2)")
|
|
||||||
(list "flow-error" "not-suspended"))
|
|
||||||
(flow-sus-test
|
|
||||||
"resume: unknown id errors"
|
|
||||||
(flow-s "(flow/resume 999 1)")
|
|
||||||
(list "flow-error" "no-such-flow"))
|
|
||||||
|
|
||||||
;; ── flow/cancel ─────────────────────────────────────────────────
|
|
||||||
(flow-sus-test
|
|
||||||
"cancel: returns a flow-cancelled state"
|
|
||||||
(flow-s
|
|
||||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/cancel id)")
|
|
||||||
(list "flow-cancelled" 1))
|
|
||||||
(flow-sus-test
|
|
||||||
"cancel: a cancelled flow cannot be resumed (stale resume rejected)"
|
|
||||||
(flow-s
|
|
||||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/cancel id) (flow/resume id 5)")
|
|
||||||
(list "flow-error" "not-suspended"))
|
|
||||||
(flow-sus-test
|
|
||||||
"cancel: unknown id errors"
|
|
||||||
(flow-s "(flow/cancel 999)")
|
|
||||||
(list "flow-error" "no-such-flow"))
|
|
||||||
|
|
||||||
;; ── composition ─────────────────────────────────────────────────
|
|
||||||
(flow-sus-test
|
|
||||||
"suspend inside a branch arm"
|
|
||||||
(flow-s
|
|
||||||
"(defflow gate (branch (lambda (x) (> x 0)) (lambda (x) (suspend (quote approve))) (flow-const (quote rejected)))) (define s (flow/start gate 5)) (flow/resume (car (cdr s)) (quote approved))")
|
|
||||||
"approved")
|
|
||||||
(flow-sus-test
|
|
||||||
"two independent runs get independent ids"
|
|
||||||
(flow-s
|
|
||||||
"(defflow w (lambda (x) (suspend (quote q)))) (list (car (cdr (flow/start w 0))) (car (cdr (flow/start w 0))))")
|
|
||||||
(list 1 2))
|
|
||||||
(flow-sus-test
|
|
||||||
"suspend reason may be a structured value"
|
|
||||||
(flow-s
|
|
||||||
"(defflow w (lambda (x) (suspend (list (quote needs) (quote approval))))) (car (cdr (cdr (flow/start w 0))))")
|
|
||||||
(list "needs" "approval"))
|
|
||||||
|
|
||||||
(define flow-sus-tests-run! (fn () {:total (+ flow-sus-pass flow-sus-fail) :passed flow-sus-pass :failed flow-sus-fail :fails flow-sus-fails}))
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -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)))
|
|
||||||
@@ -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)))
|
|
||||||
@@ -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)))
|
|
||||||
@@ -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
|
|
||||||
@@ -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)))
|
|
||||||
@@ -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)))
|
|
||||||
@@ -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)))
|
|
||||||
@@ -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
@@ -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)))
|
|
||||||
@@ -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"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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`.
|
|
||||||
@@ -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)))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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
@@ -1,40 +0,0 @@
|
|||||||
;; lib/mod/activity.sx — export decisions as ActivityPub-shaped events.
|
|
||||||
;;
|
|
||||||
;; The rose-ash platform propagates cross-domain effects as ActivityPub-shaped
|
|
||||||
;; activities. A moderation decision maps to a moderation verb so the rest of the
|
|
||||||
;; platform (and federated peers) can act on it: remove→Delete, ban→Block,
|
|
||||||
;; hide/escalate→Flag, keep→no activity. The precise mod action is preserved in
|
|
||||||
;; :action so a consumer can disambiguate (e.g. hide vs escalate, both Flag).
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/action->verb
|
|
||||||
(fn
|
|
||||||
(action)
|
|
||||||
(cond
|
|
||||||
((= action "remove") "Delete")
|
|
||||||
((= action "ban") "Block")
|
|
||||||
((= action "hide") "Flag")
|
|
||||||
((= action "escalate") "Flag")
|
|
||||||
(true nil))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/decision->activity
|
|
||||||
(fn
|
|
||||||
(d actor)
|
|
||||||
(let
|
|
||||||
((verb (mod/action->verb (get d :action))))
|
|
||||||
(if (nil? verb) nil {:type verb :action (get d :action) :actor actor :summary (str "moderation/" (get d :action) " via " (get d :rule)) :object (get d :report-id) :rule (get d :rule)}))))
|
|
||||||
|
|
||||||
;; map a batch of decisions to activities, dropping the no-op keeps
|
|
||||||
(define
|
|
||||||
mod/decisions->activities
|
|
||||||
(fn
|
|
||||||
(decisions actor)
|
|
||||||
(reduce
|
|
||||||
(fn
|
|
||||||
(acc d)
|
|
||||||
(let
|
|
||||||
((a (mod/decision->activity d actor)))
|
|
||||||
(if (nil? a) acc (append acc (list a)))))
|
|
||||||
(list)
|
|
||||||
decisions)))
|
|
||||||
163
lib/mod/api.sx
163
lib/mod/api.sx
@@ -1,163 +0,0 @@
|
|||||||
;; lib/mod/api.sx — report registry + lifecycle façade + public entry points.
|
|
||||||
;;
|
|
||||||
;; mod/report files a report (assigning a sequential id) and opens a lifecycle
|
|
||||||
;; case for it; mod/add-evidence accumulates evidence; mod/decide runs the engine
|
|
||||||
;; and commits to the audit log. The lifecycle façade (mod/triage, mod/resolve,
|
|
||||||
;; mod/review, mod/appeal, mod/finalize) drives the per-report case through its
|
|
||||||
;; states, logging each committed decision to the audit trail.
|
|
||||||
|
|
||||||
(define mod/*reports* (list))
|
|
||||||
(define mod/*cases* (list))
|
|
||||||
(define mod/*counter* 0)
|
|
||||||
(define mod/*rules* mod/default-rules)
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/reset!
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(begin
|
|
||||||
(set! mod/*reports* (list))
|
|
||||||
(set! mod/*cases* (list))
|
|
||||||
(set! mod/*counter* 0)
|
|
||||||
(mod/audit-reset!))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/report
|
|
||||||
(fn
|
|
||||||
(by about reason)
|
|
||||||
(begin
|
|
||||||
(set! mod/*counter* (+ mod/*counter* 1))
|
|
||||||
(let
|
|
||||||
((id (str "r" mod/*counter*)))
|
|
||||||
(let
|
|
||||||
((r (mod/mk-report id by about reason)))
|
|
||||||
(begin
|
|
||||||
(append! mod/*reports* r)
|
|
||||||
(append! mod/*cases* {:id id :case (mod/mk-case r)})
|
|
||||||
r))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/get-report
|
|
||||||
(fn
|
|
||||||
(id)
|
|
||||||
(reduce
|
|
||||||
(fn (acc r) (if (= (mod/report-id r) id) r acc))
|
|
||||||
nil
|
|
||||||
mod/*reports*)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/add-evidence
|
|
||||||
(fn
|
|
||||||
(id kind val)
|
|
||||||
(let
|
|
||||||
((r (mod/get-report id)))
|
|
||||||
(if
|
|
||||||
(nil? r)
|
|
||||||
nil
|
|
||||||
(let
|
|
||||||
((updated (mod/attach-evidence r (mod/mk-evidence kind val))))
|
|
||||||
(begin
|
|
||||||
(set!
|
|
||||||
mod/*reports*
|
|
||||||
(map
|
|
||||||
(fn (x) (if (= (mod/report-id x) id) updated x))
|
|
||||||
mod/*reports*))
|
|
||||||
updated))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/decide
|
|
||||||
(fn
|
|
||||||
(id)
|
|
||||||
(let
|
|
||||||
((r (mod/get-report id)))
|
|
||||||
(if
|
|
||||||
(nil? r)
|
|
||||||
nil
|
|
||||||
(let
|
|
||||||
((d (mod/decide-report r mod/*reports* mod/*rules*)))
|
|
||||||
(begin (mod/log-decision! d (mod/report-evidence r)) d))))))
|
|
||||||
|
|
||||||
;; ── lifecycle façade over the case registry ──
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/case-of
|
|
||||||
(fn
|
|
||||||
(id)
|
|
||||||
(reduce
|
|
||||||
(fn (acc rec) (if (= (get rec :id) id) (get rec :case) acc))
|
|
||||||
nil
|
|
||||||
mod/*cases*)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/case-store!
|
|
||||||
(fn
|
|
||||||
(id c)
|
|
||||||
(set!
|
|
||||||
mod/*cases*
|
|
||||||
(map
|
|
||||||
(fn (rec) (if (= (get rec :id) id) {:id id :case c} rec))
|
|
||||||
mod/*cases*))))
|
|
||||||
|
|
||||||
;; apply a lifecycle op to the stored case, persist it, and (when a decision was
|
|
||||||
;; committed cleanly) append it to the audit log; returns the updated case
|
|
||||||
(define
|
|
||||||
mod/case-apply!
|
|
||||||
(fn
|
|
||||||
(id op log?)
|
|
||||||
(let
|
|
||||||
((c (mod/case-of id)))
|
|
||||||
(if
|
|
||||||
(nil? c)
|
|
||||||
nil
|
|
||||||
(let
|
|
||||||
((c2 (op c)))
|
|
||||||
(begin
|
|
||||||
(mod/case-store! id c2)
|
|
||||||
(when
|
|
||||||
log?
|
|
||||||
(when
|
|
||||||
(nil? (mod/case-error c2))
|
|
||||||
(let
|
|
||||||
((d (mod/case-decision c2)))
|
|
||||||
(if
|
|
||||||
(nil? d)
|
|
||||||
nil
|
|
||||||
(mod/log-decision!
|
|
||||||
d
|
|
||||||
(mod/report-evidence (mod/case-report c2)))))))
|
|
||||||
c2))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/triage
|
|
||||||
(fn
|
|
||||||
(id)
|
|
||||||
(mod/case-apply!
|
|
||||||
id
|
|
||||||
(fn (c) (mod/case-triage c mod/*reports* mod/*rules*))
|
|
||||||
false)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/resolve
|
|
||||||
(fn (id) (mod/case-apply! id (fn (c) (mod/case-resolve c)) true)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/review
|
|
||||||
(fn
|
|
||||||
(id kind val)
|
|
||||||
(mod/case-apply!
|
|
||||||
id
|
|
||||||
(fn (c) (mod/case-review c kind val mod/*reports* mod/*rules*))
|
|
||||||
true)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/appeal
|
|
||||||
(fn
|
|
||||||
(id kind val)
|
|
||||||
(mod/case-apply!
|
|
||||||
id
|
|
||||||
(fn (c) (mod/case-appeal c kind val mod/*reports* mod/*rules*))
|
|
||||||
true)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/finalize
|
|
||||||
(fn (id) (mod/case-apply! id (fn (c) (mod/case-finalize c)) false)))
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
;; lib/mod/audit.sx — append-only decision log.
|
|
||||||
;;
|
|
||||||
;; Every decision the api commits is recorded as an immutable audit entry holding
|
|
||||||
;; the decision (action + matching rule), the proof tree (the derivation that
|
|
||||||
;; justified it), and a snapshot of the evidence in force at decision time. The
|
|
||||||
;; log is append-only: entries are never mutated or removed, only appended, each
|
|
||||||
;; with a monotonic sequence number. Retrieval is by report id (full history) or
|
|
||||||
;; by sequence.
|
|
||||||
|
|
||||||
(define mod/*audit-log* (list))
|
|
||||||
(define mod/*audit-seq* 0)
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/audit-reset!
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(begin (set! mod/*audit-log* (list)) (set! mod/*audit-seq* 0))))
|
|
||||||
|
|
||||||
(define mod/mk-audit-entry (fn (seq decision evidence-snapshot) {:action (get decision :action) :evidence evidence-snapshot :proof (get decision :proof) :rule (get decision :rule) :report-id (get decision :report-id) :seq seq}))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/log-decision!
|
|
||||||
(fn
|
|
||||||
(decision evidence-snapshot)
|
|
||||||
(begin
|
|
||||||
(set! mod/*audit-seq* (+ mod/*audit-seq* 1))
|
|
||||||
(let
|
|
||||||
((entry (mod/mk-audit-entry mod/*audit-seq* decision evidence-snapshot)))
|
|
||||||
(begin (append! mod/*audit-log* entry) entry)))))
|
|
||||||
|
|
||||||
;; entries for one report, in chronological (sequence) order
|
|
||||||
(define
|
|
||||||
mod/audit
|
|
||||||
(fn
|
|
||||||
(id)
|
|
||||||
(reduce
|
|
||||||
(fn
|
|
||||||
(acc e)
|
|
||||||
(if (= (get e :report-id) id) (append acc (list e)) acc))
|
|
||||||
(list)
|
|
||||||
mod/*audit-log*)))
|
|
||||||
|
|
||||||
(define mod/audit-all (fn () mod/*audit-log*))
|
|
||||||
(define mod/audit-count (fn () (len mod/*audit-log*)))
|
|
||||||
|
|
||||||
;; most recent decision logged for a report (nil if none)
|
|
||||||
(define
|
|
||||||
mod/audit-latest
|
|
||||||
(fn
|
|
||||||
(id)
|
|
||||||
(reduce
|
|
||||||
(fn (acc e) (if (= (get e :report-id) id) e acc))
|
|
||||||
nil
|
|
||||||
mod/*audit-log*)))
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
;; lib/mod/batch.sx — batch triage + corpus analytics.
|
|
||||||
;;
|
|
||||||
;; Operational layer: decide a whole queue of reports at once, summarize the
|
|
||||||
;; outcomes by action, and measure which rules actually fire across a corpus.
|
|
||||||
;; mod/never-fired is the empirical complement to lint's static unreachable check
|
|
||||||
;; (Ext 5): lint finds rules that CAN'T fire by structure; never-fired finds rules
|
|
||||||
;; that DIDN'T fire on real data.
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/decide-batch
|
|
||||||
(fn
|
|
||||||
(reports rules)
|
|
||||||
(map (fn (r) (mod/decide-report r reports rules)) reports)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/count-action
|
|
||||||
(fn
|
|
||||||
(decisions action)
|
|
||||||
(reduce
|
|
||||||
(fn (acc d) (if (= (get d :action) action) (+ acc 1) acc))
|
|
||||||
0
|
|
||||||
decisions)))
|
|
||||||
|
|
||||||
(define mod/action-histogram (fn (decisions) {:keep (mod/count-action decisions "keep") :remove (mod/count-action decisions "remove") :escalate (mod/count-action decisions "escalate") :hide (mod/count-action decisions "hide") :ban (mod/count-action decisions "ban")}))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/rule-fire-count
|
|
||||||
(fn
|
|
||||||
(decisions rule-name)
|
|
||||||
(reduce
|
|
||||||
(fn (acc d) (if (= (get d :rule) rule-name) (+ acc 1) acc))
|
|
||||||
0
|
|
||||||
decisions)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/rule-coverage
|
|
||||||
(fn
|
|
||||||
(reports rules)
|
|
||||||
(let
|
|
||||||
((decisions (mod/decide-batch reports rules)))
|
|
||||||
(map (fn (rule) {:rule (mod/rule-name rule) :fired (mod/rule-fire-count decisions (mod/rule-name rule))}) rules))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/never-fired
|
|
||||||
(fn
|
|
||||||
(reports rules)
|
|
||||||
(reduce
|
|
||||||
(fn
|
|
||||||
(acc c)
|
|
||||||
(if
|
|
||||||
(= (get c :fired) 0)
|
|
||||||
(append acc (list (get c :rule)))
|
|
||||||
acc))
|
|
||||||
(list)
|
|
||||||
(mod/rule-coverage reports rules))))
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# Mod conformance config — sourced by lib/guest/conformance.sh.
|
|
||||||
|
|
||||||
LANG_NAME=mod
|
|
||||||
MODE=dict
|
|
||||||
|
|
||||||
PRELOADS=(
|
|
||||||
lib/guest/pratt.sx
|
|
||||||
lib/prolog/tokenizer.sx
|
|
||||||
lib/prolog/parser.sx
|
|
||||||
lib/prolog/runtime.sx
|
|
||||||
lib/prolog/query.sx
|
|
||||||
lib/prolog/compiler.sx
|
|
||||||
lib/mod/schema.sx
|
|
||||||
lib/mod/policy.sx
|
|
||||||
lib/mod/defrule.sx
|
|
||||||
lib/mod/engine.sx
|
|
||||||
lib/mod/explain.sx
|
|
||||||
lib/mod/severity.sx
|
|
||||||
lib/mod/offenders.sx
|
|
||||||
lib/mod/quorum.sx
|
|
||||||
lib/mod/trace.sx
|
|
||||||
lib/mod/whatif.sx
|
|
||||||
lib/mod/batch.sx
|
|
||||||
lib/mod/temporal.sx
|
|
||||||
lib/mod/sla.sx
|
|
||||||
lib/mod/wire.sx
|
|
||||||
lib/mod/activity.sx
|
|
||||||
lib/mod/policies.sx
|
|
||||||
lib/mod/pipeline.sx
|
|
||||||
lib/mod/lifecycle.sx
|
|
||||||
lib/mod/audit.sx
|
|
||||||
lib/mod/api.sx
|
|
||||||
lib/mod/fed.sx
|
|
||||||
lib/mod/link.sx
|
|
||||||
lib/mod/lint.sx
|
|
||||||
)
|
|
||||||
|
|
||||||
SUITES=(
|
|
||||||
"decide:lib/mod/tests/decide.sx:(mod-decide-tests-run!)"
|
|
||||||
"audit:lib/mod/tests/audit.sx:(mod-audit-tests-run!)"
|
|
||||||
"escalation:lib/mod/tests/escalation.sx:(mod-escalation-tests-run!)"
|
|
||||||
"fed:lib/mod/tests/fed.sx:(mod-fed-tests-run!)"
|
|
||||||
"extensions:lib/mod/tests/extensions.sx:(mod-extensions-tests-run!)"
|
|
||||||
"link:lib/mod/tests/link.sx:(mod-link-tests-run!)"
|
|
||||||
"lint:lib/mod/tests/lint.sx:(mod-lint-tests-run!)"
|
|
||||||
"severity:lib/mod/tests/severity.sx:(mod-severity-tests-run!)"
|
|
||||||
"offenders:lib/mod/tests/offenders.sx:(mod-offenders-tests-run!)"
|
|
||||||
"quorum:lib/mod/tests/quorum.sx:(mod-quorum-tests-run!)"
|
|
||||||
"trace:lib/mod/tests/trace.sx:(mod-trace-tests-run!)"
|
|
||||||
"whatif:lib/mod/tests/whatif.sx:(mod-whatif-tests-run!)"
|
|
||||||
"batch:lib/mod/tests/batch.sx:(mod-batch-tests-run!)"
|
|
||||||
"temporal:lib/mod/tests/temporal.sx:(mod-temporal-tests-run!)"
|
|
||||||
"sla:lib/mod/tests/sla.sx:(mod-sla-tests-run!)"
|
|
||||||
"wire:lib/mod/tests/wire.sx:(mod-wire-tests-run!)"
|
|
||||||
"disjunction:lib/mod/tests/disjunction.sx:(mod-disjunction-tests-run!)"
|
|
||||||
"activity:lib/mod/tests/activity.sx:(mod-activity-tests-run!)"
|
|
||||||
"policies:lib/mod/tests/policies.sx:(mod-policies-tests-run!)"
|
|
||||||
"defrule:lib/mod/tests/defrule.sx:(mod-defrule-tests-run!)"
|
|
||||||
"pipeline:lib/mod/tests/pipeline.sx:(mod-pipeline-tests-run!)"
|
|
||||||
)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
;; lib/mod/defrule.sx — ergonomic rule / ruleset construction.
|
|
||||||
;;
|
|
||||||
;; The roadmap sketched a (defrule action :when conditions) surface. Conditions
|
|
||||||
;; already evaluate to plain data, so this needs no macro — variadic functions
|
|
||||||
;; suffice: mod/defrule collects its trailing condition forms via &rest (dropping
|
|
||||||
;; the explicit outer (list ...)), and mod/ruleset assembles rules the same way.
|
|
||||||
;;
|
|
||||||
;; (mod/ruleset
|
|
||||||
;; (mod/defrule "spam-hide" :hide (list :classification "spam"))
|
|
||||||
;; (mod/defrule "default-keep" :keep))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/defrule
|
|
||||||
(fn (name action &rest conds) (mod/mk-rule name action conds)))
|
|
||||||
|
|
||||||
(define mod/ruleset (fn (&rest rules) rules))
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
;; lib/mod/engine.sx — decide a report by querying the policy program.
|
|
||||||
;;
|
|
||||||
;; build-program assembles the report's facts plus the compiled policy clauses;
|
|
||||||
;; decide-report runs the Prolog query and returns a decision. A decision is a
|
|
||||||
;; proof, not a bare keyword: it carries the matching rule, the conditions it
|
|
||||||
;; required, the evidence that satisfied them, and a derivation — the proof tree.
|
|
||||||
;;
|
|
||||||
;; The proof tree is built constructively: for the matching rule, each body goal
|
|
||||||
;; is re-queried against the same DB with the report id bound, recording the goal
|
|
||||||
;; text, whether it was solved, and the bindings that satisfied it. That is a
|
|
||||||
;; genuine derivation drawn from the Prolog database, ready for the audit trail.
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/find-rule
|
|
||||||
(fn
|
|
||||||
(rules name)
|
|
||||||
(reduce
|
|
||||||
(fn
|
|
||||||
(acc r)
|
|
||||||
(if (nil? acc) (if (= (mod/rule-name r) name) r acc) acc))
|
|
||||||
nil
|
|
||||||
rules)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/build-program
|
|
||||||
(fn
|
|
||||||
(r count rules)
|
|
||||||
(str (mod/report-facts r count) "\n" (mod/rules->program rules))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/proof-goals
|
|
||||||
(fn
|
|
||||||
(db id conds)
|
|
||||||
(if
|
|
||||||
(empty? conds)
|
|
||||||
(list {:solved true :goal "true" :bindings {}})
|
|
||||||
(map
|
|
||||||
(fn
|
|
||||||
(c)
|
|
||||||
(let
|
|
||||||
((g (mod/cond->goal c id)))
|
|
||||||
(let ((sols (pl-query-all db g))) {:solved (if (empty? sols) false true) :goal g :bindings (if (empty? sols) {} (first sols))})))
|
|
||||||
conds))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/decide-report
|
|
||||||
(fn
|
|
||||||
(r reports rules)
|
|
||||||
(let
|
|
||||||
((count (mod/report-count (mod/report-about r) reports))
|
|
||||||
(kinds (mod/classify-keywords r))
|
|
||||||
(id (mod/report-id r)))
|
|
||||||
(let
|
|
||||||
((program (mod/build-program r count rules)))
|
|
||||||
(let
|
|
||||||
((db (pl-load program)))
|
|
||||||
(let
|
|
||||||
((sol (pl-query-one db (str "policy_action(" id ", Action, Rule)"))))
|
|
||||||
(if
|
|
||||||
(nil? sol)
|
|
||||||
{:action "keep" :proof {:goals (list) :evidence kinds :conditions (list) :rule "none" :count count} :report-id id :rule "none"}
|
|
||||||
(let
|
|
||||||
((rname (dict-get sol "Rule")))
|
|
||||||
(let ((rule (mod/find-rule rules rname))) {:action (mod/rule-action rule) :proof {:goals (mod/proof-goals db id (mod/rule-when rule)) :evidence kinds :conditions (mod/rule-when rule) :rule rname :count count} :report-id id :rule rname})))))))))
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
;; lib/mod/explain.sx — human-readable proof explanation.
|
|
||||||
;;
|
|
||||||
;; Turns a decision (from mod/decide-report, or any audit entry) into a readable
|
|
||||||
;; multi-line "why": the action, the rule that fired, the evidence in play, and
|
|
||||||
;; the derivation goal-by-goal with [proved]/[unproved] marks and the unification
|
|
||||||
;; bindings that satisfied each goal. Pure SX over the Phase-2 proof tree.
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/explain-binds
|
|
||||||
(fn
|
|
||||||
(binds)
|
|
||||||
(mod/join-with
|
|
||||||
", "
|
|
||||||
(map (fn (k) (str k "=" (dict-get binds k))) (keys binds)))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/explain-goal
|
|
||||||
(fn
|
|
||||||
(g)
|
|
||||||
(let
|
|
||||||
((mark (if (get g :solved) " [proved] " " [unproved] "))
|
|
||||||
(binds (get g :bindings)))
|
|
||||||
(if
|
|
||||||
(empty? (keys binds))
|
|
||||||
(str mark (get g :goal))
|
|
||||||
(str mark (get g :goal) " {" (mod/explain-binds binds) "}")))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/explain-evidence
|
|
||||||
(fn
|
|
||||||
(evidence)
|
|
||||||
(if
|
|
||||||
(empty? evidence)
|
|
||||||
"Evidence: (none)"
|
|
||||||
(str "Evidence: " (mod/join-with ", " evidence)))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/explain
|
|
||||||
(fn
|
|
||||||
(decision)
|
|
||||||
(let
|
|
||||||
((id (get decision :report-id))
|
|
||||||
(action (get decision :action))
|
|
||||||
(rule (get decision :rule))
|
|
||||||
(proof (get decision :proof)))
|
|
||||||
(let
|
|
||||||
((goals (get proof :goals)) (evidence (get proof :evidence)))
|
|
||||||
(mod/join-with
|
|
||||||
"\n"
|
|
||||||
(append
|
|
||||||
(list
|
|
||||||
(str "Report " id ": " action " (rule: " rule ")")
|
|
||||||
(mod/explain-evidence evidence)
|
|
||||||
"Because:")
|
|
||||||
(map mod/explain-goal goals)))))))
|
|
||||||
145
lib/mod/fed.sx
145
lib/mod/fed.sx
@@ -1,145 +0,0 @@
|
|||||||
;; lib/mod/fed.sx — federation: cross-instance reports, decision sharing, trust,
|
|
||||||
;; revocation. fed-sx itself is mocked here (an in-memory outbox); the real wire
|
|
||||||
;; transport would replace mod/fed-send!.
|
|
||||||
;;
|
|
||||||
;; Trust is advisory by default (the hard rule): a peer's decision only binds
|
|
||||||
;; locally when (mod/trusted? peer :mod) holds. An untrusted peer's decision is
|
|
||||||
;; recorded as a suggestion in the advisory log and is NOT applied. Local
|
|
||||||
;; decisions propagate outward via the outbox. Revocation undoes a locally
|
|
||||||
;; applied action when its proof is invalidated, notifying the origin peer.
|
|
||||||
|
|
||||||
(define mod/*fed-trust* (list)) ;; {:peer :scope}
|
|
||||||
(define mod/*fed-outbox* (list)) ;; {:to :type :payload}
|
|
||||||
(define mod/*fed-advisory* (list)) ;; {:peer :decision} — received, not applied
|
|
||||||
(define mod/*fed-applied* (list)) ;; {:report-id :action :origin :revoked}
|
|
||||||
(define mod/*fed-origins* (list)) ;; {:id :origin}
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/fed-reset!
|
|
||||||
(fn
|
|
||||||
()
|
|
||||||
(begin
|
|
||||||
(set! mod/*fed-trust* (list))
|
|
||||||
(set! mod/*fed-outbox* (list))
|
|
||||||
(set! mod/*fed-advisory* (list))
|
|
||||||
(set! mod/*fed-applied* (list))
|
|
||||||
(set! mod/*fed-origins* (list)))))
|
|
||||||
|
|
||||||
;; ── trust model ──
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/trust-match?
|
|
||||||
(fn
|
|
||||||
(t peer scope)
|
|
||||||
(if (= (get t :peer) peer) (= (get t :scope) scope) false)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/grant-trust
|
|
||||||
(fn (peer scope) (begin (append! mod/*fed-trust* {:scope scope :peer peer}) true)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/revoke-trust
|
|
||||||
(fn
|
|
||||||
(peer scope)
|
|
||||||
(set!
|
|
||||||
mod/*fed-trust*
|
|
||||||
(reduce
|
|
||||||
(fn
|
|
||||||
(acc t)
|
|
||||||
(if (mod/trust-match? t peer scope) acc (append acc (list t))))
|
|
||||||
(list)
|
|
||||||
mod/*fed-trust*))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/trusted?
|
|
||||||
(fn
|
|
||||||
(peer scope)
|
|
||||||
(mod/any? (fn (t) (mod/trust-match? t peer scope)) mod/*fed-trust*)))
|
|
||||||
|
|
||||||
;; ── cross-instance reports ──
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/fed-receive-report
|
|
||||||
(fn
|
|
||||||
(peer by about reason)
|
|
||||||
(let
|
|
||||||
((r (mod/report by about reason)))
|
|
||||||
(begin (append! mod/*fed-origins* {:id (mod/report-id r) :origin peer}) r))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/report-origin
|
|
||||||
(fn
|
|
||||||
(id)
|
|
||||||
(reduce
|
|
||||||
(fn (acc o) (if (= (get o :id) id) (get o :origin) acc))
|
|
||||||
"local"
|
|
||||||
mod/*fed-origins*)))
|
|
||||||
|
|
||||||
;; ── decision sharing (mock fed-sx send) ──
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/fed-send!
|
|
||||||
(fn (to type payload) (begin (append! mod/*fed-outbox* {:type type :to to :payload payload}) true)))
|
|
||||||
|
|
||||||
(define mod/fed-outbox (fn () mod/*fed-outbox*))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/fed-share-decision
|
|
||||||
(fn
|
|
||||||
(decision peers)
|
|
||||||
(reduce
|
|
||||||
(fn
|
|
||||||
(acc p)
|
|
||||||
(begin (mod/fed-send! p "decision" decision) (append acc (list p))))
|
|
||||||
(list)
|
|
||||||
peers)))
|
|
||||||
|
|
||||||
;; ── receiving a peer's decision (advisory unless trusted) ──
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/fed-applied-action
|
|
||||||
(fn
|
|
||||||
(report-id)
|
|
||||||
(reduce
|
|
||||||
(fn (acc a) (if (= (get a :report-id) report-id) a acc))
|
|
||||||
nil
|
|
||||||
mod/*fed-applied*)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/fed-receive-decision
|
|
||||||
(fn
|
|
||||||
(peer decision)
|
|
||||||
(if
|
|
||||||
(mod/trusted? peer :mod)
|
|
||||||
(begin (append! mod/*fed-applied* {:revoked false :action (get decision :action) :report-id (get decision :report-id) :origin peer}) {:advisory false :peer peer :applied true :decision decision})
|
|
||||||
(begin (append! mod/*fed-advisory* {:peer peer :decision decision}) {:advisory true :peer peer :applied false :decision decision}))))
|
|
||||||
|
|
||||||
;; ── revocation ──
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/fed-revoke!
|
|
||||||
(fn
|
|
||||||
(report-id reason)
|
|
||||||
(begin
|
|
||||||
(set!
|
|
||||||
mod/*fed-applied*
|
|
||||||
(map
|
|
||||||
(fn (a) (if (= (get a :report-id) report-id) {:revoked true :action (get a :action) :report-id (get a :report-id) :origin (get a :origin)} a))
|
|
||||||
mod/*fed-applied*))
|
|
||||||
(mod/fed-send! (mod/report-origin report-id) "revocation" {:report-id report-id :reason reason})
|
|
||||||
report-id)))
|
|
||||||
|
|
||||||
;; re-run the engine; if the action no longer holds, the prior decision's proof
|
|
||||||
;; is invalidated — revoke the applied moderation.
|
|
||||||
(define
|
|
||||||
mod/fed-revoke-if-invalidated
|
|
||||||
(fn
|
|
||||||
(report decision reports rules)
|
|
||||||
(let
|
|
||||||
((d2 (mod/decide-report report reports rules)))
|
|
||||||
(if
|
|
||||||
(= (get d2 :action) (get decision :action))
|
|
||||||
{:revoked false :decision d2}
|
|
||||||
(begin
|
|
||||||
(mod/fed-revoke! (get decision :report-id) "proof invalidated")
|
|
||||||
{:revoked true :decision d2})))))
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
;; lib/mod/lifecycle.sx — report lifecycle state machine (pure SX over the engine).
|
|
||||||
;;
|
|
||||||
;; Lifecycle state is deliberately separate from policy: the Prolog rules answer
|
|
||||||
;; "what action?", this module answers "where in the process is this report?".
|
|
||||||
;;
|
|
||||||
;; :open ──triage──▶ :triaged ──resolve/review──▶ :decided ──appeal──▶ :appealed
|
|
||||||
;; │ │
|
|
||||||
;; └────finalize───▶ :final ◀┘
|
|
||||||
;;
|
|
||||||
;; A case is an immutable value {:report :state :decision :tier :error :history}.
|
|
||||||
;; Every transition returns a NEW case; illegal transitions return the case
|
|
||||||
;; unchanged with :error set. Tiers: triage runs the engine (auto-tier); a
|
|
||||||
;; terminal action (hide/remove/keep) resolves immediately, an :escalate action
|
|
||||||
;; flags the case for human review (human-tier) before it can be resolved.
|
|
||||||
|
|
||||||
(define mod/case* (fn (report state decision tier err history) {:history history :state state :report report :error err :tier tier :decision decision}))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/mk-case
|
|
||||||
(fn (report) (mod/case* report "open" nil nil nil (list))))
|
|
||||||
|
|
||||||
(define mod/case-report (fn (c) (get c :report)))
|
|
||||||
(define mod/case-state (fn (c) (get c :state)))
|
|
||||||
(define mod/case-decision (fn (c) (get c :decision)))
|
|
||||||
(define mod/case-tier (fn (c) (get c :tier)))
|
|
||||||
(define mod/case-error (fn (c) (get c :error)))
|
|
||||||
(define mod/case-history (fn (c) (get c :history)))
|
|
||||||
|
|
||||||
;; ── transition table ──
|
|
||||||
|
|
||||||
(define mod/lc-transitions {:final (list) :appealed (list "final") :decided (list "appealed" "final") :open (list "triaged") :triaged (list "decided")})
|
|
||||||
|
|
||||||
(define mod/member? (fn (x lst) (mod/any? (fn (y) (= y x)) lst)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/lc-can-transition?
|
|
||||||
(fn
|
|
||||||
(from to)
|
|
||||||
(let
|
|
||||||
((outs (get mod/lc-transitions from)))
|
|
||||||
(if (nil? outs) false (mod/member? to outs)))))
|
|
||||||
|
|
||||||
;; ── core transition: validate, record history, or flag :error ──
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/case-goto
|
|
||||||
(fn
|
|
||||||
(c to note report decision tier)
|
|
||||||
(let
|
|
||||||
((from (mod/case-state c)))
|
|
||||||
(if
|
|
||||||
(mod/lc-can-transition? from to)
|
|
||||||
(mod/case*
|
|
||||||
report
|
|
||||||
to
|
|
||||||
decision
|
|
||||||
tier
|
|
||||||
nil
|
|
||||||
(append (mod/case-history c) (list {:note note :to to :from from})))
|
|
||||||
(mod/case*
|
|
||||||
(mod/case-report c)
|
|
||||||
from
|
|
||||||
(mod/case-decision c)
|
|
||||||
(mod/case-tier c)
|
|
||||||
(str "illegal transition: " from " -> " to)
|
|
||||||
(mod/case-history c))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/case-error-set
|
|
||||||
(fn
|
|
||||||
(c msg)
|
|
||||||
(mod/case*
|
|
||||||
(mod/case-report c)
|
|
||||||
(mod/case-state c)
|
|
||||||
(mod/case-decision c)
|
|
||||||
(mod/case-tier c)
|
|
||||||
msg
|
|
||||||
(mod/case-history c))))
|
|
||||||
|
|
||||||
;; ── lifecycle operations ──
|
|
||||||
|
|
||||||
;; :open → :triaged — run the auto-tier first pass.
|
|
||||||
(define
|
|
||||||
mod/case-triage
|
|
||||||
(fn
|
|
||||||
(c reports rules)
|
|
||||||
(let
|
|
||||||
((d (mod/decide-report (mod/case-report c) reports rules)))
|
|
||||||
(let
|
|
||||||
((tier (if (= (get d :action) "escalate") "human" "auto")))
|
|
||||||
(mod/case-goto
|
|
||||||
c
|
|
||||||
"triaged"
|
|
||||||
"auto-tier first pass"
|
|
||||||
(mod/case-report c)
|
|
||||||
d
|
|
||||||
tier)))))
|
|
||||||
|
|
||||||
;; :triaged → :decided — auto-tier resolves; human-tier is blocked until review.
|
|
||||||
(define
|
|
||||||
mod/case-resolve
|
|
||||||
(fn
|
|
||||||
(c)
|
|
||||||
(if
|
|
||||||
(= (mod/case-tier c) "human")
|
|
||||||
(mod/case-error-set c "awaiting human review (escalated)")
|
|
||||||
(mod/case-goto
|
|
||||||
c
|
|
||||||
"decided"
|
|
||||||
"auto-tier resolved"
|
|
||||||
(mod/case-report c)
|
|
||||||
(mod/case-decision c)
|
|
||||||
(mod/case-tier c)))))
|
|
||||||
|
|
||||||
;; :triaged → :decided — human review: attach evidence, re-decide, resolve.
|
|
||||||
(define
|
|
||||||
mod/case-review
|
|
||||||
(fn
|
|
||||||
(c kind val reports rules)
|
|
||||||
(let
|
|
||||||
((nr (mod/attach-evidence (mod/case-report c) (mod/mk-evidence kind val))))
|
|
||||||
(let
|
|
||||||
((d (mod/decide-report nr reports rules)))
|
|
||||||
(mod/case-goto c "decided" (str "human review: " kind) nr d "human")))))
|
|
||||||
|
|
||||||
;; :decided → :appealed — appeal: attach evidence, re-decide (may override).
|
|
||||||
(define
|
|
||||||
mod/case-appeal
|
|
||||||
(fn
|
|
||||||
(c kind val reports rules)
|
|
||||||
(let
|
|
||||||
((nr (mod/attach-evidence (mod/case-report c) (mod/mk-evidence kind val))))
|
|
||||||
(let
|
|
||||||
((d (mod/decide-report nr reports rules)))
|
|
||||||
(mod/case-goto
|
|
||||||
c
|
|
||||||
"appealed"
|
|
||||||
(str "appeal: " kind)
|
|
||||||
nr
|
|
||||||
d
|
|
||||||
(mod/case-tier c))))))
|
|
||||||
|
|
||||||
;; :decided | :appealed → :final
|
|
||||||
(define
|
|
||||||
mod/case-finalize
|
|
||||||
(fn
|
|
||||||
(c)
|
|
||||||
(mod/case-goto
|
|
||||||
c
|
|
||||||
"final"
|
|
||||||
"finalized"
|
|
||||||
(mod/case-report c)
|
|
||||||
(mod/case-decision c)
|
|
||||||
(mod/case-tier c))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/case-action
|
|
||||||
(fn
|
|
||||||
(c)
|
|
||||||
(let ((d (mod/case-decision c))) (if (nil? d) nil (get d :action)))))
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
;; lib/mod/link.sx — report linking + deduplication.
|
|
||||||
;;
|
|
||||||
;; Reports about the same subject form a cluster; identical reports (same
|
|
||||||
;; reporter + subject + reason) are duplicates. Linking is Prolog-backed: all
|
|
||||||
;; report facts are loaded and related ids are found by unification — the same
|
|
||||||
;; relational substrate the policy engine uses, here for retrieval rather than
|
|
||||||
;; decision. Dedup is pure SX over a normalized link key.
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/link-key
|
|
||||||
(fn
|
|
||||||
(r)
|
|
||||||
(str
|
|
||||||
(mod/report-by r)
|
|
||||||
"|"
|
|
||||||
(mod/report-about r)
|
|
||||||
"|"
|
|
||||||
(downcase (mod/report-reason r)))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/dedup-reports
|
|
||||||
(fn
|
|
||||||
(reports)
|
|
||||||
(reduce
|
|
||||||
(fn
|
|
||||||
(acc r)
|
|
||||||
(if
|
|
||||||
(mod/any? (fn (x) (= (mod/link-key x) (mod/link-key r))) acc)
|
|
||||||
acc
|
|
||||||
(append acc (list r))))
|
|
||||||
(list)
|
|
||||||
reports)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/duplicate-count
|
|
||||||
(fn (reports) (- (len reports) (len (mod/dedup-reports reports)))))
|
|
||||||
|
|
||||||
;; ── Prolog-backed relational retrieval ──
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/report-rel-facts
|
|
||||||
(fn
|
|
||||||
(reports)
|
|
||||||
(mod/join-with
|
|
||||||
"\n"
|
|
||||||
(map
|
|
||||||
(fn
|
|
||||||
(r)
|
|
||||||
(str
|
|
||||||
"report("
|
|
||||||
(mod/report-id r)
|
|
||||||
", "
|
|
||||||
(mod/pl-quote (mod/report-by r))
|
|
||||||
", "
|
|
||||||
(mod/pl-quote (mod/report-about r))
|
|
||||||
")."))
|
|
||||||
reports))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/related-ids
|
|
||||||
(fn
|
|
||||||
(subject reports)
|
|
||||||
(let
|
|
||||||
((db (pl-load (mod/report-rel-facts reports))))
|
|
||||||
(map
|
|
||||||
(fn (sol) (dict-get sol "Id"))
|
|
||||||
(pl-query-all db (str "report(Id, _, " (mod/pl-quote subject) ")"))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/reporters-of
|
|
||||||
(fn
|
|
||||||
(subject reports)
|
|
||||||
(let
|
|
||||||
((db (pl-load (mod/report-rel-facts reports))))
|
|
||||||
(map
|
|
||||||
(fn (sol) (dict-get sol "By"))
|
|
||||||
(pl-query-all db (str "report(_, By, " (mod/pl-quote subject) ")"))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/distinct
|
|
||||||
(fn
|
|
||||||
(items)
|
|
||||||
(reduce
|
|
||||||
(fn
|
|
||||||
(acc x)
|
|
||||||
(if (mod/any? (fn (y) (= y x)) acc) acc (append acc (list x))))
|
|
||||||
(list)
|
|
||||||
items)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/distinct-reporters-of
|
|
||||||
(fn (subject reports) (mod/distinct (mod/reporters-of subject reports))))
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
;; lib/mod/lint.sx — static analysis of a policy rule set.
|
|
||||||
;;
|
|
||||||
;; Because precedence is "first matching clause wins" (pl-query-one), the rule
|
|
||||||
;; order has correctness consequences a moderator can get wrong: a rule placed
|
|
||||||
;; after an unconditional (empty :when) rule can never fire, and a rule set with
|
|
||||||
;; no unconditional rule may leave some reports undecided. lint-rules surfaces
|
|
||||||
;; these without running the engine.
|
|
||||||
|
|
||||||
(define mod/rule-unconditional? (fn (r) (empty? (mod/rule-when r))))
|
|
||||||
|
|
||||||
;; names of rules that follow the first unconditional rule — structurally dead,
|
|
||||||
;; since the unconditional rule always matches first
|
|
||||||
(define
|
|
||||||
mod/unreachable-rules
|
|
||||||
(fn
|
|
||||||
(rules)
|
|
||||||
(get
|
|
||||||
(reduce
|
|
||||||
(fn
|
|
||||||
(acc r)
|
|
||||||
(if
|
|
||||||
(get acc :hit)
|
|
||||||
{:dead (append (get acc :dead) (list (mod/rule-name r))) :hit true}
|
|
||||||
(if (mod/rule-unconditional? r) {:dead (get acc :dead) :hit true} acc)))
|
|
||||||
{:dead (list) :hit false}
|
|
||||||
rules)
|
|
||||||
:dead)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/has-catchall?
|
|
||||||
(fn (rules) (mod/any? mod/rule-unconditional? rules)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/count-eq
|
|
||||||
(fn
|
|
||||||
(x lst)
|
|
||||||
(reduce (fn (a y) (if (= y x) (+ a 1) a)) 0 lst)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/duplicate-rule-names
|
|
||||||
(fn
|
|
||||||
(rules)
|
|
||||||
(let
|
|
||||||
((names (map mod/rule-name rules)))
|
|
||||||
(mod/distinct
|
|
||||||
(reduce
|
|
||||||
(fn
|
|
||||||
(acc n)
|
|
||||||
(if
|
|
||||||
(< 1 (mod/count-eq n names))
|
|
||||||
(append acc (list n))
|
|
||||||
acc))
|
|
||||||
(list)
|
|
||||||
names)))))
|
|
||||||
|
|
||||||
(define mod/lint-rules (fn (rules) {:duplicate-names (mod/duplicate-rule-names rules) :has-catchall (mod/has-catchall? rules) :unreachable (mod/unreachable-rules rules)}))
|
|
||||||
|
|
||||||
;; a rule set is well-formed when nothing is dead, it has a catch-all, and rule
|
|
||||||
;; names are unique
|
|
||||||
(define
|
|
||||||
mod/rules-ok?
|
|
||||||
(fn
|
|
||||||
(rules)
|
|
||||||
(let
|
|
||||||
((l (mod/lint-rules rules)))
|
|
||||||
(if
|
|
||||||
(empty? (get l :unreachable))
|
|
||||||
(if (get l :has-catchall) (empty? (get l :duplicate-names)) false)
|
|
||||||
false))))
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
;; lib/mod/offenders.sx — repeat-offender escalation (audit log as evidence).
|
|
||||||
;;
|
|
||||||
;; The append-only audit trail is itself a source of evidence: a subject already
|
|
||||||
;; sanctioned several times is a repeat offender. mod/decide-escalating decides a
|
|
||||||
;; report normally, then — if the action is a sanction and the subject has at
|
|
||||||
;; least k PRIOR sanctions in the audit log — upgrades it to :ban. This is the one
|
|
||||||
;; place a decision depends on history beyond the single report, and it reads that
|
|
||||||
;; history from the audit log rather than re-deriving it.
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/sanction?
|
|
||||||
(fn
|
|
||||||
(action)
|
|
||||||
(mod/any? (fn (a) (= a action)) (list "hide" "remove" "ban"))))
|
|
||||||
|
|
||||||
;; count of prior sanctioning decisions in the audit log about a subject
|
|
||||||
(define
|
|
||||||
mod/subject-sanctions
|
|
||||||
(fn
|
|
||||||
(subject)
|
|
||||||
(reduce
|
|
||||||
(fn
|
|
||||||
(acc e)
|
|
||||||
(let
|
|
||||||
((r (mod/get-report (get e :report-id))))
|
|
||||||
(if
|
|
||||||
(nil? r)
|
|
||||||
acc
|
|
||||||
(if
|
|
||||||
(if
|
|
||||||
(= (mod/report-about r) subject)
|
|
||||||
(mod/sanction? (get e :action))
|
|
||||||
false)
|
|
||||||
(+ acc 1)
|
|
||||||
acc))))
|
|
||||||
0
|
|
||||||
(mod/audit-all))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/repeat-offender?
|
|
||||||
(fn (subject k) (<= k (mod/subject-sanctions subject))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/decide-escalating
|
|
||||||
(fn
|
|
||||||
(id k)
|
|
||||||
(let
|
|
||||||
((r (mod/get-report id)))
|
|
||||||
(if
|
|
||||||
(nil? r)
|
|
||||||
nil
|
|
||||||
(let
|
|
||||||
((priors (mod/subject-sanctions (mod/report-about r))))
|
|
||||||
(let
|
|
||||||
((d (mod/decide id)))
|
|
||||||
(if
|
|
||||||
(if (mod/sanction? (get d :action)) (<= k priors) false)
|
|
||||||
{:action "ban" :proof {:goals (get (get d :proof) :goals) :prior-sanctions priors :evidence (get (get d :proof) :evidence) :conditions (list) :rule "repeat-offender-ban" :count (get (get d :proof) :count)} :report-id id :rule "repeat-offender-ban" :strategy "escalating"}
|
|
||||||
d)))))))
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
;; lib/mod/pipeline.sx — end-to-end triage orchestration.
|
|
||||||
;;
|
|
||||||
;; A single entry point that runs a report through the subsystem and returns the
|
|
||||||
;; full artifact bundle: the decision (under the report's domain policy), a
|
|
||||||
;; human-readable explanation, an ActivityPub-shaped event for the bus, and the
|
|
||||||
;; wire line for federated peers. Composes policies (Ext 17), explain (Ext 3),
|
|
||||||
;; activity (Ext 16) and wire (Ext 14) — the modules are independent, this is just
|
|
||||||
;; the convenience that wires them together for the common "process a report" path.
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/triage-pipeline
|
|
||||||
(fn
|
|
||||||
(domain r reports actor)
|
|
||||||
(let ((d (mod/decide-in domain r reports))) {:activity (mod/decision->activity d actor) :action (get d :action) :wire (mod/decision->wire d) :rule (get d :rule) :decision d :explanation (mod/explain d)})))
|
|
||||||
|
|
||||||
(define mod/pipeline-action (fn (p) (get p :action)))
|
|
||||||
(define mod/pipeline-activity (fn (p) (get p :activity)))
|
|
||||||
(define mod/pipeline-wire (fn (p) (get p :wire)))
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
;; lib/mod/policies.sx — per-domain policy registry.
|
|
||||||
;;
|
|
||||||
;; rose-ash spans domains (blog, market, events, federation, …) that want
|
|
||||||
;; different moderation — a marketplace listing and a blog comment are not held to
|
|
||||||
;; the same bar. This registry maps a domain to a rule set; mod/decide-in resolves
|
|
||||||
;; the right policy and decides. Unregistered domains fall back to the default
|
|
||||||
;; rules, so adding a domain never leaves it unmoderated.
|
|
||||||
|
|
||||||
(define mod/*policies* (list))
|
|
||||||
|
|
||||||
(define mod/policies-reset! (fn () (set! mod/*policies* (list))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/register-policy!
|
|
||||||
(fn (domain rules) (begin (append! mod/*policies* {:domain domain :rules rules}) true)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/policy-registered?
|
|
||||||
(fn
|
|
||||||
(domain)
|
|
||||||
(mod/any? (fn (p) (= (get p :domain) domain)) mod/*policies*)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/policy-for
|
|
||||||
(fn
|
|
||||||
(domain)
|
|
||||||
(reduce
|
|
||||||
(fn (acc p) (if (= (get p :domain) domain) (get p :rules) acc))
|
|
||||||
mod/default-rules
|
|
||||||
mod/*policies*)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/decide-in
|
|
||||||
(fn
|
|
||||||
(domain r reports)
|
|
||||||
(mod/decide-report r reports (mod/policy-for domain))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/registered-domains
|
|
||||||
(fn () (map (fn (p) (get p :domain)) mod/*policies*)))
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
;; lib/mod/policy.sx — moderation rules → Prolog clauses.
|
|
||||||
;;
|
|
||||||
;; A rule is {:name :action :when}. :when is a list of condition forms; each
|
|
||||||
;; compiles to a Prolog goal. The conditions in a :when list are ANDed (joined by
|
|
||||||
;; ", "); :not negates and :any (a list of sub-conditions) disjoins — so the
|
|
||||||
;; condition language is a small boolean algebra over the leaf predicates.
|
|
||||||
;; Rule order is precedence: the engine queries with pl-query-one, so the first
|
|
||||||
;; clause that proves wins. The final default rule has an empty body (true) so
|
|
||||||
;; every report yields at least :keep — "no rule matched" is a real result, not a
|
|
||||||
;; query failure.
|
|
||||||
;;
|
|
||||||
;; cond->goal takes an id-term so the same condition can be compiled with the
|
|
||||||
;; head variable "Id" (for clause bodies) or a concrete report id (for proof-tree
|
|
||||||
;; goal-by-goal re-querying in the engine).
|
|
||||||
;;
|
|
||||||
;; Precedence (top wins): exoneration evidence (appeal override) > confirmed-abuse
|
|
||||||
;; evidence (human review) > spam/abuse classification > repeated-report count >
|
|
||||||
;; default keep.
|
|
||||||
|
|
||||||
(define mod/mk-rule (fn (name action conds) {:when conds :name name :action action}))
|
|
||||||
|
|
||||||
(define mod/rule-name (fn (r) (get r :name)))
|
|
||||||
(define mod/rule-action (fn (r) (get r :action)))
|
|
||||||
(define mod/rule-when (fn (r) (get r :when)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/default-rules
|
|
||||||
(list
|
|
||||||
(mod/mk-rule
|
|
||||||
"exonerated-keep"
|
|
||||||
:keep (list (list :evidence "exonerated")))
|
|
||||||
(mod/mk-rule
|
|
||||||
"reviewer-remove"
|
|
||||||
:remove (list (list :evidence "confirmed-abuse")))
|
|
||||||
(mod/mk-rule "spam-hide" :hide (list (list :classification "spam")))
|
|
||||||
(mod/mk-rule
|
|
||||||
"abuse-remove"
|
|
||||||
:remove (list (list :classification "abuse")))
|
|
||||||
(mod/mk-rule
|
|
||||||
"repeated-escalate"
|
|
||||||
:escalate (list (list :count-at-least 3)))
|
|
||||||
(mod/mk-rule "default-keep" :keep (list))))
|
|
||||||
|
|
||||||
;; ── condition → Prolog goal ──
|
|
||||||
;;
|
|
||||||
;; (:classification "spam") → classification(Id, spam)
|
|
||||||
;; (:evidence "kind") → evidence(Id, 'kind', _)
|
|
||||||
;; (:attr "verified") → attr(Id, verified)
|
|
||||||
;; (:not <cond>) → not(<cond>) (negation)
|
|
||||||
;; (:any (list c1 c2 ...)) → (g1 ; g2 ; ...) (disjunction)
|
|
||||||
;; (:count-at-least 3) → report(Id, B, S), report_count(S, N), N >= 3
|
|
||||||
;; (:score-at-least 5) → aggregate_all(sum(W), signal(Id, _, W), T), T >= 5
|
|
||||||
;; (:reporters-at-least 2) → report(Id, _, Sr), setof(Br, report(_, Br, Sr), Bsr),
|
|
||||||
;; length(Bsr, Nr), Nr >= 2 (quorum engine)
|
|
||||||
;; (:burst-at-least 3) → report(Id, _, Sb), burst_count(Sb, Nb), Nb >= 3
|
|
||||||
;; (temporal engine)
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/cond->goal
|
|
||||||
(fn
|
|
||||||
(c idterm)
|
|
||||||
(let
|
|
||||||
((tag (first c)))
|
|
||||||
(cond
|
|
||||||
((= tag :classification)
|
|
||||||
(str "classification(" idterm ", " (nth c 1) ")"))
|
|
||||||
((= tag :evidence)
|
|
||||||
(str
|
|
||||||
"evidence("
|
|
||||||
idterm
|
|
||||||
", "
|
|
||||||
(mod/pl-quote (nth c 1))
|
|
||||||
", _)"))
|
|
||||||
((= tag :attr) (str "attr(" idterm ", " (nth c 1) ")"))
|
|
||||||
((= tag :not)
|
|
||||||
(str "not(" (mod/cond->goal (nth c 1) idterm) ")"))
|
|
||||||
((= tag :any)
|
|
||||||
(str
|
|
||||||
"("
|
|
||||||
(mod/join-with
|
|
||||||
" ; "
|
|
||||||
(map
|
|
||||||
(fn (sub) (mod/cond->goal sub idterm))
|
|
||||||
(nth c 1)))
|
|
||||||
")"))
|
|
||||||
((= tag :count-at-least)
|
|
||||||
(str
|
|
||||||
"report("
|
|
||||||
idterm
|
|
||||||
", B, S), report_count(S, N), N >= "
|
|
||||||
(nth c 1)))
|
|
||||||
((= tag :score-at-least)
|
|
||||||
(str
|
|
||||||
"aggregate_all(sum(W), signal("
|
|
||||||
idterm
|
|
||||||
", _, W), T), T >= "
|
|
||||||
(nth c 1)))
|
|
||||||
((= tag :reporters-at-least)
|
|
||||||
(str
|
|
||||||
"report("
|
|
||||||
idterm
|
|
||||||
", _, Sr), setof(Br, report(_, Br, Sr), Bsr), "
|
|
||||||
"length(Bsr, Nr), Nr >= "
|
|
||||||
(nth c 1)))
|
|
||||||
((= tag :burst-at-least)
|
|
||||||
(str
|
|
||||||
"report("
|
|
||||||
idterm
|
|
||||||
", _, Sb), burst_count(Sb, Nb), Nb >= "
|
|
||||||
(nth c 1)))
|
|
||||||
(true "true")))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/conds->body
|
|
||||||
(fn
|
|
||||||
(conds idterm)
|
|
||||||
(if
|
|
||||||
(empty? conds)
|
|
||||||
"true"
|
|
||||||
(mod/join-with ", " (map (fn (c) (mod/cond->goal c idterm)) conds)))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/rule->clause
|
|
||||||
(fn
|
|
||||||
(r)
|
|
||||||
(str
|
|
||||||
"policy_action(Id, "
|
|
||||||
(mod/rule-action r)
|
|
||||||
", '"
|
|
||||||
(mod/rule-name r)
|
|
||||||
"') :- "
|
|
||||||
(mod/conds->body (mod/rule-when r) "Id")
|
|
||||||
".")))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/rules->program
|
|
||||||
(fn (rules) (mod/join-with "\n" (map mod/rule->clause rules))))
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
;; lib/mod/quorum.sx — quorum decisions over distinct reporters (anti-brigade).
|
|
||||||
;;
|
|
||||||
;; The base engine asserts only the decided report's report/3 fact, so it can't
|
|
||||||
;; reason about WHO reported a subject. The quorum engine additionally asserts
|
|
||||||
;; every report's report/3 fact (via link's rel-facts), letting a rule require N
|
|
||||||
;; *distinct* reporters with `setof`/`length` — so one user filing many reports
|
|
||||||
;; does not manufacture consensus. Same decision shape as the base engine, plus
|
|
||||||
;; :strategy "quorum".
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/build-quorum-program
|
|
||||||
(fn
|
|
||||||
(r count reports rules)
|
|
||||||
(str
|
|
||||||
(mod/report-rel-facts reports)
|
|
||||||
"\n"
|
|
||||||
(mod/report-facts r count)
|
|
||||||
"\n"
|
|
||||||
(mod/rules->program rules))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/decide-quorum
|
|
||||||
(fn
|
|
||||||
(r reports rules)
|
|
||||||
(let
|
|
||||||
((count (mod/report-count (mod/report-about r) reports))
|
|
||||||
(kinds (mod/classify-keywords r))
|
|
||||||
(id (mod/report-id r)))
|
|
||||||
(let
|
|
||||||
((program (mod/build-quorum-program r count reports rules)))
|
|
||||||
(let
|
|
||||||
((db (pl-load program)))
|
|
||||||
(let
|
|
||||||
((sol (pl-query-one db (str "policy_action(" id ", Action, Rule)"))))
|
|
||||||
(if
|
|
||||||
(nil? sol)
|
|
||||||
{:action "keep" :proof {:goals (list) :evidence kinds :conditions (list) :rule "none" :count count} :report-id id :rule "none" :strategy "quorum"}
|
|
||||||
(let
|
|
||||||
((rule (mod/find-rule rules (dict-get sol "Rule"))))
|
|
||||||
{:action (mod/rule-action rule) :proof {:goals (mod/proof-goals db id (mod/rule-when rule)) :evidence kinds :conditions (mod/rule-when rule) :rule (mod/rule-name rule) :count count} :report-id id :rule (mod/rule-name rule) :strategy "quorum"}))))))))
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
;; lib/mod/schema.sx — report representation + Prolog fact generation.
|
|
||||||
;;
|
|
||||||
;; A report is a dict {:id :by :about :reason :evidence :attrs :signals :at}.
|
|
||||||
;; :evidence — accumulated {:kind :val} entries (human review, scanners)
|
|
||||||
;; :attrs — attribute names ("verified") for negation-as-failure conditions
|
|
||||||
;; :signals — weighted {:kind :weight} entries for aggregate scoring rules
|
|
||||||
;; :at — integer timestamp/tick (deterministic; supplied, not clock-read)
|
|
||||||
;; The engine derives keyword classifications from the reason text and projects
|
|
||||||
;; the report, its classifications, evidence, attributes, and signals into Prolog
|
|
||||||
;; facts that policy clauses match against.
|
|
||||||
|
|
||||||
(define mod/mk-report (fn (id by about reason) {:attrs (list) :id id :signals (list) :by by :evidence (list) :about about :at 0 :reason reason}))
|
|
||||||
|
|
||||||
(define mod/report-id (fn (r) (get r :id)))
|
|
||||||
(define mod/report-by (fn (r) (get r :by)))
|
|
||||||
(define mod/report-about (fn (r) (get r :about)))
|
|
||||||
(define mod/report-reason (fn (r) (get r :reason)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/report-evidence
|
|
||||||
(fn (r) (let ((e (get r :evidence))) (if (nil? e) (list) e))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/report-attrs
|
|
||||||
(fn (r) (let ((a (get r :attrs))) (if (nil? a) (list) a))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/report-signals
|
|
||||||
(fn (r) (let ((s (get r :signals))) (if (nil? s) (list) s))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/report-at
|
|
||||||
(fn (r) (let ((t (get r :at))) (if (nil? t) 0 t))))
|
|
||||||
|
|
||||||
(define mod/mk-evidence (fn (kind val) {:val val :kind kind}))
|
|
||||||
(define mod/evidence-kind (fn (e) (get e :kind)))
|
|
||||||
(define mod/evidence-val (fn (e) (get e :val)))
|
|
||||||
|
|
||||||
(define mod/mk-signal (fn (kind weight) {:kind kind :weight weight}))
|
|
||||||
(define mod/signal-kind (fn (s) (get s :kind)))
|
|
||||||
(define mod/signal-weight (fn (s) (get s :weight)))
|
|
||||||
|
|
||||||
(define mod/report* (fn (r evs attrs sigs at) {:attrs attrs :id (mod/report-id r) :signals sigs :by (mod/report-by r) :evidence evs :about (mod/report-about r) :at at :reason (mod/report-reason r)}))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/with-evidence
|
|
||||||
(fn
|
|
||||||
(r evs)
|
|
||||||
(mod/report*
|
|
||||||
r
|
|
||||||
evs
|
|
||||||
(mod/report-attrs r)
|
|
||||||
(mod/report-signals r)
|
|
||||||
(mod/report-at r))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/with-attrs
|
|
||||||
(fn
|
|
||||||
(r attrs)
|
|
||||||
(mod/report*
|
|
||||||
r
|
|
||||||
(mod/report-evidence r)
|
|
||||||
attrs
|
|
||||||
(mod/report-signals r)
|
|
||||||
(mod/report-at r))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/with-signals
|
|
||||||
(fn
|
|
||||||
(r sigs)
|
|
||||||
(mod/report*
|
|
||||||
r
|
|
||||||
(mod/report-evidence r)
|
|
||||||
(mod/report-attrs r)
|
|
||||||
sigs
|
|
||||||
(mod/report-at r))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/with-at
|
|
||||||
(fn
|
|
||||||
(r at)
|
|
||||||
(mod/report*
|
|
||||||
r
|
|
||||||
(mod/report-evidence r)
|
|
||||||
(mod/report-attrs r)
|
|
||||||
(mod/report-signals r)
|
|
||||||
at)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/attach-evidence
|
|
||||||
(fn
|
|
||||||
(r e)
|
|
||||||
(mod/with-evidence r (append (mod/report-evidence r) (list e)))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/attach-attr
|
|
||||||
(fn (r a) (mod/with-attrs r (append (mod/report-attrs r) (list a)))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/attach-signal
|
|
||||||
(fn (r s) (mod/with-signals r (append (mod/report-signals r) (list s)))))
|
|
||||||
|
|
||||||
;; ── substring search (the prolog-loaded env lacks includes?; slice/len do work) ──
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/contains-at?
|
|
||||||
(fn
|
|
||||||
(hay needle hl nl pos)
|
|
||||||
(if
|
|
||||||
(< hl (+ pos nl))
|
|
||||||
false
|
|
||||||
(if
|
|
||||||
(= (slice hay pos (+ pos nl)) needle)
|
|
||||||
true
|
|
||||||
(mod/contains-at? hay needle hl nl (+ pos 1))))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/str-contains?
|
|
||||||
(fn
|
|
||||||
(hay needle)
|
|
||||||
(let
|
|
||||||
((hl (len hay)) (nl (len needle)))
|
|
||||||
(if
|
|
||||||
(= nl 0)
|
|
||||||
true
|
|
||||||
(mod/contains-at? hay needle hl nl 0)))))
|
|
||||||
|
|
||||||
;; ── evidence derivation (keyword classification) ──
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/spam-keywords
|
|
||||||
(list "spam" "buy now" "click here" "free money" "viagra" "limited offer"))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/abuse-keywords
|
|
||||||
(list "abuse" "harassment" "threat" "slur" "hate speech"))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/any?
|
|
||||||
(fn (pred coll) (reduce (fn (acc x) (if acc acc (pred x))) false coll)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/reason-matches?
|
|
||||||
(fn
|
|
||||||
(reason kws)
|
|
||||||
(let
|
|
||||||
((low (downcase reason)))
|
|
||||||
(mod/any? (fn (k) (mod/str-contains? low k)) kws))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/classify-keywords
|
|
||||||
(fn
|
|
||||||
(r)
|
|
||||||
(let
|
|
||||||
((reason (mod/report-reason r)) (kinds (list)))
|
|
||||||
(begin
|
|
||||||
(when
|
|
||||||
(mod/reason-matches? reason mod/spam-keywords)
|
|
||||||
(append! kinds "spam"))
|
|
||||||
(when
|
|
||||||
(mod/reason-matches? reason mod/abuse-keywords)
|
|
||||||
(append! kinds "abuse"))
|
|
||||||
kinds))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/report-count
|
|
||||||
(fn
|
|
||||||
(about reports)
|
|
||||||
(reduce
|
|
||||||
(fn
|
|
||||||
(acc r)
|
|
||||||
(if (= (mod/report-about r) about) (+ acc 1) acc))
|
|
||||||
0
|
|
||||||
reports)))
|
|
||||||
|
|
||||||
;; ── Prolog fact projection ──
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/join-with
|
|
||||||
(fn
|
|
||||||
(sep items)
|
|
||||||
(reduce (fn (acc x) (if (= acc "") x (str acc sep x))) "" items)))
|
|
||||||
|
|
||||||
(define mod/pl-quote (fn (s) (str "'" s "'")))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/classification-facts
|
|
||||||
(fn
|
|
||||||
(id kinds)
|
|
||||||
(mod/join-with
|
|
||||||
"\n"
|
|
||||||
(map (fn (k) (str "classification(" id ", " k ").")) kinds))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/evidence-facts
|
|
||||||
(fn
|
|
||||||
(id evs)
|
|
||||||
(mod/join-with
|
|
||||||
"\n"
|
|
||||||
(map
|
|
||||||
(fn
|
|
||||||
(e)
|
|
||||||
(str
|
|
||||||
"evidence("
|
|
||||||
id
|
|
||||||
", "
|
|
||||||
(mod/pl-quote (mod/evidence-kind e))
|
|
||||||
", "
|
|
||||||
(mod/pl-quote (str (mod/evidence-val e)))
|
|
||||||
")."))
|
|
||||||
evs))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/attr-facts
|
|
||||||
(fn
|
|
||||||
(id attrs)
|
|
||||||
(mod/join-with "\n" (map (fn (a) (str "attr(" id ", " a ").")) attrs))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/signal-facts
|
|
||||||
(fn
|
|
||||||
(id sigs)
|
|
||||||
(mod/join-with
|
|
||||||
"\n"
|
|
||||||
(map
|
|
||||||
(fn
|
|
||||||
(s)
|
|
||||||
(str
|
|
||||||
"signal("
|
|
||||||
id
|
|
||||||
", "
|
|
||||||
(mod/pl-quote (mod/signal-kind s))
|
|
||||||
", "
|
|
||||||
(mod/signal-weight s)
|
|
||||||
")."))
|
|
||||||
sigs))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/report-facts
|
|
||||||
(fn
|
|
||||||
(r count)
|
|
||||||
(let
|
|
||||||
((id (mod/report-id r))
|
|
||||||
(by (mod/pl-quote (mod/report-by r)))
|
|
||||||
(about (mod/pl-quote (mod/report-about r))))
|
|
||||||
(let
|
|
||||||
((cls (mod/classification-facts id (mod/classify-keywords r)))
|
|
||||||
(evs (mod/evidence-facts id (mod/report-evidence r)))
|
|
||||||
(ats (mod/attr-facts id (mod/report-attrs r)))
|
|
||||||
(sgs (mod/signal-facts id (mod/report-signals r))))
|
|
||||||
(mod/join-with
|
|
||||||
"\n"
|
|
||||||
(list
|
|
||||||
(str "report(" id ", " by ", " about ").")
|
|
||||||
(str "report_count(" about ", " count ").")
|
|
||||||
cls
|
|
||||||
evs
|
|
||||||
ats
|
|
||||||
sgs))))))
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"lang": "mod",
|
|
||||||
"total_passed": 390,
|
|
||||||
"total_failed": 0,
|
|
||||||
"total": 390,
|
|
||||||
"suites": [
|
|
||||||
{"name":"decide","passed":31,"failed":0,"total":31},
|
|
||||||
{"name":"audit","passed":29,"failed":0,"total":29},
|
|
||||||
{"name":"escalation","passed":46,"failed":0,"total":46},
|
|
||||||
{"name":"fed","passed":26,"failed":0,"total":26},
|
|
||||||
{"name":"extensions","passed":32,"failed":0,"total":32},
|
|
||||||
{"name":"link","passed":12,"failed":0,"total":12},
|
|
||||||
{"name":"lint","passed":14,"failed":0,"total":14},
|
|
||||||
{"name":"severity","passed":14,"failed":0,"total":14},
|
|
||||||
{"name":"offenders","passed":19,"failed":0,"total":19},
|
|
||||||
{"name":"quorum","passed":9,"failed":0,"total":9},
|
|
||||||
{"name":"trace","passed":15,"failed":0,"total":15},
|
|
||||||
{"name":"whatif","passed":13,"failed":0,"total":13},
|
|
||||||
{"name":"batch","passed":17,"failed":0,"total":17},
|
|
||||||
{"name":"temporal","passed":15,"failed":0,"total":15},
|
|
||||||
{"name":"sla","passed":15,"failed":0,"total":15},
|
|
||||||
{"name":"wire","passed":16,"failed":0,"total":16},
|
|
||||||
{"name":"disjunction","passed":10,"failed":0,"total":10},
|
|
||||||
{"name":"activity","passed":17,"failed":0,"total":17},
|
|
||||||
{"name":"policies","passed":14,"failed":0,"total":14},
|
|
||||||
{"name":"defrule","passed":11,"failed":0,"total":11},
|
|
||||||
{"name":"pipeline","passed":15,"failed":0,"total":15}
|
|
||||||
],
|
|
||||||
"generated": "2026-06-06T19:40:03+00:00"
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# mod scoreboard
|
|
||||||
|
|
||||||
**390 / 390 passing** (0 failure(s)).
|
|
||||||
|
|
||||||
| Suite | Passed | Total | Status |
|
|
||||||
|-------|--------|-------|--------|
|
|
||||||
| decide | 31 | 31 | ok |
|
|
||||||
| audit | 29 | 29 | ok |
|
|
||||||
| escalation | 46 | 46 | ok |
|
|
||||||
| fed | 26 | 26 | ok |
|
|
||||||
| extensions | 32 | 32 | ok |
|
|
||||||
| link | 12 | 12 | ok |
|
|
||||||
| lint | 14 | 14 | ok |
|
|
||||||
| severity | 14 | 14 | ok |
|
|
||||||
| offenders | 19 | 19 | ok |
|
|
||||||
| quorum | 9 | 9 | ok |
|
|
||||||
| trace | 15 | 15 | ok |
|
|
||||||
| whatif | 13 | 13 | ok |
|
|
||||||
| batch | 17 | 17 | ok |
|
|
||||||
| temporal | 15 | 15 | ok |
|
|
||||||
| sla | 15 | 15 | ok |
|
|
||||||
| wire | 16 | 16 | ok |
|
|
||||||
| disjunction | 10 | 10 | ok |
|
|
||||||
| activity | 17 | 17 | ok |
|
|
||||||
| policies | 14 | 14 | ok |
|
|
||||||
| defrule | 11 | 11 | ok |
|
|
||||||
| pipeline | 15 | 15 | ok |
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
;; lib/mod/severity.sx — "strictest-wins" decision strategy.
|
|
||||||
;;
|
|
||||||
;; The default engine resolves precedence by rule ORDER (first proven clause wins,
|
|
||||||
;; via pl-query-one). Some policies instead want the HARSHEST applicable sanction
|
|
||||||
;; regardless of order. mod/decide-strictest collects every rule that proves
|
|
||||||
;; (pl-query-all) and picks the highest-severity action. Same decision shape as
|
|
||||||
;; the engine, plus :strategy. Built over the engine's helpers; engine untouched.
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/action-severity
|
|
||||||
(fn
|
|
||||||
(action)
|
|
||||||
(cond
|
|
||||||
((= action "ban") 4)
|
|
||||||
((= action "remove") 3)
|
|
||||||
((= action "hide") 2)
|
|
||||||
((= action "escalate") 1)
|
|
||||||
(true 0))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/strictest-sol
|
|
||||||
(fn
|
|
||||||
(sols)
|
|
||||||
(reduce
|
|
||||||
(fn
|
|
||||||
(acc s)
|
|
||||||
(if
|
|
||||||
(nil? acc)
|
|
||||||
s
|
|
||||||
(if
|
|
||||||
(<
|
|
||||||
(mod/action-severity (dict-get acc "Action"))
|
|
||||||
(mod/action-severity (dict-get s "Action")))
|
|
||||||
s
|
|
||||||
acc)))
|
|
||||||
nil
|
|
||||||
sols)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/decide-strictest
|
|
||||||
(fn
|
|
||||||
(r reports rules)
|
|
||||||
(let
|
|
||||||
((count (mod/report-count (mod/report-about r) reports))
|
|
||||||
(kinds (mod/classify-keywords r))
|
|
||||||
(id (mod/report-id r)))
|
|
||||||
(let
|
|
||||||
((program (mod/build-program r count rules)))
|
|
||||||
(let
|
|
||||||
((db (pl-load program)))
|
|
||||||
(let
|
|
||||||
((sols (pl-query-all db (str "policy_action(" id ", Action, Rule)"))))
|
|
||||||
(let
|
|
||||||
((best (mod/strictest-sol sols)))
|
|
||||||
(if
|
|
||||||
(nil? best)
|
|
||||||
{:action "keep" :proof {:goals (list) :evidence kinds :conditions (list) :rule "none" :count count} :report-id id :rule "none" :strategy "strictest"}
|
|
||||||
(let
|
|
||||||
((rule (mod/find-rule rules (dict-get best "Rule"))))
|
|
||||||
{:action (mod/rule-action rule) :proof {:goals (mod/proof-goals db id (mod/rule-when rule)) :evidence kinds :conditions (mod/rule-when rule) :rule (mod/rule-name rule) :count count} :report-id id :rule (mod/rule-name rule) :strategy "strictest"})))))))))
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
;; lib/mod/sla.sx — service-level sweep over pending lifecycle cases.
|
|
||||||
;;
|
|
||||||
;; Composes the Phase-3 lifecycle with the Ext-12 time dimension: a case left in a
|
|
||||||
;; pending state (open / triaged / appealed) past a deadline has breached SLA and
|
|
||||||
;; should resurface. A timed-case pairs a case with the tick it entered its
|
|
||||||
;; current state (the caller stamps this — the lifecycle stays timeless and pure).
|
|
||||||
;; Terminal states (decided / final) never breach.
|
|
||||||
|
|
||||||
(define mod/pending-states (list "open" "triaged" "appealed"))
|
|
||||||
(define mod/pending-state? (fn (s) (mod/member? s mod/pending-states)))
|
|
||||||
|
|
||||||
(define mod/mk-timed-case (fn (c entered-at) {:entered-at entered-at :case c}))
|
|
||||||
(define mod/tc-case (fn (tc) (get tc :case)))
|
|
||||||
(define mod/tc-entered-at (fn (tc) (get tc :entered-at)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/overdue?
|
|
||||||
(fn
|
|
||||||
(tc now deadline)
|
|
||||||
(if
|
|
||||||
(mod/pending-state? (mod/case-state (mod/tc-case tc)))
|
|
||||||
(< deadline (- now (mod/tc-entered-at tc)))
|
|
||||||
false)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/sla-sweep
|
|
||||||
(fn
|
|
||||||
(timed-cases now deadline)
|
|
||||||
(reduce
|
|
||||||
(fn
|
|
||||||
(acc tc)
|
|
||||||
(if
|
|
||||||
(mod/overdue? tc now deadline)
|
|
||||||
(append
|
|
||||||
acc
|
|
||||||
(list (mod/report-id (mod/case-report (mod/tc-case tc)))))
|
|
||||||
acc))
|
|
||||||
(list)
|
|
||||||
timed-cases)))
|
|
||||||
|
|
||||||
(define
|
|
||||||
mod/overdue-count
|
|
||||||
(fn
|
|
||||||
(timed-cases now deadline)
|
|
||||||
(len (mod/sla-sweep timed-cases now deadline))))
|
|
||||||
|
|
||||||
(define mod/age (fn (tc now) (- now (mod/tc-entered-at tc))))
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user