# 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/**` and `plans/acl-on-sx.md`. Do **not** edit `spec/`,
  `hosts/`, `shared/`, `lib/datalog/**`, or other `lib/<lang>/`. You may **import**
  from `lib/datalog/` (its public API in `lib/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-tree` MCP 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

- [x] `lib/acl/schema.sx` — sorts: subject {user, group, role, service}, action,
  resource {page, post, thread, peer}
- [x] `lib/acl/facts.sx` — `actor`, `resource`, `grant`, `deny` predicates as Datalog
  EDB
- [x] `lib/acl/engine.sx` — `(permit? subj act res db)` reduces to Datalog query
- [x] `lib/acl/api.sx` — public `(acl/permit? ...)` taking implicit current db
- [x] `lib/acl/tests/direct.sx` — 15+ cases: direct grant, missing grant, explicit deny
- [x] `lib/acl/scoreboard.{json,md}` baseline
- [x] `lib/acl/conformance.sh` runs the suite

## Phase 2 — Inheritance

- [x] `member_of(actor, group)` chain — group grants apply to members (transitive)
- [x] `child_of(res, parent)` chain — parent grants apply to children (transitive)
- [x] role expansion — role contains list of (action, resource) tuples
- [x] deny-overrides — explicit deny wins over inherited allow
- [x] `lib/acl/tests/inherit.sx` — 25+ cases: nested groups, deep resource trees,
  conflict resolution, deny precedence
- [x] 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

- [x] `(acl/explain subj act res)` → `{:allowed? T :proof <tree>}`
- [x] proof tree extracts from Datalog's derivation
- [x] `lib/acl/audit.sx` — append-only decision log (in-memory + serializer for disk)
- [x] `(acl/audit-tail n)` for recent decisions
- [x] `lib/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-deny`
    constructors returning Datalog fact tuples.
  - `engine.sx` — owns the ruleset `acl-phase1-rules` and reduces decisions to
    `dl-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 like `acl/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 because `deny` is EDB-only (no rule derives it). Verified:
    grant+deny on same (S,A,R) → denied.
  - Conformance: `conformance.conf` (datalog preloads + acl modules) + thin
    `conformance.sh` wrapper over `lib/guest/conformance.sh`. Scoreboard
    generated by the shared driver.
  - **Shared-plumbing note (for eventual `lib/guest/rules/`):** the
    `build-db = dl-program-data(facts, rules)` + `decide = non-empty ground query`
    shape 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-rules` with
  `eff_grant`/`eff_deny` derived relations; `member_of` carries both group and
  role membership, `child_of` carries resource trees, `role_grant` confers a
  role's (action,resource) capabilities. Direct grants unchanged (base case of
  `eff_grant`), Phase 1 suite still green. Constructors `acl-member-of`,
  `acl-child-of`, `acl-role-grant` added; 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
    the `lib/guest/rules/` extraction stays at the build/decide level, not the
    ruleset level.
- **Phase 3 complete (89/89, +35 explain).** Added `explain.sx` (proof
  reconstruction, see policy section above), `audit.sx` (append-only log), and
  extended `api.sx` with `acl/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* across `dl-query` saturation/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-based `acl-et-eq?` (compare symbols via `symbol->string`), matching the
    datalog suite's `dl-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_subtree` with 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_eval` all work; used full-file rewrites instead of path edits throughout.

## Blockers

(loop fills this in)
