GitHub

EACL is a situated, open-source ReBAC authorization library inspired by SpiceDB.

EACL is built in Clojure and backed by Datomic Pro, Datahike or DataScript.

Authentication (AuthN) Authorization (AuthZ)
Who are you?, i.e. which <subject>? What can <subject> do? (what EACL cares about)

EACL permissions are just data that live next to your Application Data as attributes on entities, hence situated. This has several benefits, mainly reduced latency.

EACL's situated nature makes it suitable for real-time UI view maintenance:

  • Let's say you have 1k-10k online clients. When should they refresh their UI? Every time the database changes? It doesn't scale, especially if those queries are expensive.
  • EACL makes it cheap & easy to compute which online users are affected by an entity change via eacl/lookup-subjects because it is designed to compute recursive viewership.
  • Basically, EACL helps to avoids query amplification without a network hop, while answering authorization questions quickly and correctly.

EACL does not claim to solve the Materialized View problem (related to DDF), but it gets you 95% of the way there, while being fast enough for 1k-10k online users - maybe more.

🦅 EACL is pronounced "EE-kəl", like "eagle" with a k because it keeps a watchful eagle-eye on your permissions.

Is it any good?

Yes. EACL is best-in-class ReBAC authorization for the Clojure ecosystem.

What is EACL good for?

EACL can efficiently answer questions like, "Can <subject> do <permission> on <resource>?" E.g.

(eacl/can? acl (->user "alice") :view (->server "account1") consistency/fully-consistent)
=> true | false ; 0.01ms-10ms.

Or, "Which <resources> can <subject> do <permission> on?" (as-of <10s ago> or newer)

(eacl/lookup-resources acl
  {:subject       (->user "alice")
   :permission    :view
   :resource/type :product
   :first         50
   :consistency   (consistency/at-least-as-fresh "<10 seconds ago token>")})
=> {:data [{:type :product :id "product-1"}
           {:type :product :id "product-7"}
           ...
           {:type :product :id "product-63"}]
    :page-info ...
    :cached? true|false
    ...} ; in ~1-20ms depending on cache, page size & schema complexity.

The :consistency argument is optional. The default is minimize-latency, which means locally-consistent to the Peer.

Refer to the full EACL API.

Overview

  • EACL is inspired by SpiceDB, the most faithful open-source implementation of the Google Zanzibar whitepaper.
    • Zanzibar powers Google Drive, YouTube, Gmail and Google Calendar, serving billions of authorization requests per day for Billions of (capital B) Relationships.
  • EACL is designed as a stepping stone to SpiceDB once you need hyperscale:
    • Unlike SpiceDB, EACL is situated, which has several benefits.
    • SpiceDB is benchmarked against 100 billion Relationships for CheckPermission: 5.8ms P95 at 1M QPS with up to 100B relationships on CockroachDB; EACL makes no such scale promises, but if you adopt the ReBAC data model, you will avoid having to rewrite when you do hit hyperscale.
  • EACL supports the same consistency semantics as SpiceDB, but owing to EACL's situated nature, some modes are backend-specific, i.e. not every backend supports every mode.
  • EACL is fast even without its optional cache.
    • For small-to-medium workloads, EACL is faster than Spice (in my experience), but no official benchmarks are published at this time. Under internal testing, EACL performs well against a database with 1M Relationships and complex, real-world recursive schema.
    • EACL should comfortably handle 10M-100M Relationships, but I haven't benchmarked 10M Relationships yet. Performance will vary on the complexity of your schema, number of Relationships and lookup query page-size.
  • EACL is formally verified using Dafny, TLA+/TLC, and Apalache. In short EACL is IMO, correct:
    • The EACL kernel (decision engine + cache) is generated from formal models, i.e. it will never say "yes" when means "no", and it will never serve stale cache segments that are behind time T as per your request's consistency constraints.
    • Clojure/ClojureScript backend implementations are internally certified, but are not generated from proofs.
    • EACL does not attempt to verify the correctness of its supported backends – that is the database authors' problem.
    • EACL has not been independently audited.

This README is probably too technical – I will simplify it over time with more examples and link to more technical documentation as-needed.

Supported Backends

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.

Datahike backed by S3 is attractive for infrequently-accessed apps, because you can trade latency for reduced storage cost, and it supports serverless to reduce running cost.

