explain.sx reconstructs a canonical proof tree (first-rule, first-solution)
by goal-directed search over the saturated db, since Datalog keeps no
provenance; depth-capped for cyclic safety. acl-explain returns
{:allowed? :proof :reason} with the blocking eff_deny proof on denial.
audit.sx is an append-only decision log (monotonic seq, disk serializer).
api gains acl/explain, acl/audit, acl/audit-tail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
10 KiB
acl-on-sx: Access Control on Datalog
rose-ash needs fine-grained, explainable, federation-aware access control. Subjects (users, groups, roles, services) × actions (read, edit, comment, moderate, federate) × resources (pages, posts, threads, peers). Decisions must come with a trace — not just permit/deny, but why.
Datalog's bottom-up rule engine produces transparent permit/deny chains: the proof tree is the audit trail. Inheritance over groups + resource hierarchies is recursive Datalog in one rule. Federation extends naturally — fed-sx replicates ACL facts, peers reason over the union.
End-state: a Datalog-on-SX layer specifically for ACL, with explanation API, audit log,
and federation extension. Reuses lib/datalog/ evaluator and term model where possible.
Status (rolling)
bash lib/acl/conformance.sh → 89/89 (Phases 1-3 complete)
Ground rules
- Scope: only touch
lib/acl/**andplans/acl-on-sx.md. Do not editspec/,hosts/,shared/,lib/datalog/**, or otherlib/<lang>/. You may import fromlib/datalog/(its public API inlib/datalog/datalog.sx); do not copy or modify Datalog code. - Shared-file issues go under "Blockers" with a minimal repro; do not fix here.
- SX files: use
sx-treeMCP tools only. - Architecture: thin layer on top of
lib/datalog/. Define schema, surface API, audit + federation hooks. The rule engine itself is Datalog's. - Watch for shared patterns going into
lib/guest/— both acl-sx and mod-sx need rule-engine plumbing. If you find shared shape, flag it for extraction (don't extract yet — wait for mod-sx to start). - Commits: one feature per commit. Keep Progress log updated and tick boxes.
Architecture sketch
ACL declarations (SX) User query
│ │
▼ ▼
lib/acl/schema.sx lib/acl/api.sx
— subject sorts — (acl/permit? subj act res)
— resource sorts — (acl/explain subj act res)
— action sorts — (acl/audit subj act res :allowed?)
— fact schema │
│ ▼
▼ lib/acl/engine.sx
lib/acl/facts.sx — builds Datalog query
— actor(id, kind) — invokes lib/datalog/
— resource(id, kind) — extracts proof tree
— member_of(actor, group) │
— child_of(res, parent) ▼
— grant(actor, act, res) lib/acl/audit.sx
— deny (actor, act, res) — persistent decision log
— query API
Phase 1 — Direct grants
lib/acl/schema.sx— sorts: subject {user, group, role, service}, action, resource {page, post, thread, peer}lib/acl/facts.sx—actor,resource,grant,denypredicates as Datalog EDBlib/acl/engine.sx—(permit? subj act res db)reduces to Datalog querylib/acl/api.sx— public(acl/permit? ...)taking implicit current dblib/acl/tests/direct.sx— 15+ cases: direct grant, missing grant, explicit denylib/acl/scoreboard.{json,md}baselinelib/acl/conformance.shruns the suite
Phase 2 — Inheritance
member_of(actor, group)chain — group grants apply to members (transitive)child_of(res, parent)chain — parent grants apply to children (transitive)- role expansion — role contains list of (action, resource) tuples
- deny-overrides — explicit deny wins over inherited allow
lib/acl/tests/inherit.sx— 25+ cases: nested groups, deep resource trees, conflict resolution, deny precedence- document the deny-overrides choice in plan
deny-overrides policy (the choice)
Encoded as stratified negation: permit(S,A,R) :- eff_grant(S,A,R), not eff_deny(S,A,R). Both eff_grant and eff_deny inherit through the same
member_of (group/role) and child_of (resource) chains. Consequences:
- An explicit deny on the exact (S,A,R) defeats any inherited allow.
- A group-level or ancestor-resource deny inherits down and defeats a member's/descendant's grant — deny is authoritative across the closure, not only at the leaf. This is the fail-safe reading: the most permissive interpretation of "deny wins" would let a narrow grant escape a broad deny; we chose the opposite.
- Deny is dimension-scoped: a deny on (S, edit, R) never blocks (S, read, R).
Stratifiable because neither eff_grant nor eff_deny depends on permit;
permit sits in a strictly higher stratum. Termination is guaranteed —
recursion is only over EDB member_of/child_of (no function symbols), so
cyclic membership/containment reaches a fixpoint rather than looping (tested).
Phase 3 — Explanation + audit
(acl/explain subj act res)→{:allowed? T :proof <tree>}- proof tree extracts from Datalog's derivation
lib/acl/audit.sx— append-only decision log (in-memory + serializer for disk)(acl/audit-tail n)for recent decisionslib/acl/tests/explain.sx— proof correctness, audit completeness
proof reconstruction (the choice)
lib/datalog/ records derived facts but not provenance, so lib/acl/explain.sx
reconstructs the proof by goal-directed search over the saturated db: for a
ground goal, find the first ACL rule (in acl-rules order) whose body holds,
take the first dl-query solution binding the rest, recurse on each body
literal; negated literals become verified :neg-ok leaves. The Datalog
derivation graph is a DAG (a fact may hold many ways) — we pick ONE canonical
proof: first-rule, first-solution, with EDB/direct rules ordered first so
proofs bottom out quickly. A depth cap (64) guards pathological cyclic data.
acl-explain returns {:allowed? :proof :reason}; on denial :reason carries
the blocking eff_deny proof (explicit or inherited) when one exists, else nil
(no grant). Audit log is append-only with monotonic seq numbers (no wall-clock,
for determinism); acl-audit-decide! is the logged path, acl-permit? stays
pure.
Phase 4 — Federation
- peer trust facts —
peer(addr, kind),trust(peer, level) - delegated grants —
delegate(peer, actor, action, resource) - cross-instance permit chain — query asks local + queries trusted peers via fed-sx
- revocation propagation — fact retraction across federation
lib/acl/tests/fed.sx— federated grant chains (mock fed-sx transport in tests)
Progress log
- Phase 1 complete (24/24). ACL is a thin layer over
lib/datalog/:schema.sx— sorts (subject/resource kinds, well-known actions) + EDB predicate arity table +acl-fact-valid?validator. Schema is data, since Datalog is untyped.facts.sx—acl-actor/acl-resource-fact/acl-grant/acl-denyconstructors returning Datalog fact tuples.engine.sx— owns the rulesetacl-phase1-rulesand reduces decisions todl-query.acl-build-db=dl-program-data facts rules;acl-permit?= non-empty(permit S A R)query.api.sx—acl/load!rebuilds an implicit current db;acl/permit?queries it. (Slash-symbols likeacl/permit?parse fine as single tokens.)- deny-overrides encoded as
permit(S,A,R) :- grant(S,A,R), not deny(S,A,R). Stratifies cleanly becausedenyis EDB-only (no rule derives it). Verified: grant+deny on same (S,A,R) → denied. - Conformance:
conformance.conf(datalog preloads + acl modules) + thinconformance.shwrapper overlib/guest/conformance.sh. Scoreboard generated by the shared driver. - Shared-plumbing note (for eventual
lib/guest/rules/): thebuild-db = dl-program-data(facts, rules)+decide = non-empty ground queryshape is exactly what mod-sx (Prolog moderation) will also need. The reusable seam is engine.sx's two functions — facts→db and ground-query→bool — both pure pass-throughs to the rule engine. Not extracting yet (wait for mod-sx as second consumer per ground rules).
- Phase 2 complete (54/54, +30 inherit). Extended
acl-ruleswitheff_grant/eff_denyderived relations;member_ofcarries both group and role membership,child_ofcarries resource trees,role_grantconfers a role's (action,resource) capabilities. Direct grants unchanged (base case ofeff_grant), Phase 1 suite still green. Constructorsacl-member-of,acl-child-of,acl-role-grantadded; schema arity table extended. See the deny-overrides policy section above. Verified cyclic membership terminates.- Shared-plumbing update: the reusable seam is still just engine.sx's
facts -> db+ground-query -> bool. The inheritance rules are ACL-specific (group/resource/role vocabulary); mod-sx will have its own. So thelib/guest/rules/extraction stays at the build/decide level, not the ruleset level.
- Shared-plumbing update: the reusable seam is still just engine.sx's
- Phase 3 complete (89/89, +35 explain). Added
explain.sx(proof reconstruction, see policy section above),audit.sx(append-only log), and extendedapi.sxwithacl/explain/acl/audit/acl/audit-tail. No engine changes — explanation reads the same saturated db the decision uses.- Substrate gotcha: the host
=compares symbols by interned identity, which is unstable acrossdl-querysaturation/substitution within a single image — the same two structurally-equal symbol-lists compared=true once and false moments later in the REPL. Conformance runs in a fresh process per suite so it's deterministic there, but test assertions now use a name-basedacl-et-eq?(compare symbols viasymbol->string), matching the datalog suite'sdl-api-deep=?convention. Worth flagging to the kernel owners but out of acl scope. - Tooling note: sx-tree path-based edit tools (
sx_replace_node,sx_read_subtreewith a path) ignored the path argument in this worktree (always resolved to index 0 /[0,..]), in BOTH(a b c)and(a,b,c)forms.sx_write_file,sx_validate,sx_find_all,sx_summarise,sx_evalall work; used full-file rewrites instead of path edits throughout.
- Substrate gotcha: the host
Blockers
(loop fills this in)