🦅 EACL: Enterprise Access ControL is a ReBAC Authorization library inspired by SpiceDB, built in Clojure and backed by Datomic Pro, Datahike, Datalevin or DataScript.
93
stars
717
commits
Clojure
primary language
Sep 14, 2026
updated
EACL is a situated ReBAC authorization library inspired by SpiceDB, built in Clojure and backed by Datomic Pro, Datahike, DataScript, or a qualified embedded Datalevin deployment.
| Authentication (AuthN) | Authorization (AuthZ) |
|---|---|
Who are you?, i.e. who is the <subject>? | What can <subject> do? |
EACL is concerned with fast & correct authorization, i.e. permissions.
EACL permissions are just data that co-exist with your application data – hence, situated, which offers several benefits, notably:
🦅 EACL is pronounced "EE-kəl", like "eagle" with a k because as a situated AuthZ system, EACL monitors the situation 🥁.
You need the Clojure CLI and Java 25 or newer for this published build. The Datomic Peer dependency is included; this memory example needs no separate Datomic server or account.
Create a directory with this deps.edn:
{:deps {dev.eacl/eacl-datomic {:mvn/version "8.0.0-RC-2026-09-12"}}}
This is a release candidate (a preview release). See
Clojars for published versions.
Start a REPL in that directory with clojure -M, then evaluate:
(require '[datomic.api :as d]
'[eacl.core :as eacl]
'[eacl.datomic.core :as eacl.datomic]
'[eacl.datomic.schema])
(def uri (str "datomic:mem://eacl-" (random-uuid)))
(d/create-database uri)
(def conn (d/connect uri))
;; Install EACL's schema, then your application's schema.
(eacl.datomic.schema/install! conn)
@(d/transact conn
[{:db/ident :app/id
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity}
{:db/ident :document/title
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one}])
(def acl (eacl.datomic/make-client conn
{:object-id->lookup-ref (fn [id] [:app/id id])
:entid->object-id (fn [db eid] (:app/id (d/entity db eid)))}))
(eacl/write-schema! acl
"definition user {}
definition document {
relation viewer: user
relation owner: user
permission view = viewer + owner
permission share = owner
}")
@(d/transact conn
[{:app/id "alice"}
{:app/id "bob"}
{:app/id "report" :document/title "Report"}])
(def alice (eacl/spice-object :user "alice"))
(def bob (eacl/spice-object :user "bob"))
(def report (eacl/spice-object :document "report"))
(eacl/create-relationship! acl alice :viewer report)
(eacl/can? acl alice :view report) ; true
(eacl/can? acl bob :view report) ; false
(mapv :id (:data (eacl/lookup-resources acl
{:subject alice :permission :view :resource/type :document :first 10})))
;; => ["report"]
Give each user and document a unique, stable ID owned by your application.
This example uses :app/id; you do not need to put application IDs in EACL's
internal schema.
The default token keys are process-local. For keys that survive restarts or work across load-balanced Peers, see security keys. Leave cache options out to use the defaults.
To delete a secured entity in Datomic, use :eacl.fn/retractEntity. Install
the function during database setup; it removes the entity and its relationships
in one transaction:
(require '[eacl.datomic.safe-retraction])
(eacl.datomic.safe-retraction/install! conn)
@(d/transact conn [[:eacl.fn/retractEntity [:app/id "report"]]])
(eacl/can? acl alice :view report) ; false
Do not use ordinary entity retraction while EACL relationships still refer to the entity. See safe deletion.
Yes.
| Database | Module | Storage |
|---|---|---|
| Datomic Pro | eacl-datomic | DynamoDB (recommended), Cassandra or SQL |
| Datahike | eacl-datahike | DynamoDB, S3 (cheaper, but slower), LMDB, SQL, Redis, GCS or IndexedDB. |
| DataScript | eacl-datascript | In-memory, but can persist to disk or add a SQL adapter. No time-travel. |
| Datalevin | eacl-datalevin — implemented, publication pending | Embedded LMDB; storage-enforced write policy and ordered generations. |
S3-backed Datahike is attractive for infrequently-accessed apps, because you can trade latency for reduced storage cost, and it supports serverless to reduce running Peer / Transactor costs.
Note: DataScript and Datalevin have no at-exact-snapshot semantics. Datahike requires a retained commit graph or temporal history to support exact snapshots. Datalevin uses scalar ordered-generation proofs from the maintained fork, so completed answers may reuse across unrelated forward revisions without historical selection.
t in [e a v t].This README is too long & too technical, so I am working to simplify it and break it out into area-specific documents. Despite my best attempts, EACL has become a large project.
[!WARNING] EACL is used in production, but under active development. The examples use
8.0.0-RC-2026-09-12, a release candidate. See Clojars for published versions.
Consider 10,000 online users: how often should clients re-query to keep their UIs up-to-date?
tx_rate(num_users, tx_rate_per_user).num_users * tx_rate(num_users, tx_rate_per_user) * queries_per_view quickly becomes a Read Amplification problem that can dramatically lower Peer performance, or require horizontal scaling.What if you could compute exactly which users are affected by every DB write and notify only those clients, in real-time?
d/listen, so you can inspect tx-data for every transaction, and call EACL's efficient eacl/lookup-subjects to retrieve a list of users who can see the affected resource, filter it down to online users and notify them. Alternatively, call eacl/can? for each online user in parallel.EACL implements an idiomatic IAuthorization protocol for each supported backend, which extends the SpiceDB gRPC API.
EACL can efficiently answer questions like, "Can <subject> do <permission> on <resource>?"
(eacl/can? acl subject permission resource ?consistency)
=> true | false
; e.g.
(eacl/can? acl (->user "alice") :view (->server "server1") eacl.spicedb.consistency/fully-consistent)
=> true | false
If you need cache provenance, use check-permission instead of can?, otherwise they are equivalent:
(eacl/check-permission acl
{:subject subject
:permission permission
:resource resource
:consistency eacl.spicedb.consistency/fully-consistent})
=> {:allowed? true, :cached? boolean, :cache-basis ...}
"Which <resources> does <subject> have <permission> on, as-of <10 seconds ago, or newer>?"
(eacl/lookup-resources acl
{:subject subject
:permission permission
:resource/type resource-type
:first page-size ; or :last page-size
:consistency (eacl.spicedb.consistency/at-least-as-fresh token-10s-ago)})
=> {:data [{:type :product :id "product-1"}
{:type :product :id "product-7"}
...
{:type :product :id "product-63"}]
:page-info ...
:cached? true|false>
...}
The :consistency argument is optional. The default is minimize-latency,
which means locally-consistent to the Peer. at-least-as-fresh selects an
appropriate backend basis directly and requires no cache-checkpoint option.
(def token-10s-ago (eacl.datomic/zed-token-at-least-seconds-ago acl 10))
(eacl/lookup-resources acl
{:subject (->user "alice")
:permission :view
:resource/type :product
:first 50
:consistency (eacl.spicedb.consistency/at-least-as-fresh token-10s-ago)})
=> {:data [{:type :product :id "product-1"}
{:type :product :id "product-7"}
...
{:type :product :id "product-63"}]
:page-info ...
:cached? true|false>
...}
(eacl/lookup-resources acl query)
=> {:data [resources...] :page-info {...} :cached? boolean :cache-basis ...}
(eacl/lookup-subjects acl query)
=> {:data [subjects...] :page-info {...} :cached? boolean :cache-basis ...}
SpiceDB does not support counting (you must traverse in pages), but EACL does.
(eacl/count-resources acl query)
=> {:count 42, :limit -1, :cached? boolean, :cache-basis ...}
(eacl/count-subjects acl query)
=> {:count 7, :limit -1, :cached? boolean, :cache-basis ...}
Without :count-limit, :limit is -1 and the count operation exhausts the
result set. Pass :count-limit n to bound work. The result then includes
:truncated?; true means at least one additional result exists.
Note: the default :limit will soon change to 50k instead of -1 (infinite), because high count-limits can exhaust Peers and trigger costly I/O from storage, esp. in recursive schemas.
An EACL client reads the current database. A snapshot holds one database version and one evaluation time, so several reads see the same state.
(eacl/with-snapshot [s (eacl/snapshot acl)]
[(eacl/can? s alice :view report)
(eacl/lookup-resources s
{:subject alice :permission :view :resource/type :document})])
with-snapshot releases the snapshot when the body finishes, including when
it throws. If you retain one manually, call eacl/release! when finished.
A read after release raises :eacl/snapshot-released. Datalevin snapshots
must be used and released on the platform thread that acquired them.
A retained snapshot keeps its captured time. An expiring share can therefore still grant access in an old snapshot after its deadline. Use the client, or acquire a new snapshot, when checking current access.
To select a historical version, pass a consistency descriptor to
eacl/snapshot. The backend must support that selection. A consistency
option on an existing snapshot only checks that it meets the requested
condition; it cannot move the snapshot to another version.
You can also preview changes without committing them:
(eacl/with-snapshot [base (eacl/snapshot acl)]
(let [tx (eacl/tx-relationship base :delete alice :viewer report)]
(eacl/with-snapshot [preview (eacl/with base tx)]
(eacl/can? preview alice :view report))))
Use eacl/with-schema to preview a permission schema. Preview results do not
become shared cached answers. Use these public helpers instead of wrapping a
raw Datomic d/with or d/filter value in an implementation-level client.
See atomic writes and the backend guide for supported snapshot operations and backend limits.
EACL's situated philosophy aligns with that of Datomic: if Data is local and Query is local, perception can scale, so why wait for an external AuthZ system to compute permissions?
As long as the DB basis is recent enough for our consistency demands, we can avoid a network hop. This yields several benefits:
Reduced Latency: EACL avoids a network hop to an external AuthZ system, but depending on consistency semantics, we can await new data from the Transactor if the Peer has fallen behind.
Consider that to leverage at_least_as_fresh consistency semantics in SpiceDB for LookupResources, you need to:
In EACL, since Peers are locally-consistent, as long as database snapshot S (valid @ time T) is locally available, we can query immediately without a network hop, or reuse cached answers derived from S or newer.
Bonus: Relationships are just data, so permission graph traversal can improve database cache locality for faster entity hydration before display.
Since you have to hit the DB anyway to show anything useful, we might as well compute permissions on the Peer, which has the database – which is what EACL does.
Time Travel: Unlike Spice cursors, EACL cursors do not expire (and are encrypted for UI exposure) unless you specify a TTL, so we can reconstruct selected snapshots if the backend retains it.
at-exact-snapshot.Consistency: Syncing to an external system introduces eventual consistency. With situated AuthZ, queries are at least locally-consistent as-of time T.
Simple Syncing: Relationships are just 3-tuples of [subject relation resource], so there is no impedance mismatch when syncing to SpiceDB at scale.
Real-time UI updates for materialized views: it is cheap to compute the subset of online clients that need to re-query while avoiding query amplification due to a busy Transactor.
Situated is faster for small (~1k-100k relationships) to medium-applications (~1M-10M Relationships):
Application & Authorization Data live together in harmony. In my testing with small to medium-sized workloads, EACL is as good, or faster than SpiceDB, owing to reduced latency from its situated design, but no EACL benchmarks are published at this time (benchmarks are a tricky business).
One less thing to deploy & sync Relationships to.
Note that EACL has Limitations compared to SpiceDB.
In a ReBAC system like EACL, Subjects & Resources are related via Relationships.
A Relationship is just a 3-tuple of [subject relation resource], e.g.
[user1 :owner account1] means subject user1 is the :owner of resource account1, and[account1 :account product1] means subject account1 is the :account for resource product1.EACL models the 3 core concepts in its permission graph:
What do they look like?
{:keys [type id]}, e.g.
(spice-object :user "user-1") => {:type :user, :id "user-1"},(spice-object :product "product-5") => {:type :product, :id "product-5"}(def ->user (partial spice-object :user)), so that(->user "user-1") => {:type :user, :id "user-1"}.Relations and Permissions:
Relation defines how a Subject & a Resource can be related via a Relationship.Permission defines which permissions are granted to a Subject via a chain of Relationships between subjects & resources.
Relation, i.e. (Relationship subject relation resource)
(Relationship (->user alice) :owner (->account "acme")) means that,
(->user "alice") is the Subject,:owner is the name of the Relation (as defined by schema)(->account "acme") is the ResourceEACL supports four consistency modes named after SpiceDB's Consistency Semantics.
These modes allow us to trade consistency for speed (reduced latency), by enabling cache reuse. These modes affect which database snapshot S and in-memory cache segments C are allowed to participate in answering a permission query:
minimize-latency means locally-consistent to the Peer as-of now, i.e. (d/db conn) + valid cache segments.at-least-as-fresh uses query against a local snapshot S that is as fresh or fresher than T, i.e. (d/sync conn T) which will only block if Peer is behind S.at-exact-snapshot always calls (d/as-of db T) and reuses cache segments valid for basis B >= S.fully-consistent means locally-consistent after a (d/sync conn) (blocking call), so behaves like minimize-latency but Peer is fully up-to-date.For detailed descriptions, Continue to Consistency Modes →
Core model:
Consider that in a fast-moving database, (d/db conn) is always moving, so a naive cache would invalidate after every write. EACL uses dependency-proofs to reuse cached segments that are proven to be unaffected by unrelated writes.
For read-only actions, we might be fine with reusing cached answers that are a few seconds old and avoid expensive computation.
For example, when a YouTube video with millions of views is unpublished, it is probably fine to keep serving it for a few seconds instead of recomputing access on every view. Here's how to do it in EACL:
(def token-10s-ago (eacl.datomic/zed-token-at-least-seconds-ago acl 10))
(eacl/can? acl (->user "alice") :view (->video "my-video")
(eacl.spicedb.consistency/at-least-as-fresh token-10s-ago))
This also works for lookups, like listing video resources:
(eacl/lookup-resources acl
{:subject (->user "alice")
:permission :view
:resource/type :video
:consistency (eacl.spicedb.consistency/at-least-as-fresh token-10s-ago)})
However, for destructive actions, e.g. permanently deleting a video, we will want to make 100% sure the user is allowed to do that. For that we can use fully-consistent:
(eacl/can? acl (->user "alice") :delete (->video "my-video")
eacl.spicedb.consistency/fully-consistent) ; this will block on (d/sync conn)
Most of the time, we will use eacl.spicedb.consistency/minimize-latency, which uses what is locally-consistent to the Peer:
(eacl/can? acl (->user "alice") :view (->video "my-video")
eacl.spicedb.consistency/minimize-latency)
db value as if derived from (d/as-of (d/db conn) T), where
(d/db conn),(d/sync conn).(d/as-of db <T-10 seconds>), orT+2 seconds, which is relevant in multi-Peer systems.Even in a fast-moving DB, EACL will reuse cache segments that are unaffected by unrelated mutations.
minimize-latency (the default and simplest) is fast and locally-consistent to the Peer, i.e. (d/db conn).
at-least-as-fresh uses @(d/sync conn token-revision) which can block if the Peer is behind. It does not block if the Peer has revision snapshot S.
<10 seconds ago> or fresher.<subject> view this <video> as-of <30 seconds ago, or fresher>?"T is newer than the central Transactor has seen, EACL will return an error.fully-consistent blocks on @(d/sync conn) before taking a snapshot S from (d/db conn), because the Peer could be behind, so it can be slow:
<subject> delete this <video> as-of <right now>?"at-exact-snapshot always time-travels with (d/as-of db T):
d/as-of can be expensive, so you will typically only use this if you need historical data.T is in the future (relative to Peer), EACL may block on (d/sync conn T) before (d/as-of db T).
T is newer than the Transactor has seen.T.at-exact-snapshot is not supported by DataScript or Datalevin. Both fail closed instead of emulating history.The EACL engine and cache benefit from monotonic txId (transaction IDs), i.e. the t in [e a v t], so cache segments are keyed by basis T. The engine will only use cache segments valid @ T.
EACL cursors encode DB basis, schema version and related cache proofs.
Not every mode is supported by every backend, because some modes rely on time travel over full history.
Unsupported modes by backend will return an error. Refer Consistency and ZedTokens.
EACL co-exists with your data in Datomic, Datahike, DataScript, or Datalevin. As a result, EACL installs and maintains some attributes in your data store, all of which are prefixed by :eacl*.
Presently, EACL Relationships are stored in history to support auditability, d/as-of & at-exact-snapshot semantics, but in a future version of EACL, history could be optional to save on storage, but then you lose time travel & auditability. For many applications that only care about permissions as-of "now", this would be acceptable.
The EACL-specific attributes are detailed below.
EACL Relationships are light by virtue of being stored directly on entities as two tuples:
:eacl.v8.relationship/subject-type+relation+resource-type+resource+qualifier:eacl.v8.relationship/resource-type+relation+subject-type+subject+qualifierTo retract an entity and its Relationships, use :eacl.fn/retractEntity, an optional Transactor function you can install.
If you only want to retract Relationships, call eacl/delete-relationships!.
To retract an entity and its Relationships, you may call eacl/delete-relationships! followed by :db.fn/retractEntity, but this will write two transactions to the tx-log, which is fine, but not ideal and adds noise to the Transactor, so prefer :eacl.fn/retractEntity for full entity retractions.
If you call :db.fn/retractEntity without eacl/delete-relationships!, you will leave ghost Relationship tuples lying around on the contra-object (subject or resource), so it's safer to use :eacl.fn/retractEntity for secured entities and reduce transactor noise.
EACL's contract with you is that you MUST use the EACL APIs to maintain Relationships so we can guarantee cache coherence and a clean database. If you mess with EACL's data structures, it becomes your problem.
But you will probably forget one day, so there are helpers to fined & clean up ghost tuples :). Refer Deleting a Secured Entity.
:eacl.relation/resource-type:eacl.relation/relation-name:eacl.relation/subject-type:eacl.relation/resource-type+relation-name+subject-type:eacl/relation-version tracks cache coherence in Datomic, Datahike, and DataScript. Datalevin uses the scalar :eacl.datalevin/relation-generation; each is advanced by relevant Relationship writes.:eacl/id:eacl.permission/resource-type:eacl.permission/permission-name:eacl.permission/expression-payload:eacl.permission/resource-type+permission-nameThe canonical payload contains its expression-format version. EACL does not store a second format field, a content digest, a policy digest, admission limits, or expression/DAG metrics. Those values either duplicate the payload or describe one client's resource-admission policy rather than permission meaning. Node counts, depth, fan-in, encoded size, normalized DAG counts, word counts, and checkpoint weights are derived from the payload and may be cached inside the client.
:eacl/id identifies EACL's internal Relations and Permissions. Use a separate application attribute, such as :app/id, for your users and resources.:eacl/schema-string stores a valid schema string was written via eacl/write-schema!.:eacl/schema-version track the schema revision in Datomic Pro.:eacl/schema-generation and :eacl/schema-write-fence track schema writes in Datahike and DataScript. Datalevin uses scalar :eacl.datalevin/schema-generation and :eacl.datalevin/schema-write-fence values in its native max-tx domain.:eacl/storage-version identifies Relationship storage ABI 8 across the bundled backends (five-slot endpoint pairs).:eacl/permission-storage-version identifies Datomic's canonical permission representation (version 8).:eacl.fn/assert-relation-unused is a Transactor function in Datomic that guards removing Relations with active Relationships (to avoids orphaned Relationships).cljs-cache LRU. Custom
cache providers are intentionally unsupported because they
cannot participate in EACL's private lifecycle and proof contracts.Let's design a simple permission schema for a Google Drive clone using the SpiceDB schema DSL:
definition user {}
definition folder {
relation owner: user ; a folder as an ownerh
relation viewer: user ; a folder can have viewers, who may not be the owner
relation parent: folder ; a folder can have parent, i.e. nested file system
permission view = owner + viewer + parent->view ; can view a folder as the owner, or view its parent
permission edit = owner + parent->edit ; you can edit a folder if you are the owner, or can edit its parent
}
definition document {
relation owner: user ; a document has an owner
relation viewer: user ; a document can have viewers who aren't the owner
relation folder: folder ; a document belongs in a folder
permission view = owner + viewer + folder->view ; you can view a document if you own it, ar a viewer or can view the parent folder
permission edit = owner + folder->edit ; you can edit a document if own it, or if you can edit the folder
}
Basically, a user owns folders & documents; documents go in a folder and folders can nest, i.e. folders have children – it's a file system:
:view a document if you are the owner, a viewer, or if you can view the folder it's in.:view a folder if you can view a parent folder (recursive schema).:edit a document if you are the owner, or if you can edit the folder (or any of its parents).You can share documents or folders with other users by making them a viewer, i.e. by adding (Relationship (->user "bob") :viewer (->folder "my-folder")), to share my-folder with user "bob".
Because of the recursive view permission, user bob will be able to :view any nested files or folders under the folder, my-folder, that you shared with them.
EACL supports multiple backends. Each adapter will bring in the shared EACL engine:
;; Datomic Pro
{:deps {dev.eacl/eacl-datomic {:mvn/version "8.0.0-RC-2026-09-12"}}}
;; Datahike
{:deps {dev.eacl/eacl-datahike {:mvn/version "8.0.0-RC-2026-09-12"}}}
;; DataScript
{:deps {dev.eacl/eacl-datascript {:mvn/version "8.0.0-RC-2026-09-12"}}}
;; Datalevin (coordinate reserved; publication remains gated)
{:deps {dev.eacl/eacl-datalevin {:mvn/version "8.0.0-SNAPSHOT"}}}
;; Core-only consumers and backend authors (you typically won't need this)
{:deps {dev.eacl/eacl {:mvn/version "8.0.0-RC-2026-09-12"}}}
For source development, clone the full repository, prepare the generated core runtime as described below, then keep the same library coordinate and use :local/root`. The backend module resolves the sibling core module:
{:deps {dev.eacl/eacl-datomic
{:local/root "/absolute/path/to/eacl/core/modules/eacl-datomic"}}}
Source consumers who compile the EACL kernel locally need the Clojure CLI, Node.js, and the repository-pinned Dafny, Apalache, and TLA+ tools. Prepare the generated JVM and browser runtimes before using a :local/root dependency:
cd modules/eacl
# Default Java target
clojure -T:build prep
# Example Java 17 target
clojure -T:build prep :java-release 17
clojure -T:build jar :java-release 17
Pass the same :java-release to prep and jar or install. The generated
kernel defaults to Java 25 bytecode and may be compiled for Java 8 through 26,
but the complete JVM EACL module requires Java 11 or newer because its Caffeine
cache dependency does. Java 17 is EACL's supported production runtime floor.
See formal/README.md for tool versions and the full
verification commands.
For permission operators, precedence, stratification, limits, ordering, cursors,
cache behavior, and measured performance, see the permission set-algebra
guide. For module selection, current
capability differences, cache mutation rules, and recursive controls, see the
backend guide. Datalevin setup,
mandatory lifecycle/watermark inputs, write-policy boundary, and publication
status are documented in the eacl-datalevin module
README. Backend authors should also read the
adapter boundary and basis-source
migration guide.
To create a Relationship, first define your schema using eacl/write-schema!:
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
relation viewer: user
relation active: user
relation banned: user
permission admin = owner - banned
permission view = (owner + viewer) & active
}
definition product {
relation account: account
permission edit = account->admin
permission view = account->view
}")
This schema defines:
account, which can have owner, viewer, active, and banned users, with
admin permission granted to non-banned owners.product belongs to an account, with edit permission for account
admins and view permission for active account owners or viewers.In the schema DSL, + is union, & is intersection, and - is directed set
exclusion. Parentheses are supported. + binds more tightly than &, which
binds more tightly than -; repeated exclusion associates from the left.
(eacl/read-relationships acl filters)
=> {:data [relationships...]
:page-info {...}
:cached? boolean
:cache-basis ...}
(eacl/write-relationships! acl updates)
=> {:zed/token "eacl_z4_..."}
where updates is a collection of RelationshipUpdate records:
(eacl/->RelationshipUpdate operation relationship),
or, maps {:operation op :relationship rel}, and
operation is one of :create, :touch or :delete.
A bare [operation relationship] vector is rejected as an unsupported update.
Schema names are validated: unknown definitions, relations or bad subject types will fail with :eacl/unknown-definition / :eacl/unknown-relation-or-permission.
Relationship Conflicts?
:create will fail with :eacl/relationship-conflict if the same if the same Relationship already exists.
:creates of one relationship produce exactly one success.:touch is idempotent. Repeating one operation for the same relationship inside a batch has the same outcome as submitting it once (:create still conflicts when the relationship existed before the batch); mixing different operations for the same resolved relationship throws :eacl/invalid-relationship-update-batch before submission.(eacl/create-relationships! acl relationships) simply calls write-relationships! with :create operation.(eacl/delete-relationships! acl relationships) simply calls write-relationships! with :delete operation.(eacl/delete-object! acl object) => {:zed/token "eacl_z4_...", :retracted-datoms n} is a convenience helper that removes every relationship touching object, in both directions. n counts relationship datoms actually retracted by the committed transactions. On Datomic the retractions are committed in batches of 1,000 (a concurrent reader can observe a partially deleted object between batches); on DataScript and Datahike they are one atomic transaction. Consumers are expected to delete relationships before retracting a secured entity — see Deleting a Secured Entity.All list APIs use the v8 Relay pagination contract:
:first and optionally :after.:last and optionally :before.:page-info with :start-cursor, :end-cursor, :has-next-page?, and :has-previous-page?.Use eacl/check-permissions for several decisions on one snapshot. To combine
a permission check with a direct relationship filter, choose:
read-relationships with :authorization when the relationship set is smaller.lookup-resources with :resource/relationship, or lookup-subjects with
:subject/relationship, when the authorized set is smaller.Both routes can return a short or empty page with :has-next-page? true and
:bounded? true. Continue with the returned cursor. Applications must also
authorize access to sharing metadata.
See aggregate authorization for examples.
Every bounded read accepts an optional per-request :cancellation-token in
addition to :timeout-ms. Create and cancel the token through the public EACL
API:
(let [token (eacl/cancellation-token)]
;; Pass `token` to the HTTP/request owner before starting the read.
(future
(eacl/lookup-resources acl
{:subject (eacl/spice-object :user "alice")
:permission :view
:resource/type :document
:first 100
:cancellation-token token}))
(eacl/cancel! token))
Cancellation is cooperative and best-effort. EACL checks it at the same
orchestration, cursor, cache, and reducer-transition boundaries (one check per
engine step, which covers each adapter command) as the absolute deadline and, when observed before completion, throws
:eacl.execution/cancelled without returning a partial answer. A synchronous
adapter call already in progress must return before the next check, and a
completed result may win a race with a late cancellation. Applications must
therefore keep the server deadline and bounded admission control; interrupting
a worker thread is not a substitute. The token is execution-only and is
excluded from cache, continuation, and authenticated cursor identity. One
token belongs to one logical request.
(eacl/write-schema! acl schema-string) parses a SpiceDB schema DSL string, validates it, computes deltas against existing schema, checks for orphaned relationships, and transacts changes atomically.(eacl/read-schema acl) returns the current schema as a map of {:relations [...] :permissions [...]}.All schema changes must use eacl/write-schema!. If an application changes
the authorization schema directly, follow the recovery procedure in
Caching before resuming authorization traffic.
Datomic and Datahike consumers upgrading a released v7 database must run the backend's explicit permission-only v7-to-v8 migration, followed by the Relationship storage 7-to-8 migration, before constructing an ordinary v8 client. Permission storage remains version 8. Storage 8 uses a nullable qualifier reference in slot five for Caveats and expiring Relationships.
Expansion accepts exactly :resource, :permission, and the optional :consistency, :timeout-ms, and :cancellation-token keys:
(eacl/expand-permission-tree acl
{:resource (eacl/spice-object :document "readme")
:permission :view
:consistency eacl.spicedb.consistency/fully-consistent
:timeout-ms 5000})
=>
{:expanded-at "eacl_z4_..."
:tree-root
{:expanded-object {:type :document :id "readme"}
:expanded-relation :view
:intermediate
{:operation :union
:children
[{:expanded-object {:type :document :id "readme"}
:expanded-relation :viewer
:leaf {:subjects [{:type :user :id "alice"}]}}]}}}
A node contains exactly one of :leaf or :intermediate. Permission and
arrow boundaries remain visible; expansion is shallow in the SpiceDB sense,
so leaves contain subjects found by direct relation scans rather than a
flattened effective-membership set. To decide whether a subject has the
permission, use can?; do not infer authorization by flattening a tree.
Child and subject vector order is non-semantic and may differ by backend. Empty branches and duplicate paths are preserved. Compare trees as annotated topology with child/subject multisets when order is irrelevant. The exact supplied root ID is retained, while scanned IDs are converted with the selected client's object-ID codec.
The response tree and :expanded-at token are derived from the same selected
immutable snapshot. Replay the token with
(eacl.spicedb.consistency/at-exact-snapshot (:expanded-at response)) only on a backend
that advertises exact historical selection; otherwise use it as an
at-least-as-fresh causal floor. Unsupported consistency, unavailable history,
deadlines, unknown root relations or permissions, cycles, codec failures,
adapter-contract failures,
and structural limits produce typed all-or-error failures—no lazy or partial
tree is returned.
Clients accept positive exact-integer :permission-tree-limits overrides.
They are configuration-only, not request keys:
(eacl.datascript.core/make-client conn
{:permission-tree-limits
{:max-depth 50
:max-schema-components 100000
:max-relationship-values 100000
:max-tree-nodes 100000
:max-leaf-subjects 100000}})
Every bundled backend uses the same portable expansion kernel. Expected backend differences include supported consistency modes, historical retention, native scan order, and configured identity conversion.
The primary API call is can?, e.g.
(eacl/can? acl subject permission resource)
=> true | false
The other primary API call is lookup-resources, e.g.
(def page1 (eacl/lookup-resources acl
{:subject (->user "alice")
:permission :view
:resource/type :server
:first 2})) ; defaults to 1000.
page1
=> {:data [{:type :server :id "server-1"}
{:type :server :id "server-2"}]
:page-info {:start-cursor "..."
:end-cursor "..."
:has-next-page? true
:has-previous-page? false}
:cached? boolean
:cache-basis ...}
To query the next page, pass the :end-cursor from page1 as :after:
(def page2 (eacl/lookup-resources acl
{:subject (->user "alice")
:permission :view
:resource/type :server
:first 2
:after (get-in page1 [:page-info :end-cursor])}))
page2
=> {:data [{:type :server :id "server-3"}
{:type :server :id "server-4"}]
:page-info {:start-cursor "..."
:end-cursor "..."
:has-next-page? true
:has-previous-page? true}
:cached? boolean
:cache-basis ...}
To go back from page2, pass its :start-cursor as :before with :last:
(eacl/lookup-resources acl
{:subject (->user "alice")
:permission :view
:resource/type :server
:last 2
:before (get-in page2 [:page-info :start-cursor])})
Forward and backward pages return results in the same order for one fixed query and authenticated cursor walk. Permission lookups use the sealed plan's stable first-discovery order, and relationship reads use backend tuple-index order. These are pagination orders, not a global, cross-backend, or domain sort order. Backward pagination returns the previous window; it does not reverse the result order.
For Clojure/JVM applications backed by Datahike, add the Datahike adapter dependency to your deps.edn file:
{:deps {dev.eacl/eacl-datahike {:mvn/version "8.0.0-RC-2026-09-12"}}}
(ns my-eacl-datahike-project
(:require [datahike.api :as d]
[eacl.core :as eacl]
[eacl.datahike.core :as eacl.datahike]))
; Create an in-memory Datahike database and install EACL's Datahike schema:
(def conn (eacl.datahike/create-conn
[{:db/ident :app/id :db/valueType :db.type/string
:db/cardinality :db.cardinality/one :db/unique :db.unique/identity}]))
; Make an EACL client that satisfies the `IAuthorization` protocol:
(def acl (eacl.datahike/make-client conn
{:object-id->lookup-ref (fn [id] [:app/id id])
:entid->object-id (fn [db eid] (:app/id (d/entity db eid)))}))
; Write your permission schema using SpiceDB schema DSL:
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
permission admin = owner
}")
; Transact application entities with unique `:app/id` values:
(d/transact conn
[{:app/id "user-1"}
{:app/id "account-1"}])
; Create a Relationship between existing entities:
(eacl/create-relationship! acl
(eacl/spice-object :user "user-1")
:owner
(eacl/spice-object :account "account-1"))
; Run a Permission Check with `can?`:
(eacl/can? acl
(eacl/spice-object :user "user-1")
:admin
(eacl/spice-object :account "account-1"))
; => true
EACL-created Datahike databases enable :keep-history? true by default so
exact tokens and cursors survive ordinary commit-record cutoff collection.
Pass {:keep-history? false} to create-conn only when lower write/storage
amplification is worth making exact reconstruction conditional on retained
commit records.
For server-side or browser demos, use the DataScript adapter:
{:deps {dev.eacl/eacl-datascript {:mvn/version "8.0.0-RC-2026-09-12"}}}
The example below runs on the JVM. For ClojureScript, also add the cache fork to your application's dependencies; Maven cannot declare this Git dependency:
com.github.theronic/cljs-cache
{:git/url "https://github.com/theronic/cljs-cache.git"
:git/sha "4143cc036446a47f0c6dfd9f8dde90363835051c"}
(ns my-eacl-datascript-demo
(:require [datascript.core :as ds]
[eacl.core :as eacl]
[eacl.datascript.core :as eacl.datascript]))
(def conn (eacl.datascript/create-conn {:app/id {:db/unique :db.unique/identity}}))
(def acl (eacl.datascript/make-client conn
{:object-id->lookup-ref (fn [id] [:app/id id])
:entid->object-id (fn [db eid] (:app/id (ds/entity db eid)))}))
(ds/transact! conn
[{:db/id -1 :app/id "user-1"}
{:db/id -2 :app/id "account-1"}])
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
permission admin = owner
}")
(eacl/create-relationship! acl
(eacl/spice-object :user "user-1")
:owner
(eacl/spice-object :account "account-1"))
(eacl/can? acl
(eacl/spice-object :user "user-1")
:admin
(eacl/spice-object :account "account-1"))
; => true
EACL parses a documented subset of the SpiceDB schema DSL to define your authorization model. Use eacl/write-schema! to parse, validate, and transact your schema:
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
permission admin = owner
permission update = admin
}
definition product {
relation account: account
permission edit = account->admin
}")
write-schema! validates your schema and provides informative error messages. An invalid schema throws and nothing is transacted:
definition/relation declarations throw. // and /* */ comments are supported.eacl/write-schema! rejects replacing a non-empty schema with zero definitions. The backend schema namespaces expose a lower-level {:allow-empty-schema? true} option for an intentional wipe; direct use must also follow the cache-recovery rules because it bypasses the EACL client.When you call write-schema! with a modified schema, EACL:
Let's model the following SpiceDB schema in EACL:
definition user {}
definition account {
relation owner: user
}
We define two resource types, user & account, where any user subject can be the :owner of an account resource.
A Relationship is just a 3-tuple of [subject relation resource]:
(eacl/->Relationship (->user "alice") :owner (->account "acme"))
Let's add a direct permission to the schema for account resources:
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
permission update = owner
}")
Here, permission update = owner means any user who is an :owner of an account will have the update permission for that account.
At this point, all permissions checks via eacl/can? will return false, because there are no Relationships defined:
(eacl/can? acl (->user "alice") :update (->account "acme"))
=> false
What happens when we create some Relationships between users & accounts?
In EACL, Relationships are expressed as 3-tuples of [subject relation resource] using the ->Relationship helper, e.g. user alice is an :owner of acme account:
(eacl/->Relationship (->user "alice") :owner (->account "acme"))
Now let's create a Relationship between a user subject and an account resource using eacl/create-relationships!:
(eacl/create-relationships! acl [(eacl/->Relationship (->user "alice") :owner (->account "acme"))])
Note: eacl/create-relationships! is just a wrapper over eacl/write-relationships! with the :create operation. It will throw if there is an existing relationship that matches input.
Now that we have created a Relationship between a user and an account, we call eacl/can? to check if a user has the :update permission on the ACME account, e.g. "can Alice :update the ACME account?"
(eacl/can? acl (->user "alice") :update (->account "acme"))
=> true
Indeed, she can. Why? Because Alice is an :owner of the ACME account and the :update permission is granted to all users who are :owner(s).
Can Bob :update the ACME account?
(eacl/can? acl (->user "bob") :update (->account "acme"))
=> false
No, he cannot, because Bob is not an :owner of the ACME account.
Arrow permissions imply a graph hop. Arrows are designated by -> in the SpiceDB schema DSL:
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
permission admin = owner
permission update = admin
}
definition product {
relation account: account
permission edit = account->admin
}")
Here, permission edit = account->admin states that subjects are granted the edit permission if, and only if they have the admin permission on the related account for that product. Only account owners have the admin permission on the related account. So given that:
(->user "alice") is the :owner of (->account "acme"), and(->account "acme") is the :account for (->product "SKU-123"),:edit permission on product SKU-123.Now you can use can? to check those arrow permissions:
(eacl/can? acl (->user "alice") :edit (->product "SKU-123"))
=> true ; if Alice is an :owner of the Account for that Product.
(eacl/can? acl (->user "bob") :edit (->product "SKU-123"))
=> false ; if Bob is not the :owner of the Account for that Product.
Internally, EACL stores relation and permission definitions as entities and stores each relationship in both directions for efficient traversal.
SpiceDB uses strings for subject and resource IDs. Internally, EACL uses backend-native entity IDs, but you can configure EACL to convert internal IDs to external, and vice versa.
Note: Internal Datomic eids should not be exposed to consumers, because those eids are not guaranteed to be stable after a DB rebuild.
Every adapter's make-client takes config :entid->object-id & :object-id->lookup-ref, which are functions that convert between internal entity IDs and external object IDs.
It is common to attach a unique UUID to secured entities for external use, e.g. [:your/uuid "554dbf64-70cc..."], but you can use internal eids and convert them at the call-site. This attribute should have the property :db/unique :db.unique/identity.
Here is how to configure that translation when construction an ACL client via make-client, when using Datomic Pro:
(def acl (eacl.datomic.core/make-client conn
{:entid->object-id (fn [db eid] (:your/uuid (d/entity db eid)))
:object-id->lookup-ref (fn [obj-id] [:your/uuid obj-id])}))
The default options are to use the built-in EACL string attr :eacl/id, but you can use the internal Datomic eids with the following "identity" functions:
(def acl (eacl.datomic.core/make-client conn
{:entid->object-id (fn [_db eid] eid)
:object-id->lookup-ref (fn [obj-id] obj-id)}))
make-client rejects unknown options with {:type :eacl/invalid-config}.
Expression admission limits are immutable client configuration. Overrides are merged with EACL's calibrated defaults and checked against portable hard ceilings:
(def acl (eacl.datomic.core/make-client conn
{:expression-limits
{:maximum-source-nodes 32768
:maximum-source-depth 64
:maximum-expression-bytes 262144}}))
The profile applies to schema reads and writes performed by that client. It is also accepted by direct schema writers and the explicit Datomic v7-to-v8 permission migration. Two Peers may deliberately use different profiles: a stricter Peer can reject a schema accepted by a looser Peer, but schemas accepted by both have identical permission meaning. The profile is never written to the database and never coordinates Peers.
All backends issue non-expiring cursors by default. Configure a positive
:cursor-ttl-seconds only when the application deliberately wants a maximum
pagination age; cache capacity is independent of cursor age.
EACL relationship generations and canonical dependency proofs determine whether a completed value remains valid after an unrelated write. Storage itself is ordinary keyed retention: exact snapshot identity or a complete forward-valid proof is part of the key, rather than hidden in a custom generation-aware backend.
Recursive traversal must retain deduplication and continuation state that is too large and too private to place in a public cursor. The shared caches provide:
How the EACL cache works:
B >= T.B.S.Suppose:
token floor T = 100
selected basis B = 120
EACL first looks for an answer computed at database version 120. For a current database read, it can also reuse an earlier answer if the permission schema and all relationships that answer depends on are unchanged. Otherwise it computes the answer at version 120.
Historical reads only reuse answers from the selected historical version. Eviction affects performance; it does not turn an allowed request into a denied one. Cache retention also does not control when a share expires.
Use EACL's APIs, or submit EACL-produced transaction data intact, when changing relationships or permission schemas. Use the supported deletion helpers for secured entities. These writes give the cache the information it needs to recognize changes. Ordinary application data can use normal database writes.
Start with the default cache. To bound retained entries:
{:cache {:max-entries 2048
:denotation-max-entries 4096}}
These are entry counts, not byte limits. To bypass cached authorization results
for a request, pass :cache? false.
If you bypass EACL to change authorization data, stop affected requests, repair the data, and expire every affected client before serving again. After a database restore or history replacement, coordinate that reset across all Peers. Expiring a cache does not repair dangling relationships.
See the cache guide for configuration, statistics, persistence, and recovery.
A Zed token identifies a database revision. EACL returns one from a mutation so another request can ask to see that write.
(require '[eacl.spicedb.consistency])
(def write-result (eacl/create-relationship! acl alice :owner report))
(eacl/can? acl alice :view report
(eacl.spicedb.consistency/at-least-as-fresh (:zed/token write-result)))
| Mode | Use it to |
|---|---|
minimize-latency (default) | Read the current database visible to this Peer. |
fully-consistent | Synchronize before selecting a database version, where the backend supports it. |
at-least-as-fresh | Read a version that includes an earlier write. |
at-exact-snapshot | Read the exact historical version named by a token. |
Treat tokens as opaque strings. A token applies only to its original database
and lifecycle. For tokens returned by a browser, the server should normally
choose at-least-as-fresh; letting a caller select old authorization state
requires a separate application policy.
Datomic can reconstruct historical versions from retained history. Datahike needs temporal history or a retained commit. DataScript and Datalevin do not support arbitrary historical selection. An unavailable version causes an error; EACL does not silently choose another one.
Peers accepting the same cursors or tokens need shared keys. See security keys for setup and rotation, and the backend guide for consistency limits.
EACL's bundled situated backends require object IDs to resolve to application entities:
can?, lookup-resources, lookup-subjects, count-resources, count-subjects, read-relationships) treat unknown IDs as matching nothing: can? returns false, lookups and reads return empty pages.write-relationships! and friends) throw ex-info {:type :eacl/unknown-object, :object {:type … :id …}} — a relationship to a nonexistent entity is unsatisfiable, and failing loudly beats minting ghost entities or raw Datomic errors.If a lookup result has no external ID in the selected database,
lookup-resources and lookup-subjects raise
{:type :eacl/unresolvable-object} and identify every offending internal ID
instead of silently omitting authorized objects. This usually indicates a
dangling relationship left by retracting an entity before its relationships.
read-relationships still returns the damaged relationship half with a nil
ID so it can be repaired.
[!IMPORTANT] Do not call the backend's ordinary entity-retraction operation on a secured entity before removing its EACL relationships.
EACL stores both directions of a relationship. A native entity retraction removes the half stored on the target, but it cannot follow the peer ID stored inside the other endpoint's tuple or vector. The surviving half is a ghost relationship and can continue granting access.
The portable deletion sequence is:
;; Remove every relationship touching the object in both directions.
(eacl/delete-object! acl (->account "acme"))
;; Then delete the application entity with the backend's normal operation.
@(d/transact conn [[:db.fn/retractEntity account-eid]])
delete-object! removes relationships but does not delete the application
entity. It is idempotent. The Datomic implementation batches high-degree
cleanup; the Datahike, DataScript, and Datalevin implementations use one
transaction in their certified in-process topologies.
Backends that support transaction functions also provide an optional atomic
:eacl.fn/retractEntity. It removes both relationship halves and the target
entity in one transaction. The function is not installed by the normal EACL
schema; enabling it is an explicit deployment step.
| Backend/configuration | Safe-retraction support |
|---|---|
| Datomic Peer/Pro | Named :eacl.fn/retractEntity |
| DataScript CLJ/CLJS | Named or direct in-process function |
| Datahike with an in-process writer | Named or direct, depending on schema configuration |
| Datahike remote/function-unsafe writer | Use delete-object! and ordinary deletion |
| Datalevin qualified embedded writer | Direct in-process function |
Datomic example:
(require '[datomic.api :as d]
'[eacl.datomic.safe-retraction])
;; Privileged, idempotent deployment step.
(eacl.datomic.safe-retraction/install! conn)
@(d/transact conn [[:eacl.fn/retractEntity [:app/id "acme"]]])
The target can be a numeric entity ID or a valid lookup ref. Multiple and repeated invocations compose in one transaction:
@(d/transact conn [[:eacl.fn/retractEntity 1]
[:eacl.fn/retractEntity 2]
[:eacl.fn/retractEntity 1]])
A numeric entity ID can repair peer-side ghosts after an earlier native retraction. A lookup ref that no longer resolves cannot reveal the former entity ID, so it cannot perform that repair.
Do not add relationships involving a target in the same application
transaction that safely retracts it. Prefer delete-object! for very
high-degree targets so cleanup can be batched.
Use the backend's support-descriptor before choosing a
Datahike or DataScript deployment mode. Installation, direct-mode examples,
restore behavior, integrity reports, and repair tools are documented in the
adapter guides:
EACL parses a documented subset of the SpiceDB schema DSL. Use
eacl/write-schema! to define your schema.
EACL's parser requires each relation or permission declaration to end at a
newline; put the next declaration and the definition's closing brace on a later
line. Empty definitions may still use the compact definition user {} form.
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
permission admin = owner
}
definition server {
relation account: account
permission admin = account->admin
}")
Here's a complete example of defining a schema with eacl/write-schema!:
(eacl/write-schema! acl
"definition user {}
definition platform {
relation super_admin: user
}
definition account {
relation platform: platform
relation owner: user
permission admin = owner + platform->super_admin
}
definition server {
relation account: account
relation shared_admin: user
permission reboot = account->admin + shared_admin
}")
This schema defines:
platform resources can have super_admin usersaccount resources can have a platform and owner, with admin permission granted to owners and platform super_adminsserver resources belong to an account and can have shared_admin users, with reboot permission granted to account admins and shared_adminsCreate relationships between existing entities with
eacl/create-relationships!. To combine relationship changes with application
data, see atomic writes, including the published
release's limitation for new entities and tempids.
eacl-caveats-jvm evaluator; ClojureScript clients must supply a compatible
evaluator. See supported expressions and limits.:eacl.pagination/restart-required error. Start the lookup again without the
expired cursor. When using an explicit EACL snapshot, including one selected
with at-exact-snapshot, cursors keep working against relationships that are
valid at the snapshot's captured evaluation time.at-exact-snapshot and continued
cursors require the backend to reconstruct the selected database value.
Ordinary Datomic history and history-enabled Datahike do not age-expire.
History-disabled Datahike can lose a conditionally retained commit and then
returns snapshot-unavailable rather than silently using a newer value.subject#relation subject sets are not supported. Model group membership with explicit group Relationships and arrow permissions when that expresses the required semantics.can? for an authorization decision.delete-object!, or use the optional safe-retraction function — see
Deleting a Secured Entity.:count-limit to bound
counts, and raise recursive traversal limits only after load testing. If a
cached continuation is unavailable, EACL may replay earlier traversal work
to continue a cursor.EACL follows SpiceDB's schema vocabulary and shared authorization semantics, but it is not a byte-for-byte or operational clone:
:minimize-latency. EACL selects the current
immutable database value visible to the local backend connection. SpiceDB may use
an optimized cached revision, so freshness can differ. Use each backend's
own causal token with at-least-as-fresh or at-exact-snapshot when the
distinction matters; tokens and cursors are backend-local.count-resources, count-subjects, a controllable EACL result
cache, and delete-object!, which removes both stored Relationship halves.
Qualified deletion uses bounded native transactions; each transaction removes
both endpoint values and their owned qualifier together. These operations do
not have direct SpiceDB API equivalents.expand-permission-tree refuses cycles (:eacl.permission-tree/cycle-detected)
and depth beyond :permission-tree-limits (:max-depth 50 by default).:permission slot of
expand-permission-tree; can?, check-permission, the lookups and the
counts require a permission (SpiceDB accepts either).:subject/id must also contain
:subject/type. This fails closed instead of interpreting one external ID
across every subject definition.Some of this open-source work was generously funded by my former employer, CloudAfrica.
See Caveats and expiring Relationships for the v8 public APIs, optional JVM evaluator, trusted-clock and cursor semantics, and coordinated rollout.
Clojure
72.3%
Dafny
24.4%
TLA
1.4%
🦅 EACL: Enterprise Access ControL is a ReBAC Authorization library inspired by SpiceDB, built in Clojure and backed by Datomic Pro, Datahike, Datalevin or DataScript.
93
stars
717
commits
Clojure
primary language
Sep 14, 2026
updated
EACL is a situated ReBAC authorization library inspired by SpiceDB, built in Clojure and backed by Datomic Pro, Datahike, DataScript, or a qualified embedded Datalevin deployment.
| Authentication (AuthN) | Authorization (AuthZ) |
|---|---|
Who are you?, i.e. who is the <subject>? | What can <subject> do? |
EACL is concerned with fast & correct authorization, i.e. permissions.
EACL permissions are just data that co-exist with your application data – hence, situated, which offers several benefits, notably:
🦅 EACL is pronounced "EE-kəl", like "eagle" with a k because as a situated AuthZ system, EACL monitors the situation 🥁.
You need the Clojure CLI and Java 25 or newer for this published build. The Datomic Peer dependency is included; this memory example needs no separate Datomic server or account.
Create a directory with this deps.edn:
{:deps {dev.eacl/eacl-datomic {:mvn/version "8.0.0-RC-2026-09-12"}}}
This is a release candidate (a preview release). See
Clojars for published versions.
Start a REPL in that directory with clojure -M, then evaluate:
(require '[datomic.api :as d]
'[eacl.core :as eacl]
'[eacl.datomic.core :as eacl.datomic]
'[eacl.datomic.schema])
(def uri (str "datomic:mem://eacl-" (random-uuid)))
(d/create-database uri)
(def conn (d/connect uri))
;; Install EACL's schema, then your application's schema.
(eacl.datomic.schema/install! conn)
@(d/transact conn
[{:db/ident :app/id
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity}
{:db/ident :document/title
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one}])
(def acl (eacl.datomic/make-client conn
{:object-id->lookup-ref (fn [id] [:app/id id])
:entid->object-id (fn [db eid] (:app/id (d/entity db eid)))}))
(eacl/write-schema! acl
"definition user {}
definition document {
relation viewer: user
relation owner: user
permission view = viewer + owner
permission share = owner
}")
@(d/transact conn
[{:app/id "alice"}
{:app/id "bob"}
{:app/id "report" :document/title "Report"}])
(def alice (eacl/spice-object :user "alice"))
(def bob (eacl/spice-object :user "bob"))
(def report (eacl/spice-object :document "report"))
(eacl/create-relationship! acl alice :viewer report)
(eacl/can? acl alice :view report) ; true
(eacl/can? acl bob :view report) ; false
(mapv :id (:data (eacl/lookup-resources acl
{:subject alice :permission :view :resource/type :document :first 10})))
;; => ["report"]
Give each user and document a unique, stable ID owned by your application.
This example uses :app/id; you do not need to put application IDs in EACL's
internal schema.
The default token keys are process-local. For keys that survive restarts or work across load-balanced Peers, see security keys. Leave cache options out to use the defaults.
To delete a secured entity in Datomic, use :eacl.fn/retractEntity. Install
the function during database setup; it removes the entity and its relationships
in one transaction:
(require '[eacl.datomic.safe-retraction])
(eacl.datomic.safe-retraction/install! conn)
@(d/transact conn [[:eacl.fn/retractEntity [:app/id "report"]]])
(eacl/can? acl alice :view report) ; false
Do not use ordinary entity retraction while EACL relationships still refer to the entity. See safe deletion.
Yes.
| Database | Module | Storage |
|---|---|---|
| Datomic Pro | eacl-datomic | DynamoDB (recommended), Cassandra or SQL |
| Datahike | eacl-datahike | DynamoDB, S3 (cheaper, but slower), LMDB, SQL, Redis, GCS or IndexedDB. |
| DataScript | eacl-datascript | In-memory, but can persist to disk or add a SQL adapter. No time-travel. |
| Datalevin | eacl-datalevin — implemented, publication pending | Embedded LMDB; storage-enforced write policy and ordered generations. |
S3-backed Datahike is attractive for infrequently-accessed apps, because you can trade latency for reduced storage cost, and it supports serverless to reduce running Peer / Transactor costs.
Note: DataScript and Datalevin have no at-exact-snapshot semantics. Datahike requires a retained commit graph or temporal history to support exact snapshots. Datalevin uses scalar ordered-generation proofs from the maintained fork, so completed answers may reuse across unrelated forward revisions without historical selection.
t in [e a v t].This README is too long & too technical, so I am working to simplify it and break it out into area-specific documents. Despite my best attempts, EACL has become a large project.
[!WARNING] EACL is used in production, but under active development. The examples use
8.0.0-RC-2026-09-12, a release candidate. See Clojars for published versions.
Consider 10,000 online users: how often should clients re-query to keep their UIs up-to-date?
tx_rate(num_users, tx_rate_per_user).num_users * tx_rate(num_users, tx_rate_per_user) * queries_per_view quickly becomes a Read Amplification problem that can dramatically lower Peer performance, or require horizontal scaling.What if you could compute exactly which users are affected by every DB write and notify only those clients, in real-time?
d/listen, so you can inspect tx-data for every transaction, and call EACL's efficient eacl/lookup-subjects to retrieve a list of users who can see the affected resource, filter it down to online users and notify them. Alternatively, call eacl/can? for each online user in parallel.EACL implements an idiomatic IAuthorization protocol for each supported backend, which extends the SpiceDB gRPC API.
EACL can efficiently answer questions like, "Can <subject> do <permission> on <resource>?"
(eacl/can? acl subject permission resource ?consistency)
=> true | false
; e.g.
(eacl/can? acl (->user "alice") :view (->server "server1") eacl.spicedb.consistency/fully-consistent)
=> true | false
If you need cache provenance, use check-permission instead of can?, otherwise they are equivalent:
(eacl/check-permission acl
{:subject subject
:permission permission
:resource resource
:consistency eacl.spicedb.consistency/fully-consistent})
=> {:allowed? true, :cached? boolean, :cache-basis ...}
"Which <resources> does <subject> have <permission> on, as-of <10 seconds ago, or newer>?"
(eacl/lookup-resources acl
{:subject subject
:permission permission
:resource/type resource-type
:first page-size ; or :last page-size
:consistency (eacl.spicedb.consistency/at-least-as-fresh token-10s-ago)})
=> {:data [{:type :product :id "product-1"}
{:type :product :id "product-7"}
...
{:type :product :id "product-63"}]
:page-info ...
:cached? true|false>
...}
The :consistency argument is optional. The default is minimize-latency,
which means locally-consistent to the Peer. at-least-as-fresh selects an
appropriate backend basis directly and requires no cache-checkpoint option.
(def token-10s-ago (eacl.datomic/zed-token-at-least-seconds-ago acl 10))
(eacl/lookup-resources acl
{:subject (->user "alice")
:permission :view
:resource/type :product
:first 50
:consistency (eacl.spicedb.consistency/at-least-as-fresh token-10s-ago)})
=> {:data [{:type :product :id "product-1"}
{:type :product :id "product-7"}
...
{:type :product :id "product-63"}]
:page-info ...
:cached? true|false>
...}
(eacl/lookup-resources acl query)
=> {:data [resources...] :page-info {...} :cached? boolean :cache-basis ...}
(eacl/lookup-subjects acl query)
=> {:data [subjects...] :page-info {...} :cached? boolean :cache-basis ...}
SpiceDB does not support counting (you must traverse in pages), but EACL does.
(eacl/count-resources acl query)
=> {:count 42, :limit -1, :cached? boolean, :cache-basis ...}
(eacl/count-subjects acl query)
=> {:count 7, :limit -1, :cached? boolean, :cache-basis ...}
Without :count-limit, :limit is -1 and the count operation exhausts the
result set. Pass :count-limit n to bound work. The result then includes
:truncated?; true means at least one additional result exists.
Note: the default :limit will soon change to 50k instead of -1 (infinite), because high count-limits can exhaust Peers and trigger costly I/O from storage, esp. in recursive schemas.
An EACL client reads the current database. A snapshot holds one database version and one evaluation time, so several reads see the same state.
(eacl/with-snapshot [s (eacl/snapshot acl)]
[(eacl/can? s alice :view report)
(eacl/lookup-resources s
{:subject alice :permission :view :resource/type :document})])
with-snapshot releases the snapshot when the body finishes, including when
it throws. If you retain one manually, call eacl/release! when finished.
A read after release raises :eacl/snapshot-released. Datalevin snapshots
must be used and released on the platform thread that acquired them.
A retained snapshot keeps its captured time. An expiring share can therefore still grant access in an old snapshot after its deadline. Use the client, or acquire a new snapshot, when checking current access.
To select a historical version, pass a consistency descriptor to
eacl/snapshot. The backend must support that selection. A consistency
option on an existing snapshot only checks that it meets the requested
condition; it cannot move the snapshot to another version.
You can also preview changes without committing them:
(eacl/with-snapshot [base (eacl/snapshot acl)]
(let [tx (eacl/tx-relationship base :delete alice :viewer report)]
(eacl/with-snapshot [preview (eacl/with base tx)]
(eacl/can? preview alice :view report))))
Use eacl/with-schema to preview a permission schema. Preview results do not
become shared cached answers. Use these public helpers instead of wrapping a
raw Datomic d/with or d/filter value in an implementation-level client.
See atomic writes and the backend guide for supported snapshot operations and backend limits.
EACL's situated philosophy aligns with that of Datomic: if Data is local and Query is local, perception can scale, so why wait for an external AuthZ system to compute permissions?
As long as the DB basis is recent enough for our consistency demands, we can avoid a network hop. This yields several benefits:
Reduced Latency: EACL avoids a network hop to an external AuthZ system, but depending on consistency semantics, we can await new data from the Transactor if the Peer has fallen behind.
Consider that to leverage at_least_as_fresh consistency semantics in SpiceDB for LookupResources, you need to:
In EACL, since Peers are locally-consistent, as long as database snapshot S (valid @ time T) is locally available, we can query immediately without a network hop, or reuse cached answers derived from S or newer.
Bonus: Relationships are just data, so permission graph traversal can improve database cache locality for faster entity hydration before display.
Since you have to hit the DB anyway to show anything useful, we might as well compute permissions on the Peer, which has the database – which is what EACL does.
Time Travel: Unlike Spice cursors, EACL cursors do not expire (and are encrypted for UI exposure) unless you specify a TTL, so we can reconstruct selected snapshots if the backend retains it.
at-exact-snapshot.Consistency: Syncing to an external system introduces eventual consistency. With situated AuthZ, queries are at least locally-consistent as-of time T.
Simple Syncing: Relationships are just 3-tuples of [subject relation resource], so there is no impedance mismatch when syncing to SpiceDB at scale.
Real-time UI updates for materialized views: it is cheap to compute the subset of online clients that need to re-query while avoiding query amplification due to a busy Transactor.
Situated is faster for small (~1k-100k relationships) to medium-applications (~1M-10M Relationships):
Application & Authorization Data live together in harmony. In my testing with small to medium-sized workloads, EACL is as good, or faster than SpiceDB, owing to reduced latency from its situated design, but no EACL benchmarks are published at this time (benchmarks are a tricky business).
One less thing to deploy & sync Relationships to.
Note that EACL has Limitations compared to SpiceDB.
In a ReBAC system like EACL, Subjects & Resources are related via Relationships.
A Relationship is just a 3-tuple of [subject relation resource], e.g.
[user1 :owner account1] means subject user1 is the :owner of resource account1, and[account1 :account product1] means subject account1 is the :account for resource product1.EACL models the 3 core concepts in its permission graph:
What do they look like?
{:keys [type id]}, e.g.
(spice-object :user "user-1") => {:type :user, :id "user-1"},(spice-object :product "product-5") => {:type :product, :id "product-5"}(def ->user (partial spice-object :user)), so that(->user "user-1") => {:type :user, :id "user-1"}.Relations and Permissions:
Relation defines how a Subject & a Resource can be related via a Relationship.Permission defines which permissions are granted to a Subject via a chain of Relationships between subjects & resources.
Relation, i.e. (Relationship subject relation resource)
(Relationship (->user alice) :owner (->account "acme")) means that,
(->user "alice") is the Subject,:owner is the name of the Relation (as defined by schema)(->account "acme") is the ResourceEACL supports four consistency modes named after SpiceDB's Consistency Semantics.
These modes allow us to trade consistency for speed (reduced latency), by enabling cache reuse. These modes affect which database snapshot S and in-memory cache segments C are allowed to participate in answering a permission query:
minimize-latency means locally-consistent to the Peer as-of now, i.e. (d/db conn) + valid cache segments.at-least-as-fresh uses query against a local snapshot S that is as fresh or fresher than T, i.e. (d/sync conn T) which will only block if Peer is behind S.at-exact-snapshot always calls (d/as-of db T) and reuses cache segments valid for basis B >= S.fully-consistent means locally-consistent after a (d/sync conn) (blocking call), so behaves like minimize-latency but Peer is fully up-to-date.For detailed descriptions, Continue to Consistency Modes →
Core model:
Consider that in a fast-moving database, (d/db conn) is always moving, so a naive cache would invalidate after every write. EACL uses dependency-proofs to reuse cached segments that are proven to be unaffected by unrelated writes.
For read-only actions, we might be fine with reusing cached answers that are a few seconds old and avoid expensive computation.
For example, when a YouTube video with millions of views is unpublished, it is probably fine to keep serving it for a few seconds instead of recomputing access on every view. Here's how to do it in EACL:
(def token-10s-ago (eacl.datomic/zed-token-at-least-seconds-ago acl 10))
(eacl/can? acl (->user "alice") :view (->video "my-video")
(eacl.spicedb.consistency/at-least-as-fresh token-10s-ago))
This also works for lookups, like listing video resources:
(eacl/lookup-resources acl
{:subject (->user "alice")
:permission :view
:resource/type :video
:consistency (eacl.spicedb.consistency/at-least-as-fresh token-10s-ago)})
However, for destructive actions, e.g. permanently deleting a video, we will want to make 100% sure the user is allowed to do that. For that we can use fully-consistent:
(eacl/can? acl (->user "alice") :delete (->video "my-video")
eacl.spicedb.consistency/fully-consistent) ; this will block on (d/sync conn)
Most of the time, we will use eacl.spicedb.consistency/minimize-latency, which uses what is locally-consistent to the Peer:
(eacl/can? acl (->user "alice") :view (->video "my-video")
eacl.spicedb.consistency/minimize-latency)
db value as if derived from (d/as-of (d/db conn) T), where
(d/db conn),(d/sync conn).(d/as-of db <T-10 seconds>), orT+2 seconds, which is relevant in multi-Peer systems.Even in a fast-moving DB, EACL will reuse cache segments that are unaffected by unrelated mutations.
minimize-latency (the default and simplest) is fast and locally-consistent to the Peer, i.e. (d/db conn).
at-least-as-fresh uses @(d/sync conn token-revision) which can block if the Peer is behind. It does not block if the Peer has revision snapshot S.
<10 seconds ago> or fresher.<subject> view this <video> as-of <30 seconds ago, or fresher>?"T is newer than the central Transactor has seen, EACL will return an error.fully-consistent blocks on @(d/sync conn) before taking a snapshot S from (d/db conn), because the Peer could be behind, so it can be slow:
<subject> delete this <video> as-of <right now>?"at-exact-snapshot always time-travels with (d/as-of db T):
d/as-of can be expensive, so you will typically only use this if you need historical data.T is in the future (relative to Peer), EACL may block on (d/sync conn T) before (d/as-of db T).
T is newer than the Transactor has seen.T.at-exact-snapshot is not supported by DataScript or Datalevin. Both fail closed instead of emulating history.The EACL engine and cache benefit from monotonic txId (transaction IDs), i.e. the t in [e a v t], so cache segments are keyed by basis T. The engine will only use cache segments valid @ T.
EACL cursors encode DB basis, schema version and related cache proofs.
Not every mode is supported by every backend, because some modes rely on time travel over full history.
Unsupported modes by backend will return an error. Refer Consistency and ZedTokens.
EACL co-exists with your data in Datomic, Datahike, DataScript, or Datalevin. As a result, EACL installs and maintains some attributes in your data store, all of which are prefixed by :eacl*.
Presently, EACL Relationships are stored in history to support auditability, d/as-of & at-exact-snapshot semantics, but in a future version of EACL, history could be optional to save on storage, but then you lose time travel & auditability. For many applications that only care about permissions as-of "now", this would be acceptable.
The EACL-specific attributes are detailed below.
EACL Relationships are light by virtue of being stored directly on entities as two tuples:
:eacl.v8.relationship/subject-type+relation+resource-type+resource+qualifier:eacl.v8.relationship/resource-type+relation+subject-type+subject+qualifierTo retract an entity and its Relationships, use :eacl.fn/retractEntity, an optional Transactor function you can install.
If you only want to retract Relationships, call eacl/delete-relationships!.
To retract an entity and its Relationships, you may call eacl/delete-relationships! followed by :db.fn/retractEntity, but this will write two transactions to the tx-log, which is fine, but not ideal and adds noise to the Transactor, so prefer :eacl.fn/retractEntity for full entity retractions.
If you call :db.fn/retractEntity without eacl/delete-relationships!, you will leave ghost Relationship tuples lying around on the contra-object (subject or resource), so it's safer to use :eacl.fn/retractEntity for secured entities and reduce transactor noise.
EACL's contract with you is that you MUST use the EACL APIs to maintain Relationships so we can guarantee cache coherence and a clean database. If you mess with EACL's data structures, it becomes your problem.
But you will probably forget one day, so there are helpers to fined & clean up ghost tuples :). Refer Deleting a Secured Entity.
:eacl.relation/resource-type:eacl.relation/relation-name:eacl.relation/subject-type:eacl.relation/resource-type+relation-name+subject-type:eacl/relation-version tracks cache coherence in Datomic, Datahike, and DataScript. Datalevin uses the scalar :eacl.datalevin/relation-generation; each is advanced by relevant Relationship writes.:eacl/id:eacl.permission/resource-type:eacl.permission/permission-name:eacl.permission/expression-payload:eacl.permission/resource-type+permission-nameThe canonical payload contains its expression-format version. EACL does not store a second format field, a content digest, a policy digest, admission limits, or expression/DAG metrics. Those values either duplicate the payload or describe one client's resource-admission policy rather than permission meaning. Node counts, depth, fan-in, encoded size, normalized DAG counts, word counts, and checkpoint weights are derived from the payload and may be cached inside the client.
:eacl/id identifies EACL's internal Relations and Permissions. Use a separate application attribute, such as :app/id, for your users and resources.:eacl/schema-string stores a valid schema string was written via eacl/write-schema!.:eacl/schema-version track the schema revision in Datomic Pro.:eacl/schema-generation and :eacl/schema-write-fence track schema writes in Datahike and DataScript. Datalevin uses scalar :eacl.datalevin/schema-generation and :eacl.datalevin/schema-write-fence values in its native max-tx domain.:eacl/storage-version identifies Relationship storage ABI 8 across the bundled backends (five-slot endpoint pairs).:eacl/permission-storage-version identifies Datomic's canonical permission representation (version 8).:eacl.fn/assert-relation-unused is a Transactor function in Datomic that guards removing Relations with active Relationships (to avoids orphaned Relationships).cljs-cache LRU. Custom
cache providers are intentionally unsupported because they
cannot participate in EACL's private lifecycle and proof contracts.Let's design a simple permission schema for a Google Drive clone using the SpiceDB schema DSL:
definition user {}
definition folder {
relation owner: user ; a folder as an ownerh
relation viewer: user ; a folder can have viewers, who may not be the owner
relation parent: folder ; a folder can have parent, i.e. nested file system
permission view = owner + viewer + parent->view ; can view a folder as the owner, or view its parent
permission edit = owner + parent->edit ; you can edit a folder if you are the owner, or can edit its parent
}
definition document {
relation owner: user ; a document has an owner
relation viewer: user ; a document can have viewers who aren't the owner
relation folder: folder ; a document belongs in a folder
permission view = owner + viewer + folder->view ; you can view a document if you own it, ar a viewer or can view the parent folder
permission edit = owner + folder->edit ; you can edit a document if own it, or if you can edit the folder
}
Basically, a user owns folders & documents; documents go in a folder and folders can nest, i.e. folders have children – it's a file system:
:view a document if you are the owner, a viewer, or if you can view the folder it's in.:view a folder if you can view a parent folder (recursive schema).:edit a document if you are the owner, or if you can edit the folder (or any of its parents).You can share documents or folders with other users by making them a viewer, i.e. by adding (Relationship (->user "bob") :viewer (->folder "my-folder")), to share my-folder with user "bob".
Because of the recursive view permission, user bob will be able to :view any nested files or folders under the folder, my-folder, that you shared with them.
EACL supports multiple backends. Each adapter will bring in the shared EACL engine:
;; Datomic Pro
{:deps {dev.eacl/eacl-datomic {:mvn/version "8.0.0-RC-2026-09-12"}}}
;; Datahike
{:deps {dev.eacl/eacl-datahike {:mvn/version "8.0.0-RC-2026-09-12"}}}
;; DataScript
{:deps {dev.eacl/eacl-datascript {:mvn/version "8.0.0-RC-2026-09-12"}}}
;; Datalevin (coordinate reserved; publication remains gated)
{:deps {dev.eacl/eacl-datalevin {:mvn/version "8.0.0-SNAPSHOT"}}}
;; Core-only consumers and backend authors (you typically won't need this)
{:deps {dev.eacl/eacl {:mvn/version "8.0.0-RC-2026-09-12"}}}
For source development, clone the full repository, prepare the generated core runtime as described below, then keep the same library coordinate and use :local/root`. The backend module resolves the sibling core module:
{:deps {dev.eacl/eacl-datomic
{:local/root "/absolute/path/to/eacl/core/modules/eacl-datomic"}}}
Source consumers who compile the EACL kernel locally need the Clojure CLI, Node.js, and the repository-pinned Dafny, Apalache, and TLA+ tools. Prepare the generated JVM and browser runtimes before using a :local/root dependency:
cd modules/eacl
# Default Java target
clojure -T:build prep
# Example Java 17 target
clojure -T:build prep :java-release 17
clojure -T:build jar :java-release 17
Pass the same :java-release to prep and jar or install. The generated
kernel defaults to Java 25 bytecode and may be compiled for Java 8 through 26,
but the complete JVM EACL module requires Java 11 or newer because its Caffeine
cache dependency does. Java 17 is EACL's supported production runtime floor.
See formal/README.md for tool versions and the full
verification commands.
For permission operators, precedence, stratification, limits, ordering, cursors,
cache behavior, and measured performance, see the permission set-algebra
guide. For module selection, current
capability differences, cache mutation rules, and recursive controls, see the
backend guide. Datalevin setup,
mandatory lifecycle/watermark inputs, write-policy boundary, and publication
status are documented in the eacl-datalevin module
README. Backend authors should also read the
adapter boundary and basis-source
migration guide.
To create a Relationship, first define your schema using eacl/write-schema!:
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
relation viewer: user
relation active: user
relation banned: user
permission admin = owner - banned
permission view = (owner + viewer) & active
}
definition product {
relation account: account
permission edit = account->admin
permission view = account->view
}")
This schema defines:
account, which can have owner, viewer, active, and banned users, with
admin permission granted to non-banned owners.product belongs to an account, with edit permission for account
admins and view permission for active account owners or viewers.In the schema DSL, + is union, & is intersection, and - is directed set
exclusion. Parentheses are supported. + binds more tightly than &, which
binds more tightly than -; repeated exclusion associates from the left.
(eacl/read-relationships acl filters)
=> {:data [relationships...]
:page-info {...}
:cached? boolean
:cache-basis ...}
(eacl/write-relationships! acl updates)
=> {:zed/token "eacl_z4_..."}
where updates is a collection of RelationshipUpdate records:
(eacl/->RelationshipUpdate operation relationship),
or, maps {:operation op :relationship rel}, and
operation is one of :create, :touch or :delete.
A bare [operation relationship] vector is rejected as an unsupported update.
Schema names are validated: unknown definitions, relations or bad subject types will fail with :eacl/unknown-definition / :eacl/unknown-relation-or-permission.
Relationship Conflicts?
:create will fail with :eacl/relationship-conflict if the same if the same Relationship already exists.
:creates of one relationship produce exactly one success.:touch is idempotent. Repeating one operation for the same relationship inside a batch has the same outcome as submitting it once (:create still conflicts when the relationship existed before the batch); mixing different operations for the same resolved relationship throws :eacl/invalid-relationship-update-batch before submission.(eacl/create-relationships! acl relationships) simply calls write-relationships! with :create operation.(eacl/delete-relationships! acl relationships) simply calls write-relationships! with :delete operation.(eacl/delete-object! acl object) => {:zed/token "eacl_z4_...", :retracted-datoms n} is a convenience helper that removes every relationship touching object, in both directions. n counts relationship datoms actually retracted by the committed transactions. On Datomic the retractions are committed in batches of 1,000 (a concurrent reader can observe a partially deleted object between batches); on DataScript and Datahike they are one atomic transaction. Consumers are expected to delete relationships before retracting a secured entity — see Deleting a Secured Entity.All list APIs use the v8 Relay pagination contract:
:first and optionally :after.:last and optionally :before.:page-info with :start-cursor, :end-cursor, :has-next-page?, and :has-previous-page?.Use eacl/check-permissions for several decisions on one snapshot. To combine
a permission check with a direct relationship filter, choose:
read-relationships with :authorization when the relationship set is smaller.lookup-resources with :resource/relationship, or lookup-subjects with
:subject/relationship, when the authorized set is smaller.Both routes can return a short or empty page with :has-next-page? true and
:bounded? true. Continue with the returned cursor. Applications must also
authorize access to sharing metadata.
See aggregate authorization for examples.
Every bounded read accepts an optional per-request :cancellation-token in
addition to :timeout-ms. Create and cancel the token through the public EACL
API:
(let [token (eacl/cancellation-token)]
;; Pass `token` to the HTTP/request owner before starting the read.
(future
(eacl/lookup-resources acl
{:subject (eacl/spice-object :user "alice")
:permission :view
:resource/type :document
:first 100
:cancellation-token token}))
(eacl/cancel! token))
Cancellation is cooperative and best-effort. EACL checks it at the same
orchestration, cursor, cache, and reducer-transition boundaries (one check per
engine step, which covers each adapter command) as the absolute deadline and, when observed before completion, throws
:eacl.execution/cancelled without returning a partial answer. A synchronous
adapter call already in progress must return before the next check, and a
completed result may win a race with a late cancellation. Applications must
therefore keep the server deadline and bounded admission control; interrupting
a worker thread is not a substitute. The token is execution-only and is
excluded from cache, continuation, and authenticated cursor identity. One
token belongs to one logical request.
(eacl/write-schema! acl schema-string) parses a SpiceDB schema DSL string, validates it, computes deltas against existing schema, checks for orphaned relationships, and transacts changes atomically.(eacl/read-schema acl) returns the current schema as a map of {:relations [...] :permissions [...]}.All schema changes must use eacl/write-schema!. If an application changes
the authorization schema directly, follow the recovery procedure in
Caching before resuming authorization traffic.
Datomic and Datahike consumers upgrading a released v7 database must run the backend's explicit permission-only v7-to-v8 migration, followed by the Relationship storage 7-to-8 migration, before constructing an ordinary v8 client. Permission storage remains version 8. Storage 8 uses a nullable qualifier reference in slot five for Caveats and expiring Relationships.
Expansion accepts exactly :resource, :permission, and the optional :consistency, :timeout-ms, and :cancellation-token keys:
(eacl/expand-permission-tree acl
{:resource (eacl/spice-object :document "readme")
:permission :view
:consistency eacl.spicedb.consistency/fully-consistent
:timeout-ms 5000})
=>
{:expanded-at "eacl_z4_..."
:tree-root
{:expanded-object {:type :document :id "readme"}
:expanded-relation :view
:intermediate
{:operation :union
:children
[{:expanded-object {:type :document :id "readme"}
:expanded-relation :viewer
:leaf {:subjects [{:type :user :id "alice"}]}}]}}}
A node contains exactly one of :leaf or :intermediate. Permission and
arrow boundaries remain visible; expansion is shallow in the SpiceDB sense,
so leaves contain subjects found by direct relation scans rather than a
flattened effective-membership set. To decide whether a subject has the
permission, use can?; do not infer authorization by flattening a tree.
Child and subject vector order is non-semantic and may differ by backend. Empty branches and duplicate paths are preserved. Compare trees as annotated topology with child/subject multisets when order is irrelevant. The exact supplied root ID is retained, while scanned IDs are converted with the selected client's object-ID codec.
The response tree and :expanded-at token are derived from the same selected
immutable snapshot. Replay the token with
(eacl.spicedb.consistency/at-exact-snapshot (:expanded-at response)) only on a backend
that advertises exact historical selection; otherwise use it as an
at-least-as-fresh causal floor. Unsupported consistency, unavailable history,
deadlines, unknown root relations or permissions, cycles, codec failures,
adapter-contract failures,
and structural limits produce typed all-or-error failures—no lazy or partial
tree is returned.
Clients accept positive exact-integer :permission-tree-limits overrides.
They are configuration-only, not request keys:
(eacl.datascript.core/make-client conn
{:permission-tree-limits
{:max-depth 50
:max-schema-components 100000
:max-relationship-values 100000
:max-tree-nodes 100000
:max-leaf-subjects 100000}})
Every bundled backend uses the same portable expansion kernel. Expected backend differences include supported consistency modes, historical retention, native scan order, and configured identity conversion.
The primary API call is can?, e.g.
(eacl/can? acl subject permission resource)
=> true | false
The other primary API call is lookup-resources, e.g.
(def page1 (eacl/lookup-resources acl
{:subject (->user "alice")
:permission :view
:resource/type :server
:first 2})) ; defaults to 1000.
page1
=> {:data [{:type :server :id "server-1"}
{:type :server :id "server-2"}]
:page-info {:start-cursor "..."
:end-cursor "..."
:has-next-page? true
:has-previous-page? false}
:cached? boolean
:cache-basis ...}
To query the next page, pass the :end-cursor from page1 as :after:
(def page2 (eacl/lookup-resources acl
{:subject (->user "alice")
:permission :view
:resource/type :server
:first 2
:after (get-in page1 [:page-info :end-cursor])}))
page2
=> {:data [{:type :server :id "server-3"}
{:type :server :id "server-4"}]
:page-info {:start-cursor "..."
:end-cursor "..."
:has-next-page? true
:has-previous-page? true}
:cached? boolean
:cache-basis ...}
To go back from page2, pass its :start-cursor as :before with :last:
(eacl/lookup-resources acl
{:subject (->user "alice")
:permission :view
:resource/type :server
:last 2
:before (get-in page2 [:page-info :start-cursor])})
Forward and backward pages return results in the same order for one fixed query and authenticated cursor walk. Permission lookups use the sealed plan's stable first-discovery order, and relationship reads use backend tuple-index order. These are pagination orders, not a global, cross-backend, or domain sort order. Backward pagination returns the previous window; it does not reverse the result order.
For Clojure/JVM applications backed by Datahike, add the Datahike adapter dependency to your deps.edn file:
{:deps {dev.eacl/eacl-datahike {:mvn/version "8.0.0-RC-2026-09-12"}}}
(ns my-eacl-datahike-project
(:require [datahike.api :as d]
[eacl.core :as eacl]
[eacl.datahike.core :as eacl.datahike]))
; Create an in-memory Datahike database and install EACL's Datahike schema:
(def conn (eacl.datahike/create-conn
[{:db/ident :app/id :db/valueType :db.type/string
:db/cardinality :db.cardinality/one :db/unique :db.unique/identity}]))
; Make an EACL client that satisfies the `IAuthorization` protocol:
(def acl (eacl.datahike/make-client conn
{:object-id->lookup-ref (fn [id] [:app/id id])
:entid->object-id (fn [db eid] (:app/id (d/entity db eid)))}))
; Write your permission schema using SpiceDB schema DSL:
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
permission admin = owner
}")
; Transact application entities with unique `:app/id` values:
(d/transact conn
[{:app/id "user-1"}
{:app/id "account-1"}])
; Create a Relationship between existing entities:
(eacl/create-relationship! acl
(eacl/spice-object :user "user-1")
:owner
(eacl/spice-object :account "account-1"))
; Run a Permission Check with `can?`:
(eacl/can? acl
(eacl/spice-object :user "user-1")
:admin
(eacl/spice-object :account "account-1"))
; => true
EACL-created Datahike databases enable :keep-history? true by default so
exact tokens and cursors survive ordinary commit-record cutoff collection.
Pass {:keep-history? false} to create-conn only when lower write/storage
amplification is worth making exact reconstruction conditional on retained
commit records.
For server-side or browser demos, use the DataScript adapter:
{:deps {dev.eacl/eacl-datascript {:mvn/version "8.0.0-RC-2026-09-12"}}}
The example below runs on the JVM. For ClojureScript, also add the cache fork to your application's dependencies; Maven cannot declare this Git dependency:
com.github.theronic/cljs-cache
{:git/url "https://github.com/theronic/cljs-cache.git"
:git/sha "4143cc036446a47f0c6dfd9f8dde90363835051c"}
(ns my-eacl-datascript-demo
(:require [datascript.core :as ds]
[eacl.core :as eacl]
[eacl.datascript.core :as eacl.datascript]))
(def conn (eacl.datascript/create-conn {:app/id {:db/unique :db.unique/identity}}))
(def acl (eacl.datascript/make-client conn
{:object-id->lookup-ref (fn [id] [:app/id id])
:entid->object-id (fn [db eid] (:app/id (ds/entity db eid)))}))
(ds/transact! conn
[{:db/id -1 :app/id "user-1"}
{:db/id -2 :app/id "account-1"}])
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
permission admin = owner
}")
(eacl/create-relationship! acl
(eacl/spice-object :user "user-1")
:owner
(eacl/spice-object :account "account-1"))
(eacl/can? acl
(eacl/spice-object :user "user-1")
:admin
(eacl/spice-object :account "account-1"))
; => true
EACL parses a documented subset of the SpiceDB schema DSL to define your authorization model. Use eacl/write-schema! to parse, validate, and transact your schema:
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
permission admin = owner
permission update = admin
}
definition product {
relation account: account
permission edit = account->admin
}")
write-schema! validates your schema and provides informative error messages. An invalid schema throws and nothing is transacted:
definition/relation declarations throw. // and /* */ comments are supported.eacl/write-schema! rejects replacing a non-empty schema with zero definitions. The backend schema namespaces expose a lower-level {:allow-empty-schema? true} option for an intentional wipe; direct use must also follow the cache-recovery rules because it bypasses the EACL client.When you call write-schema! with a modified schema, EACL:
Let's model the following SpiceDB schema in EACL:
definition user {}
definition account {
relation owner: user
}
We define two resource types, user & account, where any user subject can be the :owner of an account resource.
A Relationship is just a 3-tuple of [subject relation resource]:
(eacl/->Relationship (->user "alice") :owner (->account "acme"))
Let's add a direct permission to the schema for account resources:
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
permission update = owner
}")
Here, permission update = owner means any user who is an :owner of an account will have the update permission for that account.
At this point, all permissions checks via eacl/can? will return false, because there are no Relationships defined:
(eacl/can? acl (->user "alice") :update (->account "acme"))
=> false
What happens when we create some Relationships between users & accounts?
In EACL, Relationships are expressed as 3-tuples of [subject relation resource] using the ->Relationship helper, e.g. user alice is an :owner of acme account:
(eacl/->Relationship (->user "alice") :owner (->account "acme"))
Now let's create a Relationship between a user subject and an account resource using eacl/create-relationships!:
(eacl/create-relationships! acl [(eacl/->Relationship (->user "alice") :owner (->account "acme"))])
Note: eacl/create-relationships! is just a wrapper over eacl/write-relationships! with the :create operation. It will throw if there is an existing relationship that matches input.
Now that we have created a Relationship between a user and an account, we call eacl/can? to check if a user has the :update permission on the ACME account, e.g. "can Alice :update the ACME account?"
(eacl/can? acl (->user "alice") :update (->account "acme"))
=> true
Indeed, she can. Why? Because Alice is an :owner of the ACME account and the :update permission is granted to all users who are :owner(s).
Can Bob :update the ACME account?
(eacl/can? acl (->user "bob") :update (->account "acme"))
=> false
No, he cannot, because Bob is not an :owner of the ACME account.
Arrow permissions imply a graph hop. Arrows are designated by -> in the SpiceDB schema DSL:
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
permission admin = owner
permission update = admin
}
definition product {
relation account: account
permission edit = account->admin
}")
Here, permission edit = account->admin states that subjects are granted the edit permission if, and only if they have the admin permission on the related account for that product. Only account owners have the admin permission on the related account. So given that:
(->user "alice") is the :owner of (->account "acme"), and(->account "acme") is the :account for (->product "SKU-123"),:edit permission on product SKU-123.Now you can use can? to check those arrow permissions:
(eacl/can? acl (->user "alice") :edit (->product "SKU-123"))
=> true ; if Alice is an :owner of the Account for that Product.
(eacl/can? acl (->user "bob") :edit (->product "SKU-123"))
=> false ; if Bob is not the :owner of the Account for that Product.
Internally, EACL stores relation and permission definitions as entities and stores each relationship in both directions for efficient traversal.
SpiceDB uses strings for subject and resource IDs. Internally, EACL uses backend-native entity IDs, but you can configure EACL to convert internal IDs to external, and vice versa.
Note: Internal Datomic eids should not be exposed to consumers, because those eids are not guaranteed to be stable after a DB rebuild.
Every adapter's make-client takes config :entid->object-id & :object-id->lookup-ref, which are functions that convert between internal entity IDs and external object IDs.
It is common to attach a unique UUID to secured entities for external use, e.g. [:your/uuid "554dbf64-70cc..."], but you can use internal eids and convert them at the call-site. This attribute should have the property :db/unique :db.unique/identity.
Here is how to configure that translation when construction an ACL client via make-client, when using Datomic Pro:
(def acl (eacl.datomic.core/make-client conn
{:entid->object-id (fn [db eid] (:your/uuid (d/entity db eid)))
:object-id->lookup-ref (fn [obj-id] [:your/uuid obj-id])}))
The default options are to use the built-in EACL string attr :eacl/id, but you can use the internal Datomic eids with the following "identity" functions:
(def acl (eacl.datomic.core/make-client conn
{:entid->object-id (fn [_db eid] eid)
:object-id->lookup-ref (fn [obj-id] obj-id)}))
make-client rejects unknown options with {:type :eacl/invalid-config}.
Expression admission limits are immutable client configuration. Overrides are merged with EACL's calibrated defaults and checked against portable hard ceilings:
(def acl (eacl.datomic.core/make-client conn
{:expression-limits
{:maximum-source-nodes 32768
:maximum-source-depth 64
:maximum-expression-bytes 262144}}))
The profile applies to schema reads and writes performed by that client. It is also accepted by direct schema writers and the explicit Datomic v7-to-v8 permission migration. Two Peers may deliberately use different profiles: a stricter Peer can reject a schema accepted by a looser Peer, but schemas accepted by both have identical permission meaning. The profile is never written to the database and never coordinates Peers.
All backends issue non-expiring cursors by default. Configure a positive
:cursor-ttl-seconds only when the application deliberately wants a maximum
pagination age; cache capacity is independent of cursor age.
EACL relationship generations and canonical dependency proofs determine whether a completed value remains valid after an unrelated write. Storage itself is ordinary keyed retention: exact snapshot identity or a complete forward-valid proof is part of the key, rather than hidden in a custom generation-aware backend.
Recursive traversal must retain deduplication and continuation state that is too large and too private to place in a public cursor. The shared caches provide:
How the EACL cache works:
B >= T.B.S.Suppose:
token floor T = 100
selected basis B = 120
EACL first looks for an answer computed at database version 120. For a current database read, it can also reuse an earlier answer if the permission schema and all relationships that answer depends on are unchanged. Otherwise it computes the answer at version 120.
Historical reads only reuse answers from the selected historical version. Eviction affects performance; it does not turn an allowed request into a denied one. Cache retention also does not control when a share expires.
Use EACL's APIs, or submit EACL-produced transaction data intact, when changing relationships or permission schemas. Use the supported deletion helpers for secured entities. These writes give the cache the information it needs to recognize changes. Ordinary application data can use normal database writes.
Start with the default cache. To bound retained entries:
{:cache {:max-entries 2048
:denotation-max-entries 4096}}
These are entry counts, not byte limits. To bypass cached authorization results
for a request, pass :cache? false.
If you bypass EACL to change authorization data, stop affected requests, repair the data, and expire every affected client before serving again. After a database restore or history replacement, coordinate that reset across all Peers. Expiring a cache does not repair dangling relationships.
See the cache guide for configuration, statistics, persistence, and recovery.
A Zed token identifies a database revision. EACL returns one from a mutation so another request can ask to see that write.
(require '[eacl.spicedb.consistency])
(def write-result (eacl/create-relationship! acl alice :owner report))
(eacl/can? acl alice :view report
(eacl.spicedb.consistency/at-least-as-fresh (:zed/token write-result)))
| Mode | Use it to |
|---|---|
minimize-latency (default) | Read the current database visible to this Peer. |
fully-consistent | Synchronize before selecting a database version, where the backend supports it. |
at-least-as-fresh | Read a version that includes an earlier write. |
at-exact-snapshot | Read the exact historical version named by a token. |
Treat tokens as opaque strings. A token applies only to its original database
and lifecycle. For tokens returned by a browser, the server should normally
choose at-least-as-fresh; letting a caller select old authorization state
requires a separate application policy.
Datomic can reconstruct historical versions from retained history. Datahike needs temporal history or a retained commit. DataScript and Datalevin do not support arbitrary historical selection. An unavailable version causes an error; EACL does not silently choose another one.
Peers accepting the same cursors or tokens need shared keys. See security keys for setup and rotation, and the backend guide for consistency limits.
EACL's bundled situated backends require object IDs to resolve to application entities:
can?, lookup-resources, lookup-subjects, count-resources, count-subjects, read-relationships) treat unknown IDs as matching nothing: can? returns false, lookups and reads return empty pages.write-relationships! and friends) throw ex-info {:type :eacl/unknown-object, :object {:type … :id …}} — a relationship to a nonexistent entity is unsatisfiable, and failing loudly beats minting ghost entities or raw Datomic errors.If a lookup result has no external ID in the selected database,
lookup-resources and lookup-subjects raise
{:type :eacl/unresolvable-object} and identify every offending internal ID
instead of silently omitting authorized objects. This usually indicates a
dangling relationship left by retracting an entity before its relationships.
read-relationships still returns the damaged relationship half with a nil
ID so it can be repaired.
[!IMPORTANT] Do not call the backend's ordinary entity-retraction operation on a secured entity before removing its EACL relationships.
EACL stores both directions of a relationship. A native entity retraction removes the half stored on the target, but it cannot follow the peer ID stored inside the other endpoint's tuple or vector. The surviving half is a ghost relationship and can continue granting access.
The portable deletion sequence is:
;; Remove every relationship touching the object in both directions.
(eacl/delete-object! acl (->account "acme"))
;; Then delete the application entity with the backend's normal operation.
@(d/transact conn [[:db.fn/retractEntity account-eid]])
delete-object! removes relationships but does not delete the application
entity. It is idempotent. The Datomic implementation batches high-degree
cleanup; the Datahike, DataScript, and Datalevin implementations use one
transaction in their certified in-process topologies.
Backends that support transaction functions also provide an optional atomic
:eacl.fn/retractEntity. It removes both relationship halves and the target
entity in one transaction. The function is not installed by the normal EACL
schema; enabling it is an explicit deployment step.
| Backend/configuration | Safe-retraction support |
|---|---|
| Datomic Peer/Pro | Named :eacl.fn/retractEntity |
| DataScript CLJ/CLJS | Named or direct in-process function |
| Datahike with an in-process writer | Named or direct, depending on schema configuration |
| Datahike remote/function-unsafe writer | Use delete-object! and ordinary deletion |
| Datalevin qualified embedded writer | Direct in-process function |
Datomic example:
(require '[datomic.api :as d]
'[eacl.datomic.safe-retraction])
;; Privileged, idempotent deployment step.
(eacl.datomic.safe-retraction/install! conn)
@(d/transact conn [[:eacl.fn/retractEntity [:app/id "acme"]]])
The target can be a numeric entity ID or a valid lookup ref. Multiple and repeated invocations compose in one transaction:
@(d/transact conn [[:eacl.fn/retractEntity 1]
[:eacl.fn/retractEntity 2]
[:eacl.fn/retractEntity 1]])
A numeric entity ID can repair peer-side ghosts after an earlier native retraction. A lookup ref that no longer resolves cannot reveal the former entity ID, so it cannot perform that repair.
Do not add relationships involving a target in the same application
transaction that safely retracts it. Prefer delete-object! for very
high-degree targets so cleanup can be batched.
Use the backend's support-descriptor before choosing a
Datahike or DataScript deployment mode. Installation, direct-mode examples,
restore behavior, integrity reports, and repair tools are documented in the
adapter guides:
EACL parses a documented subset of the SpiceDB schema DSL. Use
eacl/write-schema! to define your schema.
EACL's parser requires each relation or permission declaration to end at a
newline; put the next declaration and the definition's closing brace on a later
line. Empty definitions may still use the compact definition user {} form.
(eacl/write-schema! acl
"definition user {}
definition account {
relation owner: user
permission admin = owner
}
definition server {
relation account: account
permission admin = account->admin
}")
Here's a complete example of defining a schema with eacl/write-schema!:
(eacl/write-schema! acl
"definition user {}
definition platform {
relation super_admin: user
}
definition account {
relation platform: platform
relation owner: user
permission admin = owner + platform->super_admin
}
definition server {
relation account: account
relation shared_admin: user
permission reboot = account->admin + shared_admin
}")
This schema defines:
platform resources can have super_admin usersaccount resources can have a platform and owner, with admin permission granted to owners and platform super_adminsserver resources belong to an account and can have shared_admin users, with reboot permission granted to account admins and shared_adminsCreate relationships between existing entities with
eacl/create-relationships!. To combine relationship changes with application
data, see atomic writes, including the published
release's limitation for new entities and tempids.
eacl-caveats-jvm evaluator; ClojureScript clients must supply a compatible
evaluator. See supported expressions and limits.:eacl.pagination/restart-required error. Start the lookup again without the
expired cursor. When using an explicit EACL snapshot, including one selected
with at-exact-snapshot, cursors keep working against relationships that are
valid at the snapshot's captured evaluation time.at-exact-snapshot and continued
cursors require the backend to reconstruct the selected database value.
Ordinary Datomic history and history-enabled Datahike do not age-expire.
History-disabled Datahike can lose a conditionally retained commit and then
returns snapshot-unavailable rather than silently using a newer value.subject#relation subject sets are not supported. Model group membership with explicit group Relationships and arrow permissions when that expresses the required semantics.can? for an authorization decision.delete-object!, or use the optional safe-retraction function — see
Deleting a Secured Entity.:count-limit to bound
counts, and raise recursive traversal limits only after load testing. If a
cached continuation is unavailable, EACL may replay earlier traversal work
to continue a cursor.EACL follows SpiceDB's schema vocabulary and shared authorization semantics, but it is not a byte-for-byte or operational clone:
:minimize-latency. EACL selects the current
immutable database value visible to the local backend connection. SpiceDB may use
an optimized cached revision, so freshness can differ. Use each backend's
own causal token with at-least-as-fresh or at-exact-snapshot when the
distinction matters; tokens and cursors are backend-local.count-resources, count-subjects, a controllable EACL result
cache, and delete-object!, which removes both stored Relationship halves.
Qualified deletion uses bounded native transactions; each transaction removes
both endpoint values and their owned qualifier together. These operations do
not have direct SpiceDB API equivalents.expand-permission-tree refuses cycles (:eacl.permission-tree/cycle-detected)
and depth beyond :permission-tree-limits (:max-depth 50 by default).:permission slot of
expand-permission-tree; can?, check-permission, the lookups and the
counts require a permission (SpiceDB accepts either).:subject/id must also contain
:subject/type. This fails closed instead of interpreting one external ID
across every subject definition.Some of this open-source work was generously funded by my former employer, CloudAfrica.
See Caveats and expiring Relationships for the v8 public APIs, optional JVM evaluator, trusted-clock and cursor semantics, and coordinated rollout.
Clojure
72.3%
Dafny
24.4%
TLA
1.4%