Note: DataScript does not store full history, so it has no at-exact-snapshot semantics. Datahike requires a retained commit graph or temporal history to support exact snapshots.

Coming Soon: Datalevin.

The Benefits of Situated Authorization

EACL's situated philosophy aligns with 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:

  1. Reduced Latency: EACL avoids a network hop to an external AuthZ system, but we can await new data from the Transactor if the Peer is behind time T, as requested by consistency semantics.

    Consider that following a mutation, if you want to leverage SpiceDB at_least_as_fresh consistency semantics to do a LookupResources query, you need to:

    1. Hit the DB or cache for the latest ZedToken pertaining to an entity,
    2. Pass the ZedToken to SpiceDB so you can retrieve a consistent page of object IDs,
    3. Hydrate entities from your database using those IDs.

    EACL can skip all that stuff because it's all local, man. As long as the Peer has data valid data as-of time T, so we don't need to wait for anyone, and if we want to make sure, we can use at-least-as-fresh or fully_consistent and the Peer will wait until it has the latest data as-of T, or continue if it has the latest data, via (d/sync conn T).

    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 in the Peer, and that is exactly what EACL does.

  1. 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.

    • Note that DataScript does not store full history, so does not support at-exact-snapshot in the past.
  2. Consistency: Syncing to an external system introduces eventual consistency. With situated AuthZ, queries are at least locally-consistent as-of time T.

    • In single-Peer environments, EACL reads from the database currently visible to the local Peer.
    • In multi-Peer environments, depending on consistency semantics, EACL may block to catch up to the Transactor if the Peer has fallen behind.
  3. Simple Syncing: Relationships are just 3-tuples of [subject relation resource], so there is no impedance mismatch when syncing to SpiceDB at scale.

  4. 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.

  5. Situated is faster for small (~1k-100k relationships) to medium-applications (~1M-10M Relationships):

    • Authorization runs in-process, so entities can be hydrate without a network hop.
    • End-to-end queries don't need to block on network I/O when data is local to the Peer.
  6. 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).

  • Planning for Scale: Avoid a rewrite later by getting your ReBAC data model right the first time.

  • EACL has limitations compared to SpiceDB: mainly, no Caveats (yet), Negation or Intersection operators (yet), and a few other minor differences.

  • One less external dependency to deploy & sync relationships to.

ReBAC: Relationship-based Access Control

In a ReBAC system like EACL, objects (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 two core concepts to model the permission graph: Schema & Relationship.

  1. Schema consists of Relations and Permissions:
    • Relation defines how a <subject> & <resource> can be related via a Relationship.
    • Permission defines which permissions are granted to a subject via a chain of Relationships between subjects & resources.
      • Permissions can be Direct Permissions or indirect, known as Arrow Permissions. An arrow implies a graph traversal.
  2. A Relationship defines how a <subject> and <resource> are related via a named relation, e.g. [(->user alice) :owner (->account "acme")] means that
    • (->user "alice") is the Subject,
    • :owner is the name of the Relation (as defined in the schema)
    • (->account "acme") is the Resource
    • so this reads as (->user "alice") is the :owner of (->account "acme").
    • In EACL, this is expressed as (->Relationship (->user "alice") :owner (->account "acme")), i.e. (Relationship subject relation resource)
    • Subjects & Resources are just maps of {:keys [type id]}, e.g. {:type :user, :id "user-1"}, or (->user "user-1") when using a helper function.

Data Structures

Relationships

EACL Relationships are light. Relationships are stored directly on entities as two tuples:

  • Forward subject->resource tuple: :eacl.v7.relationship/subject-type+relation+resource-type+resource
  • Reverse resource->subject tuple: :eacl.v7.relationship/resource-type+relation+subject-type+subject

To retract Relationships, install & use :eacl.fn/retractEntity or call eacl/delete-relationships! – not :db.fn/retractEntity, or you will leave ghost Relationship tuples lying around. EACL's contract with you is that you need to use EACL's API to maintain Relationships to guarantee cache coherence and a clean database. If you mess with EACL's data structures, it becomes your problem.

But you will probably forget, so there are helpers to clean up ghost tuples. Refer Deleting a permissioned entity.

Relations:

  • :eacl.relation/resource-type
  • :eacl.relation/relation-name
  • :eacl.relation/subject-type
  • :eacl.relation/resource-type+relation-name+subject-type
  • :eacl/relation-version is needed for cache coherence.

Permissions

  • :eacl.permission/resource-type
  • :eacl.permission/permission-name
  • :eacl.permission/source-relation-name
  • :eacl.permission/target-type
  • :eacl.permission/target-name

Permission indices (tuples):

  • Direct Permissions: :eacl.permission/resource-type+permission-name
  • Datomic permission arrows: :eacl.permission/resource-type+source-relation-name+target-type+permission-name
  • Datomic relation arrows: :eacl.permission/resource-type+source-relation-name+target-type+target-name
  • Datomic full key: :eacl.permission/resource-type+source-relation-name+target-type+target-name+permission-name
  • Datahike and DataScript full key: :eacl.permission/full-key

EACL Schema

Internal EACL data:

  • :eacl/id uniquely identifies Relations & Permissions. It's a string to match SpiceDB IDs.
  • :eacl/schema-string stores the valid schema string that was written via eacl/write-schema!.
  • :eacl/schema-version is Datomic's schema generation.
  • :eacl/schema-generation and :eacl/schema-write-fence track schema writes in Datahike and DataScript.
  • :eacl/storage-version identifies Datomic's current Relationship storage model as version 7.
  • :eacl.fn/assert-relation-unused is Datomic's commit-time guard against removing a Relation that still has Relationships.

Consistency Semantics

EACL uses the same four consistency-mode names as SpiceDB, but each adapter advertises only the guarantees its backend can provide:

  • minimize-latency (the default) selects the current immutable database value visible to the local backend connection without an explicit synchronization barrier.
  • at-least-as-fresh selects a database value at least as new as an authenticated EACL mutation token, or waits up to the configured synchronization timeout when the backend can catch up.
  • at-exact-snapshot selects the exact database value named by a token. Datomic supports this while history is available. Datahike supports it when a retained commit graph or temporal history can reconstruct the value. DataScript rejects it.
  • fully-consistent requests the backend's authoritative-head barrier. For Datomic this synchronizes the Peer before selecting a DB. Datahike advertises it only with a direct :self writer. A connection-backed DataScript client serializes selection of the current connection head.

Unsupported modes fail with a typed error instead of silently selecting a weaker mode. EACL's fully-consistent is defined by the configured backend's synchronization boundary; it is not a claim about an external globally distributed system. See Consistency and Zed tokens for usage.

Performance

  • EACL results are stable-discovery ordered. Reachable permission schema from the queried root is compiled into a sealed plan (dense canonical rule ordinals plus a certified static read-cost rank), and a single width-one depth-first reducer walks that plan over ordered backend index scans (seek-datoms on the relationship endpoint tuples), admitting each (node, entity) exactly once. It avoids both recursive Datalog materialization and persisted grant caches. Lookup results are returned in the plan's stable first-discovery order — deterministic for one immutable snapshot, schema and query, but not a global entity-ID sort. Refer to docs/stable-discovery-engine.md.
  • EACL is fast, but makes no strong performance claims at this time. For typical workloads, EACL should be as fast as, or faster than, SpiceDB. EACL is not meant for hyperscalers.
  • EACL is internally benchmarked against ~800k permissioned resources with good latency (5-30ms per query). You can scale Datomic Peers horizontally and dedicate peers to EACL as needed.
  • The performance goal for EACL is to handle 10M permissioned entities with real-time performance.
  • EACL should be good for small (~10k-100k Relationships) to medium-scale (250k-1M Relationships). You can scale Peers horizontally and may never need to migrate from EACL to SpiceDB.
  • EACL does not support all SpiceDB features. Please refer to the limitations section to decide if EACL is right for you.
  • EACL uses a bounded, client-private cache. Repeated operations reuse complete answers at the same immutable snapshot (and, when the proof-backed dependency check passes, across unrelated transactions); continued pages reuse the latest engine checkpoint for their exact snapshot; sealed plans are cached per source and basis. The cache never changes authorization semantics and can be disabled globally or per request. See Caching.
  • Lookup cursors are result edges (the boundary result's one-based ordinal and identity, bound to the sealed plan's fingerprint) carried inside an authenticated envelope. A continued page resumes from the client-private latest checkpoint for that exact snapshot when one is retained, and otherwise replays the authenticated prefix deterministically against the same snapshot before publishing anything.
  • A first page costs the reader roughly the index scans on the cheapest certified path to the first results (the reducer follows the lowest static read-cost alternatives first), not the realization of every union branch. Continuation hits make a sequential walk approximately linear in traversed work; a continuation miss deterministically replays the prefix against the same exact snapshot. Counts exhaust the same reducer and read its scalar discovered count; pass :count-limit to bound that work. Subjects are typically sparse compared to resources, i.e. 1k users will have access to 1M resources – rarely the other way around.

Public cursors are opaque, authenticated, and tied to the query and database snapshot that created them. A cursor walk stays on that snapshot even when the current database advances. Cursors have no age expiry unless :cursor-ttl-seconds is configured. If a conditionally historical backend can no longer reconstruct the selected value, EACL returns a typed snapshot-unavailable error; ordinary Datomic history and history-enabled Datahike do not expire a cursor merely because it is old.

Project Status

Warning

EACL is under active development. I try hard not to introduce breaking changes, but if data structures change, the major version will increment. The current version is the EACL 8.0 release candidate. The four 8.0.0-SNAPSHOT artifacts are available from Clojars under the verified dev.eacl group.

Modules

Choose the adapter for your backend. It brings in the shared EACL module at the same version:

;; Datomic Pro
{:deps {dev.eacl/eacl-datomic {:mvn/version "8.0.0-SNAPSHOT"}}}
;; Datahike
{:deps {dev.eacl/eacl-datahike {:mvn/version "8.0.0-SNAPSHOT"}}}
;; DataScript
{:deps {dev.eacl/eacl-datascript {:mvn/version "8.0.0-SNAPSHOT"}}}
;; Core-only consumers and backend authors
{:deps {dev.eacl/eacl {:mvn/version "8.0.0-SNAPSHOT"}}}

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"}}}

Development from source

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 default is Java 26; source builds may target Java 8 through Java 26, subject to their backend and application dependencies. See formal/README.md for tool versions and the full verification commands.

EACL does not select a logging implementation. Applications remain responsible for their own logging backend and configuration.

For module selection, current capability differences, cache mutation rules, and recursive controls, see the backend guide. Backend authors should also read the adapter boundary.

Schema & Relationships

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

     permission admin = owner
   }

   definition product {
     relation account: account

     permission edit = account->admin
     permission view = account->admin + account->viewer
   }")

This schema defines:

  • An account can have owner and viewer users, with admin permission granted to owners
  • A product belongs to an account, with edit permission for account admins and view permission for account admins and viewers

In SpiceDB schema DSL, + means union (OR-logic). EACL does not support exclusion (-) or intersection (&) yet.

EACL API

The IAuthorization protocol in modules/eacl/src/eacl/core.cljc defines an idiomatic Clojure interface that maps to and extends the SpiceDB gRPC API:

Permission Checks

(eacl/can? acl subject permission resource)
=> true | false
(eacl/check-permission acl
  {:subject subject, :permission permission, :resource resource})
=> {:allowed? true, :cached? boolean, :cache-basis ...}

Lookups

(eacl/lookup-resources acl filters)
=> {:data [resources...] :page-info {...} :cached? boolean :cache-basis ...}
(eacl/lookup-subjects acl filters)
=> {:data [subjects...] :page-info {...} :cached? boolean :cache-basis ...}

Counting

(eacl/count-resources acl filters)
=> {:count 42, :limit -1, :cached? boolean, :cache-basis ...}
(eacl/count-subjects acl filters)
=> {: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.

Relationship Maintenance

  • (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 before any endpoint is resolved: an unknown definition, relation, or a subject type the relation does not declare fails with the same typed :eacl/unknown-definition / :eacl/unknown-relation-or-permission errors the read operations use.
    • :create fails with :eacl/relationship-conflict when the relationship already exists, and the check is decided inside the transaction on every backend (Datomic: a transactor-side relation stamp CAS with re-planning; DataScript and Datahike with the default in-process writer: a transaction function), so two racing :creates of one relationship produce exactly one success. A Datahike remote writer cannot transport a transaction function and keeps the plan-time check only. :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 permissioned entity — see Deleting a permissioned entity.

All list APIs use the v8 Relay pagination contract:

  • Forward: pass :first and optionally :after.
  • Backward: pass :last and optionally :before.
  • Responses include :page-info with :start-cursor, :end-cursor, :has-next-page?, and :has-previous-page?.
  • Lookup cursors paginate in the sealed plan's stable first-discovery order; a page size change is rejected as an incompatible cursor rather than silently re-windowed.

Deadlines and cooperative cancellation

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.

Schema Maintenance

  • (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.

Permission-tree expansion

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 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 (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.

Example Queries

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.

Quickstart

Datomic Pro

Add the Datomic adapter dependency to your deps.edn file:

{:deps {dev.eacl/eacl-datomic {:mvn/version "8.0.0-SNAPSHOT"}}}
(ns my-eacl-project
  (:require [datomic.api :as d]
            [eacl.core :as eacl :refer [->Relationship spice-object]]
            [eacl.datomic.core]
            [eacl.datomic.schema :as schema]))
; Create an in-memory Datomic database:
(def datomic-uri "datomic:mem://eacl")
(d/create-database datomic-uri)
; Connect to it:
(def conn (d/connect datomic-uri))
; Install EACL's current Datomic Relationship schema:
@(d/transact conn schema/v7-schema)
; Make an EACL client that satisfies the `IAuthorization` protocol:
(def acl
  (eacl.datomic.core/make-client
   conn
   {:object-id->lookup-ref (fn [obj-id] [:eacl/id obj-id])
    :entid->object-id (fn [db eid] (:eacl/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
     permission update = admin
   }

   definition product {
     relation account: account

     permission edit = account->admin
   }")
; Transact some Datomic entities with a unique ID, e.g. `:eacl/id`:
@(d/transact conn
  [{:eacl/id "user-1"}
   {:eacl/id "user-2"}
   {:eacl/id "account-1"}
   {:eacl/id "product-1"}
   {:eacl/id "product-2"}])
; Define some convenience methods over spice-object:
; `eacl.core/spice-object` constructs a SpiceObject from `type`, `id`, and an
; optional subject relation. EACL queries do not support subject relations.
(def ->user (partial spice-object :user))
(def ->account (partial spice-object :account))
(def ->product (partial spice-object :product))
; Write some Relationships to EACL. For same-transaction entity and
; Relationship creation, use the explicit tx-relationship example below:
(eacl/create-relationships! acl
  [(eacl/->Relationship (->user "user-1") :owner (->account "account-1"))
   (eacl/->Relationship (->account "account-1") :account (->product "product-1"))])
; Run some Permission Checks with `can?`:
(eacl/can? acl (->user "user-1") :update (->account "account-1"))
; => true
(eacl/can? acl (->user "user-2") :update (->account "account-1"))
; => false
(eacl/can? acl (->user "user-1") :edit (->product "product-1"))
; => true
(eacl/can? acl (->user "user-2") :edit (->product "product-1"))
; => false
; You can enumerate the :product resources a :user subject can :edit via `lookup-resources`:
(eacl/lookup-resources acl
  {:subject       (->user "user-1")
   :permission    :edit
   :resource/type :product
   :first         1000})
; => {:data [{:type :product, :id "product-1"}]
;     :page-info {:start-cursor "eacl4_..."
;                 :end-cursor "eacl4_..."
;                 :has-next-page? false
;                 :has-previous-page? false}
;     :cached? false
;     :cache-basis ...}

Datahike Quickstart

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-SNAPSHOT"}}}
(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))
; Make an EACL client that satisfies the `IAuthorization` protocol:
(def acl (eacl.datahike/make-client conn {}))
; 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 `:eacl/id` values:
(d/transact conn
  [{:eacl/id "user-1"}
   {:eacl/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.

DataScript Quickstart

For server-side or browser demos, use the DataScript adapter:

{:deps {dev.eacl/eacl-datascript {:mvn/version "8.0.0-SNAPSHOT"}}}
(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))
(def acl (eacl.datascript/make-client conn {}))
(ds/transact! conn
  [{:db/id -1 :eacl/id "user-1"}
   {:db/id -2 :eacl/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 Schema

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
   }")

Schema Validation

write-schema! validates your schema and provides informative error messages. An invalid schema throws and nothing is transacted:

  • Parse validation: unparseable schema strings and duplicate definition/relation declarations throw. // and /* */ comments are supported.
  • Reference validation: all relations and permissions must reference valid definitions. Arrow targets must exist on every subject type of the source relation.
  • Orphan protection: relations with existing relationships cannot be deleted.
  • Empty-schema guard: the public 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.
  • Unsupported feature detection: rejects SpiceDB features unsupported by EACL (see Limitations)

Schema Updates

When you call write-schema! with a modified schema, EACL:

  1. Parses the new schema
  2. Computes deltas (additions/retractions) against existing schema
  3. Validates retractions won't orphan existing relationships
  4. Transacts changes atomically

Modelling Relations

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"))

Permission Schema: Direct Relations

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?

Creating Relationships

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.

Permission Checks

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

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:

  1. (->user "alice") is the :owner of (->account "acme"), and
  2. (->account "acme") is the :account for (->product "SKU-123"),
  3. EACL can traverse the permission graph from user -> account -> product to derive that Alice has the :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.

EACL ID Configuration

SpiceDB uses strings for external subject and resource IDs, whereas the bundled EACL adapters traverse backend-native entity IDs internally. EACL lets you configure how internal IDs are converted to external IDs 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 bundled adapter's make-client accepts :entid->object-id/:object-id->lookup-ref functions for converting between internal entity IDs and external object IDs. The following example uses Datomic.

It is common to attach a unique UUID to permissioned entities for exposing them externally, or you can convert external->internal at your call sites. Here is how you can configure EACL to convert to/from a unique attribute named :your/id:

(def acl (eacl.datomic.core/make-client conn
           {:entid->object-id (fn [db eid] (:your/id (d/entity db eid)))
            :object-id->lookup-ref (fn [obj-id] [:your/id obj-id])}))

Note that this attribute should have property :db/unique :db.unique/identity.

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}. 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 TTL and capacity remain independent of cursor age.

Caching

Caching is automatic, bounded, and private to each EACL client. Cache data is never written to the application database. EACL first looks for an answer from the exact immutable database value selected by the request. Authenticated at-exact-snapshot requests may reuse a completed answer only when the full source/lifecycle, native locator, ordinary-view, adapter/identity, engine, request, result-shape, demand, and limit identity matches. Exact requests never use managed proof-backed lifting. Ordinary current requests may reuse an older answer only when EACL establishes that the relevant schema and relationships have not changed. If it cannot establish either condition safely, it runs the authorization query normally.

A long-running request can continue using the immutable database value it started with while newer requests see newer data. EACL does not promise cache reuse for arbitrary as-of, since, filtered, speculative, or caller-constructed database values.

Cache coherence is only guaranteed as long as authorization mutations use EACL's supported APIs:

  • Change schema with eacl/write-schema!.
  • Add/retract relationships via EACL relationship APIs
  • Retract permissioned entities via :eacl.fn/retractEntity.

Ordinary application datoms that do not affect authorization are unrestricted. If an application changes EACL schema or relationship storage directly, splits EACL transaction data, changes the identity of a permissioned object outside the documented contract, or leaves relationships behind during deletion, cached authorization results may be stale.

To recover after an unsupported authorization mutation:

  1. Stop affected authorization traffic in every process.
  2. Repair the schema, identity, or relationship data through a supported EACL path.
  3. Expire or recreate every affected EACL client in every process.
  4. Resume traffic only after repair and cache rotation are complete.

Cache expiry removes remembered answers; it does not repair ghost relationships. Rewriting an unchanged schema is not a cache flush.

Most applications need no cache configuration. Disable caching for one client with eacl.cache/no-cache:

(require '[eacl.cache :as eacl-cache])
(def acl (eacl.datomic.core/make-client conn {:cache eacl-cache/no-cache}))

Or bypass the cache for one request:

(eacl/can? acl
           {:subject alice
            :permission :view
            :resource doc
            :cache? false})

Use eacl/check-permission when a caller needs cache provenance in addition to the Boolean decision:

(eacl/check-permission
 acl
 {:subject alice
  :permission :view
  :resource doc})
;; => {:allowed? true, :cached? false, :cache-basis ...}

Inspect or expire a client through its backend API:

(eacl.datomic.core/cache-stats acl)
(eacl.datomic.core/expire-cache! acl)
(eacl.datahike.core/cache-stats acl)
(eacl.datahike.core/expire-cache! acl)
(eacl.datascript.core/cache-stats acl)
(eacl.datascript.core/expire-cache! acl)

After a database restore, reset, branch replacement, or other operation that can replace history, expire or replace every affected client before serving requests. Multi-process deployments that exchange cursors or tokens must coordinate the source-lifecycle rotation described in the cache guide.

Custom ID converters remain local to one client unless every participating process uses the same deterministic converter and stable adapter fingerprint.

For cache tuning, custom identity codecs, metrics, proof availability, and the full recovery and correctness model, read Cache behavior and coherence.

Consistency and Zed tokens

Authorization defaults to the immutable database value currently visible to the local backend. Mutation responses include an authenticated revision token. Reads can request stronger behavior when the backend supports it:

(require '[eacl.spicedb.consistency :as consistency])
;; Default: current local database value.
(eacl/can? acl subject :view resource
           consistency/minimize-latency)
;; Synchronize before selecting the database value.
(eacl/can? acl subject :view resource
           consistency/fully-consistent)
;; Read at least as new as an earlier EACL mutation.
(eacl/can? acl subject :view resource
           (consistency/at-least-as-fresh write-token))
;; Read the exact historical snapshot named by a token, if available.
(eacl/can? acl subject :view resource
           (consistency/at-exact-snapshot prior-token))

Datomic exact selection treats an authentic same-source token ahead of the local Peer as replica lag: it performs bounded (d/sync conn T) when needed, verifies the returned basis, and always evaluates (d/as-of db T). A locally available T skips synchronization. Ordinary unreplaced Datomic history has no EACL cursor-retention window.

EACL-created Datahike databases retain temporal history by default. External history-enabled Datahike stores can reconstruct exact revisions after commit record collection; history-disabled stores advertise only conditional exact selection while a named commit is retained. DataScript does not provide general historical snapshot reconstruction. If a backend cannot satisfy the requested guarantee, EACL returns a typed error rather than silently selecting a different snapshot.

Treat Zed tokens as opaque. A token proves freshness only for its original backend, database, branch, and lifecycle. For a token returned through an untrusted frontend, the backend should normally choose at-least-as-fresh. Do not let a frontend request exact historical authorization without a separate authorization decision.

Multi-process deployments must configure the same cursor and Zed-token verification keys on every instance that accepts the same tokens:

(def acl
  (eacl.datomic.core/make-client
   conn
   {:security-key "32+ bytes of shared secret key material"
    :security-kid :cursor-2026-07
    :zed-token-keyring {:zed-2026-06 old-zed-root
                        :zed-2026-07 current-zed-root}
    :zed-token-kid :zed-2026-07}))

Retain old verification keys for the intended token lifetime during key rotation. The default keys are client-local, so default cursors and tokens do not survive restarts or load balancing.

See the backend guide for exact capabilities, synchronization timeouts, checkpoints, key rotation, and recursive traversal controls.

Unknown object IDs

EACL's bundled situated backends require object IDs to resolve to application entities:

  • Reads (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.
  • Writes (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.

Deleting a permissioned entity

Important

Do not call the backend's ordinary entity-retraction operation on a permissioned 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 and DataScript implementations use one transaction.

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

Datomic example:

(require '[datomic.api :as d]
         '[eacl.datomic.safe-retraction :as safe-retraction])
;; Privileged, idempotent deployment step.
(safe-retraction/install! conn)
@(d/transact
  conn
  (safe-retraction/retract-entity-tx-data [:eacl/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 safe-retraction/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:

Schema Syntax

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
   }")

Example Schema

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 users
  • account resources can have a platform and owner, with admin permission granted to owners and platform super_admins
  • server resources belong to an account and can have shared_admin users, with reboot permission granted to account admins and shared_admins

Now you can transact relationships. The usual way is eacl/create-relationships! against existing entities (see Quickstart). To create entities and relationships in the same transaction, use eacl.datomic.impl/tx-relationship with {:allow-tempids? true} — tempid pass-through is opt-in because a typo'd ID would otherwise silently create a ghost entity:

(require '[eacl.datomic.impl :as impl])
(let [db (d/db conn)]
  @(d/transact conn
    (concat
      [{:db/id   "user1-tempid"
        :eacl/id "user1"}
       {:db/id   "account1-tempid"
        :eacl/id "account1"}]
      (impl/tx-relationship db
        (impl/Relationship (spice-object :user "user1-tempid") :owner (spice-object :account "account1-tempid"))
        {:allow-tempids? true}))))

Limitations, Deficiencies & Gotchas:

  • Exact snapshots require backend history: 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.
  • History destruction is a lifecycle boundary: Datomic excision and Datahike purge/cutoff, branch force, reset, restore, or equivalent destructive replacement require quiescing affected traffic, completing the operation, rotating the shared source lifecycle and affected clients/caches, and then resuming with deliberate token/cursor key-version policy.
  • No negation operator: EACL only supports Union (+) permission operators, not - negation, e.g.
    • permission admin = owner + shared_admin is valid,
    • but permission admin = owner - banned_member is not.
  • Arrow syntax is limited to one level of nesting, e.g.
    • permission arrow = relation->via-permission is supported,
    • but permission arrow = relation->subrelation->permission is not. The target permission may itself contain an arrow, so longer graph traversals can be modelled through named permissions.
  • SpiceDB subject#relation subject sets are not supported. Model group membership with explicit group Relationships and arrow permissions when that expresses the required semantics.
  • Expansion is structural, not a membership proof: permission trees preserve relation, permission, union, and arrow boundaries. Use can? for an authorization decision.
  • Cache coherence requires EACL authorization writers: Bypassing EACL for schema, relationship, permissioned identity, or deletion mutations can leave cached answers stale. Stop affected traffic, repair the data, and expire every affected client before resuming.
  • Deleting entities: Native entity retraction does not remove the relationship stored at the other endpoint. Delete relationships first with delete-object!, or use the optional safe-retraction function — see Deleting a permissioned entity.
  • Recursive permissions have safety limits: use :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.
  • Return order: EACL makes no global, lexical, or cross-backend ordering promise. For a 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. This stability is sufficient for a cursor walk with no movement or duplicates; sort by a domain key after reading if presentation order matters.

Differences from SpiceDB

EACL follows SpiceDB's schema vocabulary and shared authorization semantics, but it is not a byte-for-byte or operational clone:

  • Result order is backend-defined. Compare lookup and relationship results as sets unless your application explicitly sorts them; never compare EACL and SpiceDB page membership or cursor bytes.
  • EACL cursors bind the selected native revision and its dependency/order proof. A cursor walk stays on that exact snapshot. If the backend cannot reconstruct it, EACL fails closed. A relevant write does not silently change page membership midway through a cursor walk.
  • Omitted consistency means :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.
  • EACL provides count-resources, count-subjects, a controllable EACL result cache, and delete-object!, which removes both stored Relationship halves. Datomic commits high-degree deletion in batches of 1,000; Datahike and DataScript use one atomic transaction. These do not have direct SpiceDB API equivalents.
  • EACL currently supports a smaller schema subset: unions and its documented arrow forms, but not caveats, wildcard subjects, expiration, intersections, exclusions, or subject relations.
  • EACL evaluates relationship cycles as a fixed point and has no separate dispatch-depth limit for checks, lookups, and counts. These operations remain subject to configured traversal work limits. SpiceDB uses a configurable dispatch-depth limit, which defaults to 50 and can return a maximum-depth error for deep or cyclic data, so the two systems can differ on those graphs. Only expand-permission-tree refuses cycles (:eacl.permission-tree/cycle-detected) and depth beyond :permission-tree-limits (:max-depth 50 by default).
  • Object identifiers are arbitrary non-empty strings and schema names follow the parser's grammar rather than SpiceDB's exact identifier and name grammars. A schema or dataset that must also load into SpiceDB should follow SpiceDB's stricter identifier and schema-name rules rather than relying on EACL's broader parser.
  • A relation name is accepted only in the :permission slot of expand-permission-tree; can?, check-permission, the lookups and the counts require a permission (SpiceDB accepts either).
  • A relationship filter containing :subject/id must also contain :subject/type. This fails closed instead of interpreting one external ID across every subject definition.

Funding

Some of this open-source work was generously funded by my former employer, CloudAfrica.

Licence

  • EACL is licensed under the Eclipse Public License v2.0.

Read the original on github.com ↗