| name | usage-control-policy |
| description | Evaluate W3C ODRL 2.2 usage-control policies over RDF with the opt-in sparq-policy crate — parse an ODRL Set/Offer/Agreement into a typed Policy of Permission/Prohibition rules (action, target, assignee, Constraint, Duty), then evaluate an access Request to a fail-closed ALLOW/DENY Decision. Use when gating a query/asset by purpose, recipient, time window, count, or a duty obligation; when mapping ODRL to the sparq-solid WAC/ACP allow-deny model; or when wiring usage control above access control. Single-node base case; federated ODRL-to-MPC disclosure is deferred. |
usage-control-policy
sparq-policy is the declarative usage-control layer above access control. Where sparq-solid answers "may this agent read graph G?", sparq-policy answers "may this party use this asset for purpose P, with obligation O, until time T, disclosing only to recipient R?" — by evaluating a W3C ODRL 2.2 policy.
It parses an ODRL policy from RDF into a typed model (Policy → {permissions, prohibitions}, each Rule carrying an Action, target, assignee/assigner, Constraints and Dutys) and evaluates an access Request to a fail-closed Decision { allow, matched_rules, unmet_constraints }. This is the single-node base case: ODRL over one node's data, reducing to the same allow/deny shape sparq-solid enforces.
Scope. Single-node only. The headline federated-disclosure / ODRL→MPC composition (per-node ODRL drives the sparq-mpc disclosed-vs-hidden split; ODRL Duty → ZK proof obligation) is deferred — it inherits the MPC honest-majority/LAN envelope and the open ZK-soundness remediation. See research/feature-research-odrl-policy.md.
Prerequisites
Quickstart
Parse an ODRL Set and evaluate a time-windowed permission. Compiles and runs against the current API.
use sparq_policy::{evaluate, parse_policy_str, Request, Value};
let ttl = r#"
@prefix odrl: <http://www.w3.org/ns/odrl/2/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
<urn:pol/1> a odrl:Set ; odrl:permission [
odrl:action odrl:read ;
odrl:target <urn:asset/x> ;
odrl:assignee <https://alice.ex/me> ;
odrl:constraint [ odrl:leftOperand odrl:dateTime ; odrl:operator odrl:lteq ;
odrl:rightOperand "2026-12-31T00:00:00Z"^^xsd:dateTime ] ;
odrl:constraint [ odrl:leftOperand odrl:purpose ; odrl:operator odrl:eq ;
odrl:rightOperand <urn:purpose/research> ] ] .
"#;
let policy = parse_policy_str(ttl, "turtle").expect("parse");
const ODRL: &str = "http://www.w3.org/ns/odrl/2/";
let req = Request::new(format!("{ODRL}read"))
.on("urn:asset/x")
.by("https://alice.ex/me")
.with(format!("{ODRL}dateTime"), Value::DateTime("2026-06-16T09:00:00Z".into()))
.with(format!("{ODRL}purpose"), Value::Iri("urn:purpose/research".into()));
let d = evaluate(&policy, &req);
assert!(d.allow);
assert_eq!(d.matched_rules.len(), 1);
The model
Policy — permissions: Vec<Rule>, prohibitions: Vec<Rule>, optional iri, and conflict: Option<ConflictStrategy> (the parsed odrl:conflict strategy — see conflict_admissibility under static analysis). Set/Offer/Agreement parse identically (subtype affects contracting, not single-node eval).
Rule — action: Action, target/assignee/assigner: Option<String>, constraints: Vec<Constraint>, logical_constraints: Vec<LogicalConstraint>, duties: Vec<Duty> (duties on permissions only). The atomic constraints and the compound logical_constraints are all ANDed.
Action — full IRI. odrl:use is the ODRL umbrella action: a permission for use permits any requested action in the use subtree of the ODRL 2.2 action hierarchy — i.e. everything except the disjoint ownership-transfer subtree (odrl:sell/odrl:give/odrl:transfer). So a use permission grants read/write/modify/… but not sell. All non-use actions match by exact IRI. [OPUS-4.8] sq-euhr3.
Constraint — atomic (left: leftOperand-IRI, operator, right: Value). The request supplies the actual value for left in its context; the constraint's right is the bound.
LogicalConstraint — a compound odrl:LogicalConstraint: a LogicalOperator (And/Or/Xone) over a set of operand ConstraintNodes (each atomic or nested compound). [OPUS-4.8] sq-a0zef.
Duty — an obligation (action) that must be in the request's discharged_duties set, or the permission is denied.
Evaluation semantics (fail-closed)
- A
Rule matches when its action permits the request action (per the ODRL action hierarchy — use subsumes its sub-actions but not the transfer subtree), its target/assignee (if set) agree (by IRI equality or collection membership — see below), and every atomic Constraint and compound LogicalConstraint is satisfied (all ANDed).
- A
Permission grants iff it matches and all its Dutys are discharged.
- A matching
Prohibition overrides any permission (carve-out — deny-overrides, the one conflict strategy implemented; NOT a spec default: the ODRL 2.2 IM default for an unset odrl:conflict is invalid, and the ODRL Formal Semantics CG report's conflict machinery is explicitly pending — see the crate README's ODRL conformance note).
- DENY by default: no matching+discharged permission, or any matching prohibition ⇒ DENY. An empty/malformed policy denies everything; a constraint with no request value, an unknown operator, or a structurally incomplete constraint all fail closed.
Constraint operators
eq, neq, lt, lteq, gt, gteq, isPartOf (set membership: right is a |/space/comma-separated set — or, for the taxonomic dimensions purpose/spatial, a transitive subsumption match over a request-supplied closure, see below), isA (identity, ≈ eq), isAnyOf (the value equals at least one member of the right-operand set — same set encoding and matching as isPartOf, incl. the taxonomic subsumption case; [FABLE-5] sq-uaz85), isNoneOf (the value equals none of them — the negative dual; a sub-value of an excluded member is also excluded under a supplied taxonomy, missing evidence stays unprovable — never vacuously satisfied — and a numeric/dateTime operand fails closed rather than negating a lexical representation gap). Numeric (xsd:integer/decimal/double/…) and xsd:dateTime/date operands compare by magnitude/instant; everything else by IRI/string value. Order comparison on non-orderable values is false (fail-closed). dateTime ordering compares the real UTC instant each lexical form denotes — mixed timezone offsets are normalized to UTC before comparing (parse_instant().cmp(), sq-qj2q; an unparseable operand is incomparable → fail-closed), so 2026-06-16T13:00:00+02:00 (= 11:00Z) correctly orders before 2026-06-16T12:00:00Z. The single offset-aware comparator is sparq_policy::cmp_datetime (one source of truth; doctest cmp_datetime_orders_by_instant_not_lexical).
Multi-valued rightOperand ([FABLE-5] sq-ueydm): the parser folds an RDF-list right operand (odrl:rightOperand ( <a> <b> ) — the rdf:first/rdf:rest chain) and a multi-object one (odrl:rightOperand <a>, <b>) into that same |-separated set encoding, so the set-relation operators (isPartOf/isAnyOf/isNoneOf) see the full member set (previously first-binding-wins dropped members / degraded a list to an unmatchable blank-node string). A single value — including a one-element list — keeps the typed (numeric/dateTime) path unchanged. Fail-closed degradations: a multi-value under a non-set operator (eq, lt, …) is ambiguous → the unsatisfiable guard; a member carrying a separator character (|/whitespace/,) cannot be encoded faithfully → the whole constraint is unsatisfiable (never split into unintended members); a malformed cyclic list terminates and still yields its complete member set; an empty list (rdf:nil) stays the unmatchable nil IRI. Tests: tests/odrl_right_operand_sets.rs. A list-valued combinator object (odrl:or ( … )) is NOT folded (operands are several objects of the combinator property, per the suite's form) — that gap is beaded separately.
odrl:spatial region enforcement + region isPartOf trees — [OPUS-4.8] sq-wukl
A spatial constraint gates a rule to a geographic region (spatial isPartOf <country/EU>, "anywhere in the EU"). The new left-operand IRI is ODRL_SPATIAL (odrl:spatial); supply the request's region with .with(ODRL_SPATIAL, Value::Iri(region)), gated through the same evaluate constraint path as every other dimension.
The spatial dimension is taxonomic, so it reuses the same caller-supplied subsumption closure the DPV purpose taxonomy uses — there is no separate spatial evidence channel. Declare the region isPartOf tree with .with_purpose_subsumption(narrower, broader) (one edge) or .with_purpose_taxonomy([(n, b), …]) (bulk), and a spatial isPartOf <EU> constraint is then satisfied by a request whose stated region is the EU or transitively part-of it — Berlin ⊑ DEU ⊑ EU.
- Honesty / fail-closed: the tree is the requester's asserted subsumption, never invented. With no edge supplied,
spatial matching is exact membership (fully backward compatible) and a sub-region does not grant a broad-region permission — a missing edge fails closed, like a missing context value. Cycle-safe (the closure tolerates a malformed A ⊑ B ⊑ A).
- Audit:
spatial_status(&rule, &request) -> SpatialMatch (Satisfied/DefinitelyUnsatisfied/Unprovable/NotConstrained) is the spatial twin of purpose_status — it runs the same subsumption-aware path evaluate does.
use sparq_policy::{evaluate, Request, Value, ODRL_SPATIAL};
// policy: distribute spatial isPartOf <country/EU>
let req = Request::new("http://www.w3.org/ns/odrl/2/distribute").on("urn:asset/x")
.with(ODRL_SPATIAL, Value::Iri("urn:country/DEU".into()))
.with_purpose_subsumption("urn:country/DEU", "urn:country/EU"); // DEU ⊑ EU
assert!(evaluate(&policy, &req).allow); // a sub-region of the named region grants
Building the request
Request::new(action_iri) then chain .on(target), .by(party), .with(left_operand_iri, Value) for each context dimension (dateTime, purpose, recipient, count, spatial, …), .with_purpose_subsumption(narrower, broader) / .with_purpose_taxonomy([(n, b), …]) per asserted subsumption edge (the DPV-purpose taxonomy / spatial region trees, above — one closure for both taxonomic dimensions), .with_party_membership(party, collection) / .with_asset_membership(asset, collection) (and bulk …_memberships([…])) per ODRL collection-membership edge (above), and .discharge(duty_action_iri) per discharged duty. Value is Iri | Str | Num(f64) | DateTime(String). For purpose specifically, prefer the first-class .for_purpose(Value) (sugar over .with(ODRL_PURPOSE, ..); read it back via req.purpose()); for the evaluation time prefer .at(instant) (sugar over .with(ODRL_DATETIME, Value::DateTime(..)); read it back via req.request_time()).
The odrl:use umbrella + ODRL action hierarchy — [OPUS-4.8] sq-euhr3
odrl:use is the ODRL "umbrella" action, but it does not subsume every action — only those the ODRL 2.2 vocabulary places odrl:includedIn use (39 actions transitively: read, display, print, play, modify, delete, aggregate, reproduce, …). The one disjoint top-level branch is the ownership-transfer subtree — odrl:sell and odrl:give are includedIn odrl:transfer, not use — so a use permission grants a read/write request but is inactive for a sell/give/transfer request.
- This is
Action::permits: use permits a requested action iff it is use itself or not in the transfer subtree (the is_transfer_subtree set: transfer/sell/give); every other action — including non-vocabulary actions such as odrl:write — is treated as a use-subtree action (the broad "use this asset" reading). It matches the SolidLab reference evaluator (suite cases 007/008/009 Active vs 010/017 Inactive).
- A non-
use action still matches only its own IRI exactly (read does not cover write).
odrl:PartyCollection / odrl:AssetCollection membership — [OPUS-4.8] sq-k7itg
A rule whose odrl:assignee is an odrl:PartyCollection (or whose odrl:target is an odrl:AssetCollection) matches a request whose party (or asset) is a member of that collection — member odrl:partOf collection — not only a request whose party/asset IS the collection IRI. The membership relation is request-supplied evidence (read out of the state-of-the-world graph), declared with .with_party_membership(party, collection) / .with_asset_membership(asset, collection) (and the bulk .with_party_memberships([…]) / .with_asset_memberships([…])).
- Sound / fail-closed: membership draws ONLY on the caller-supplied set — never inferred. With no membership edge, assignee/target matching is byte-for-byte the exact-IRI base case, so a non-member is denied and access is never widened. The edge must name the rule's exact collection (membership in a different collection does not match).
use sparq_policy::{evaluate, Request};
// policy: ex:partyCollection (a PartyCollection) may read ex:x.
let req = Request::new("http://www.w3.org/ns/odrl/2/read")
.on("http://example.org/x").by("http://example.org/alice")
.with_party_membership("http://example.org/alice", "http://example.org/partyCollection");
assert!(evaluate(&policy, &req).allow); // alice is a member → granted
odrl:LogicalConstraint — and / or / xone compound constraints — [OPUS-4.8] sq-a0zef
A rule's odrl:constraint may point at a compound odrl:LogicalConstraint — a combinator (odrl:and/odrl:or/odrl:xone) over a set of operand constraints (each itself atomic or a nested LogicalConstraint, so an or of ands — a disjunction of time windows — is supported). The parser routes these into Rule::logical_constraints; the evaluator ANDs them with the atomic constraints.
odrl:and — satisfied iff every operand holds. odrl:or — iff at least one holds. odrl:xone — iff exactly one holds.
- Fail-closed (load-bearing): each operand is three-valued (
Satisfied/DefinitelyUnsatisfied/Unprovable for missing evidence). An and/xone is never claimed Satisfied on an Unprovable operand — a compound with an unprovable operand does not silently pass (an and with an unprovable operand and no definite-false is Unprovable → no grant; a xone with any unprovable operand is Unprovable). An empty/malformed compound (e.g. an or over only an unknown-operator operand) fails closed.
use sparq_policy::{evaluate, Request};
// policy: read permitted when (dateTime > 2024-01-01) AND (dateTime < 2024-12-31).
let req = Request::new("http://www.w3.org/ns/odrl/2/read").on("urn:asset/x")
.at("2024-06-15T12:00:00Z");
assert!(evaluate(&policy, &req).allow); // inside the AND-window
// no time evidence → the AND is unprovable → fail-closed (no grant).
odrl:purpose enforcement (faithful, fail-closed) — [OPUS-4.8] sq-q56r
A purpose constraint restricts a rule to a stated purpose of use. The request carries its purpose as evidence (.for_purpose(Value)), and it is gated through the same evaluate constraint path as every other dimension — so it is actually checked end-to-end (no claimed-but-unchecked enforcement), including through the opt-in bridge's real accessible / query_as path.
- Match → grant; mismatch → deny; missing purpose → fail-closed. A request stating no purpose is unprovable: a purpose-gated permission does not grant, and a purpose-gated prohibition is not withdrawn. "No purpose stated" is never treated as "any purpose allowed".
- Match semantics (the boundary — do not over-claim): exact IRI/string equality (
eq/isA), or membership in the explicit isPartOf purpose set the constraint names, or neq (≠ the named one; still requires a stated purpose).
- DPV / purpose-taxonomy subsumption — [OPUS-4.8] sq-z3ve. Supply the request a purpose taxonomy with
.with_purpose_subsumption(narrower, broader) (one skos:broader/rdfs:subClassOf/dpv:isSubTypeOf edge) or the bulk .with_purpose_taxonomy([(n, b), …]), and a purpose constraint naming B is also satisfied by a stated purpose P that is transitively narrower than B (P ⊑ B) — a research permission covers clinical-research, and a neq research carve-out also excludes that sub-purpose. Sound, never over-claimed: the ⊑ relation is the caller-supplied transitive closure only (never inferred from IRI string structure); with no taxonomy supplied it is byte-for-byte the exact-IRI base case, the broader-under-narrower direction never matches, and access is never widened on an unproven relation. The same closure also drives the odrl:spatial region tree (above) — one subsumption evidence channel for both taxonomic dimensions.
- Audit:
purpose_status(&rule, &request) -> PurposeMatch reports exactly what the evaluator checks — Satisfied / DefinitelyUnsatisfied / Unprovable / NotConstrained (it runs the same subsumption-aware path evaluate does).
- Through the bridge, purpose stays one-shot (checked once at materialization; see the mapping table below) — it has no re-checked-condition analogue, so a changed purpose is re-evaluated on the next
refresh_odrl_grant.
odrl:recipient enforcement + neq / "everyone-except" — [OPUS-4.8] sq-5037
A recipient constraint restricts who the data may be disclosed to. The recipient-of-data is the requesting party, so a request that names a party (.by(webid)) but supplies no explicit odrl:recipient context is read as recipient = party — i.e. a recipient rule gates on who is asking, end-to-end through the same evaluate constraint path as every other dimension. An explicit .with(ODRL_RECIPIENT, Value) still takes precedence (the disclosure target need not be the authenticated principal in every deployment).
recipient neq X ("everyone EXCEPT X"): grants/forbids for any recipient that is not X. A request whose recipient IS X is the carve-out (deny on a permission; the prohibition no longer carves this party out). Missing identity (no odrl:recipient AND no party) is unprovable → fail-closed: a neq permission does not grant to an unknown recipient, and a neq prohibition is not withdrawn.
eq/isA = recipient IS the named party; isPartOf/isAnyOf = recipient ∈ static set; isNoneOf = recipient ∉ static set. Match is exact IRI/string equality by default — plus party-collection membership ([FABLE-5] sq-c2aze): when the request supplies .with_party_membership(party, collection) evidence, a recipient bound may name an odrl:PartyCollection the recipient is a member of (the same equality-or-membership lookup the assignee field gets), so recipient isPartOf <PartyCollectionIRI> is satisfied by a member — and the negative operators (neq/isNoneOf) also EXCLUDE a member of an excluded collection (the carve-out is not widened away). Membership draws ONLY on the caller-supplied evidence (never inferred); with no edge, recipient matching is byte-for-byte the flat base case.
- Audit:
recipient_status(&rule, &request) -> RecipientMatch reports exactly what the evaluator checks — Satisfied / DefinitelyUnsatisfied / Unprovable / NotConstrained (the recipient dual of purpose_status).
- Combined
recipient eq A AND neq B (one rule): the constraints are AND-combined — the recipient must BE A and must NOT BE B. Through the bridge this emits a single ConditionalGrant headed by A (positive) carrying an exceptMatcher carving out B (the per-head exception).
- Through the bridge,
recipient neq X maps to an ACP noneOf exception, and recipient isNoneOf <set> to one exception matcher per member (the list-valued dual — [FABLE-5] sq-5fkpp; see the mapping table + the bridge note below) — re-checked per session.
odrl:dateTime time-window enforcement (faithful, fail-closed) — [OPUS-4.8] sq-idnv
A dateTime constraint gates a permission/prohibition to a time window — e.g. dateTime lteq T (valid until T), dateTime gteq T (valid from T), or a two-sided window (gteq lower + lteq upper, AND-combined). The actual instant is the request's evaluation time, supplied as xsd:dateTime evidence via the first-class Request::at(instant) sugar (over .with(ODRL_DATETIME, Value::DateTime(..))); read it back via req.request_time().
- Semantics: inside the window → grant (permission) / carve-out applies (prohibition); outside → deny / carve-out gone. Instants compare by magnitude (the
lt/lteq/gt/gteq/eq/neq ordering). Missing time → unprovable → fail-closed: a time-gated permission does not grant on an unknown clock; a time-gated prohibition is not withdrawn (never silently read as "any time").
- Audit:
datetime_status(&rule, &request) -> DateTimeMatch reports exactly what the evaluator checks — Satisfied / DefinitelyUnsatisfied / Unprovable / NotConstrained (the temporal dual of purpose_status/recipient_status).
- Through the bridge, an inclusive
dateTime bound on an ALLOW (lteq → auth:notAfter, gteq → auth:notBefore) is now persisted onto the ConditionalGrant as a live-clock window ([OPUS-4.8] sq-0q7n): AuthIndex::cond_applies re-checks it against the session clock (Session::now, an xsd:dateTime string set via Session::at(now)) per request, so a lapsed window denies immediately on the next request — no refresh_odrl_grant pass needed. Fail-closed: a windowed grant evaluated with now == None never applies (mirrors the evaluator's fail-closed missing-clock); two lteq/two gteq bounds keep the tightest (intersection); a strict lt/gt bound stays one-shot (no inclusive notBefore/notAfter analogue — avoids an off-by-one). A dateTime window is mapped only on an allow; a time-windowed deny stays one-shot (a lapsed live deny would fail OPEN). The one-shot materialize_odrl_permission path is unchanged — it freezes the bound and relies on refresh_odrl_grant to retract. (Both the live-clock window_admits re-check and the set_tighter intersection-of-bounds compare by the real UTC instant via sparq_policy::cmp_datetime — the SAME offset-aware normalizer the evaluator uses, never raw lexical str::cmp — so a mixed-offset request like now = 2026-06-16T11:00:00-02:00 (= 13:00Z) is correctly DENIED against a not_after = 2026-06-16T12:00:00Z window, and an unparseable bound/clock fails closed. sq-0q7n.)
odrl:count enforcement (stateful, opt-in count-enforcement) — [OPUS-4.8] sq-zi5w
odrl:count limits the number of times a permission may be exercised ("read at most 5 times"). Unlike purpose/dateTime/recipient (stateless — a single test against evidence the request carries), a count limit is stateful: it lives in a usage counter that persists across requests. Behind the off-by-default count-enforcement feature on sparq-policy (the default build is unchanged — it treats odrl:count as the stateless numeric comparison), evaluate_and_exercise runs the real evaluate decision and, on a grant, atomically consumes one unit of budget.
// cargo: sparq-policy with --features count-enforcement
use sparq_policy::{evaluate_and_exercise, InMemoryCounterStore, Request};
let store = InMemoryCounterStore::new(); // injectable UsageCounterStore
let req = Request::new("http://www.w3.org/ns/odrl/2/read")
.on("urn:asset/x").by("https://alice.ex/me"); // policy: count lteq 3
for _ in 0..3 { assert!(evaluate_and_exercise(&policy, &req, &store).allow); }
assert!(!evaluate_and_exercise(&policy, &req, &store).allow); // 4th: limit reached → DENY
- Semantics through the real path. A grant requires the base
evaluate to grant (the odrl:count constraint is stripped from permissions for that base check, since the stateless evaluator would otherwise deny for a missing count value; everything else — action/target/assignee, prohibitions, purpose, dateTime, recipient, duties — is checked unchanged) AND the count budget to have room. **First N exercises grant; the (N+1)th denies. A denied request consumes nothing. lteq N/eq N = at most N; lt N = at most N-1; gt/gteq/neq/isPartOf express no ceiling (left to the stateless path).
- Counter-store seam.
UsageCounterStore is an injectable trait with three shipped impls of increasing reach: InMemoryCounterStore (single-process, Mutex), FileCounterStore (cross-process on one host / sane NFS, an O_EXCL lockfile — [OPUS-4.8] sq-5z1q), and BackendCounterStore<B: AtomicCounterBackend> (cross-host over Redis/SQL — [OPUS-4.8] sq-u3yo, below). The budget key is (rule_id, party, target) (CountKey) — per-assignee, per-asset, per-rule (one party exhausting a limit never locks out another; the same rule on a different target counts separately). A partyless/targetless request shares the "" bucket.
- Atomicity / concurrency boundary (load-bearing). The single mutating op is the atomic
try_consume (the whole check-and-consume under one lock in the in-memory store) — deliberately not a current()+increment() pair, which would reintroduce a TOCTOU race (two concurrent exercises both reading N-1, both granting → over-grant). A concurrency test asserts exactly the limit is granted under 16 threads, never one more. Multiple odrl:count constraints on one rule all bind the same counter (CountKey is per (rule, party, target), never per-constraint), so they are several bounds on one budget: the tightest (minimum) bound governs, and an exercise is a single atomic try_consume against that one effective limit — exactly one unit per exercise (never one-per-constraint), no read-then-consume gap. The multi-limit case is therefore as atomic as the single-limit case (a concurrency test asserts no over-grant under two constraints), closing the prior pre-check→consume window (sq-ea27). The in-memory store is atomic in-process only.
- Cross-process counting —
FileCounterStore ([OPUS-4.8] sq-5z1q). For a multi-process deployment (two workers behind a load balancer, a CLI alongside a server) where the in-memory store would let each process exercise the full "at most N" budget, FileCounterStore::new(dir) persists all budgets in one file under dir and serializes the whole compare-and-increment with an OS-level lockfile (an O_EXCL create_new lock — the cross-process analogue of the in-memory Mutex). Every process that opens the same dir shares one budget; it is a drop-in for the same UsageCounterStore seam (same try_consume contract, identical evaluate_and_exercise semantics) and is std-only — no new deps, no unsafe. A cross-process test (this binary re-spawns 6 worker subprocesses hammering one shared file with 240 attempts against a budget of 60) asserts exactly the limit is granted across all processes, never an over-grant. Boundary (honest): O_EXCL create is atomic on a local filesystem (the deploy target) and on modern NFS, but not on old/misconfigured NFS — for a multi-host deployment over a questionable mount, prefer a Redis INCR / SQL UPDATE … WHERE consumed < limit RETURNING store against the same trait. Fail-closed throughout: a lock timeout (a crashed holder left the lockfile — never stolen), an I/O error, or a corrupt counter file all yield ConsumeResult::Unavailable → DENY.
- Cross-host counting —
BackendCounterStore over an AtomicCounterBackend ([OPUS-4.8] sq-u3yo). For a multi-host deployment (workers on different machines) where even the file store cannot reliably share a budget, the bead's headline backends are Redis and SQL. sparq-policy is publish = false, std-only, dependency-of-nothing, so it does not bundle a redis/tokio-postgres/rusqlite client (which would also need a live service CI cannot provide). Instead it ships the seam: AtomicCounterBackend — the ONE primitive an operator implements: checked_increment(key, limit) -> Incremented(new) | Exhausted | Err, which is a Redis INCR-behind-a-Lua-cap and is a SQL UPDATE … SET consumed = consumed + 1 WHERE key = ? AND consumed < ? RETURNING consumed (the trait docs carry both statements verbatim). BackendCounterStore::new(backend) adapts any such backend into a full UsageCounterStore: it maps a CountKey to one collision-free (injective) backend key, calls the backend's single atomic op once (no read-then-write, so no TOCTOU window is added on top of the backend's atomicity), and translates the outcome — a BackendError → ConsumeResult::Unavailable (fail-closed). The adapter logic (the only part that lives in this crate) is tested against an in-crate reference backend reproducing the exact Redis/SQL atomic semantics — drop-in through the real evaluate_and_exercise path, an 8-thread no-over-grant oracle, injective key-encoding, and fail-closed-on-backend-error — so the wiring is proven without a live service. The operator writes ~one method and inherits a correct, fail-closed distributed store.
- Fail-closed.
ConsumeResult::Unavailable (store outage / poisoned lock / backend error) or a malformed limit → DENY — never silently "unlimited". count_status(&rule, &request, &store) -> CountStatus is the side-effect-free audit surface (Satisfied{consumed,limit} / DefinitelyUnsatisfied{..} / Unprovable / NotConstrained), the count dual of purpose_status.
- Wired THROUGH the bridge — a bridged grant self-retracts on exhaustion ([OPUS-4.8] sq-58mh). Behind
sparq-solid's count-enforcement feature (which implies odrl-bridge + sparq-policy/count-enforcement), PodStore::materialize_odrl_permission_counted(&policy, &request, &store) routes the bridge's allow/deny through evaluate_and_exercise (atomic consume) and materializes the equivalent principal auth:<mode> graph allow into <urn:sparq:auth>. ACP is stateless (no per-session usage counter), so the count cannot be a re-checked ACP condition the way recipient/dateTime are; instead it is enforced through the EXISTING refresh/retraction ledger (sq-dpk4): the count is consumed once at exercise and re-checked read-only (never re-consumed) on refresh_odrl_grants() via count_status, so once the budget is exhausted the tracked BridgeKind::PermissionCounted entry emits nothing → the bridged grant is retracted → access is GONE through the real accessible/query_as path. Fail-closed: a base Deny / exhausted / unprovable (store outage) / store-missing re-check retracts (never re-grants beyond a budget we cannot account for); refresh never itself burns a unit. The store is shared via Arc<dyn UsageCounterStore + Send + Sync> so the same budgets back both exercise-time consumption and refresh-time re-checks.
Static conflict + containment analysis (request-free) — [OPUS-4.8] sq-zabv
Decision reports (opt-in decision-report) — [GPT-5.6] sq-mu4au
Enable sparq-policy's default-off decision-report feature to aggregate already-computed
decisions without re-running or changing authorization. Supply the request action beside each
Decision; the report sorts action buckets and emits byte-stable compact JSON.
use sparq_policy::report::DecisionReport;
let decisions = [(request.action.as_str(), &decision)];
let report = DecisionReport::summarize(decisions);
assert_eq!(report.permitted + report.denied, report.total);
let stable_json = report.to_json();
conflicts counts denials whose completed decision contains evaluate's canonical
matching-prohibition audit explanation. The helper is read-only and never makes or re-derives
an allow/deny decision.
Where evaluate answers "may THIS request go through?", two request-free functions answer questions about the policies themselves (the query-containment comparison semantics, arXiv 2509.05139 §comparison). Both are always compiled (no feature, no deps) and both are sound / fail-closed — never over-claimed.
use sparq_policy::{contains, detect_conflicts, Containment, Overlap};
// Lint a policy for permission/prohibition conflicts.
for c in detect_conflicts(&policy) {
// c.permission_id is (wholly, if c.overlap == Overlap::Certain) carved out by c.prohibition_id
}
// Does the provider's offer permit everything the requester asks?
match contains(&provider_policy, &request_policy) {
Containment::Contains => { /* every ask is covered */ }
Containment::NotContained => { /* a witness ask the offer denies */ }
Containment::Unknown => { /* undecidable under the conservative comparison */ }
}
detect_conflicts(&policy) -> Vec<Conflict> — every permission/prohibition pair whose request footprints overlap (a prohibition carves the permission out — deny-overrides). Conflict { permission_id, prohibition_id, overlap, action, target }. overlap is Overlap::Certain only when the structural attributes (action / target / assignee) prove an overlap AND the prohibition adds no constraint the permission lacks (so the carve-out covers the whole permission); otherwise Overlap::Possible (the rules might overlap but we cannot prove they always do). A pair that provably never overlaps (disjoint concrete action / target / assignee) is omitted. Conflict is strictly across the permission/prohibition divide — two permissions never conflict.
contains(outer, inner) -> Containment — does outer permit everything inner permits (refinement / requester-vs-provider containment)? Containment::Contains only when every inner permission is provably subsumed by some outer permission AND no outer prohibition could carve into it; Containment::NotContained when an inner permission provably grants a request outer denies (disjoint concrete target/action, or inner leaves a dimension outer restricts wide open); Containment::Unknown otherwise. An inner with no permissions is contained vacuously.
conflict_admissibility(&policy) -> Result<(), String> ([OPUS-4.8] sq-ihqbl) — the fail-closed guard the bridge runs BEFORE materialising anything, deciding whether a policy's declared odrl:conflict conflict-resolution strategy (Policy.conflict: Option<ConflictStrategy>, parsed from odrl:conflict) is one the engine can faithfully honour. The bridge implements exactly one strategy — odrl:prohibit (deny-overrides), which is also the operative default when odrl:conflict is unset. Any other declared strategy returns Err(reason) (a loud refusal — see the bridge section) rather than being silently coerced into deny-overrides (an authorization-correctness hazard): ConflictStrategy::Perm (odrl:perm, permissions override prohibitions — unrepresentable by ∪ allow ∖ ∪ deny) is always refused; ConflictStrategy::Invalid (odrl:invalid, a conflicting policy is void as a whole) is refused iff detect_conflicts finds a conflict (else admissible — nothing to void); ConflictStrategy::Unknown(iri) (an unrecognised odrl:conflict value) is always refused. Honest boundary: an unset odrl:conflict defaults to prohibit, NOT the ODRL 2.2 spec default of invalid — fully honouring invalid for every undeclared conflicting policy would refuse the bridge's core deny-overrides use case. This divergence is deliberate and documented (decided on issue #1375, sq-zjtu1): deny-overrides is fail-closed and never authorises what a prohibition forbids; see the crate README's ODRL conformance note and the odrl-policy-bridge paper's Limitation #1.
- Soundness boundary (honest). Constraint satisfiability / query containment is undecidable in the general ODRL constraint language. This module decides only what it can prove from rule structure plus a conservative per-dimension constraint comparison (identical constraints;
eq v admitted by an outer bound; a tighter same-direction order bound — lt/lteq, gt/gteq — implying a looser one). Everything else degrades to Possible / Unknown — it never reports Certain / Contains it cannot prove (that is the fail-OPEN failure mode: claiming an ask is covered when it is not). It also does not (yet) prove NotContained from a looser inner numeric bound reaching above a tighter outer one — that case honestly returns Unknown. DPV/isPartOf set-subset refinement is a deferred bead.
Security-property ODRL profile — secx:requires… leftOperands (opt-in secprop-leftoperands) — [OPUS-4.8] sq-uor3g
A privacy/security preference — "admit only a proof method whose security posture meets THIS bar" — is expressed as an ODRL constraint over a custom secx:requires… leftOperand (research/security-properties-ontology-design.md §4.3.1). The load-bearing ODRL nuance: a rightOperand natively accepts any IRI, but a custom leftOperand MUST be declared in a published odrl:Profile and a policy using it must assert odrl:profile <profileIRI> — a conforming ODRL processor MAY reject an undeclared leftOperand. Phase 4 (this feature) ships that profile document (crates/sparq-policy/ontologies/odrl-secprop-profile.ttl) and makes the leftOperands first-class in the Rust model.
Behind the off-by-default secprop-leftoperands cargo feature (the default build already parses + evaluates such a constraint as an opaque custom leftOperand — this feature adds recognition metadata, not new eval semantics; the stateless evaluate path is unchanged). The secprop module gives the leftOperand IRIs as constants, the published profile IRI, a recogniser, and the leftOperand → security-property-dimension map (secx:overDimension):
// cargo: sparq-policy with --features secprop-leftoperands
use sparq_policy::secprop::{is_secprop_left_operand, over_dimension, PROFILE_IRI, REQUIRES_ASSURANCE};
assert!(is_secprop_left_operand(REQUIRES_ASSURANCE));
// the single fact the admissibility reduction reads: leftOperand -> dimension
assert_eq!(over_dimension(REQUIRES_ASSURANCE), Some("https://w3id.org/zkp-sparql/sec-prop#AssuranceLevel"));
// a policy using a secx: leftOperand must assert `odrl:profile <PROFILE_IRI>`
let _ = PROFILE_IRI; // https://sparq.dev/ns/odrl-secprop-profile#
- One leftOperand per requireable dimension (
requiresUnlinkabilityScope, requiresPostQuantumForgery, requiresZeroKnowledge, requiresSoundness, requiresSelectiveDisclosure, requiresSingleUse, requiresAssurance, …); the full set is secprop::SECPROP_LEFT_OPERANDS. Each is sugar for the generic primitive "a constraint over dimension X" — the one secx:overDimension fact per leftOperand is what the admissibility rule reads, so one rule covers all (design §4.5).
- This is the bridge VOCABULARY only. It NAMES the requireable dimensions; it does not perform the admissibility reduction (that is the N3 ruleset, Phase 2
sq-ufsi9, over the per-method annotation graph, Phase 3 sq-bevd3 in sparq-zk), and it asserts NO security property of any method. Whether a sparq method actually has a property is recorded — with its epistemic basis — elsewhere; sparq's whole ZK estate is research-grade and externally UNAUDITED (bead sq-qhy4), and sparq-mpc is semi-honest-only. The conservative requiresAssurance gteq secx:Proven gate mechanically removes every unaudited method — this is how sq-qhy4 enters the admissibility data flow, not merely the prose.
Bridge to WAC/ACP enforcement (opt-in odrl-bridge) — [OPUS-4.8] sq-h3uk
sparq-solid can materialize a matched ODRL permission into its <urn:sparq:auth> AUTH_GRAPH so the existing graph-level WAC/ACP enforcement applies it — no new enforcement engine. Behind the off-by-default odrl-bridge cargo feature on sparq-solid (it pulls in sparq-policy only when enabled; the default solid build stays ODRL-free). This is the single-node bridge of epic sq-3183, research-track, NOT the (gated) federated/ZK-disclosure path.
// cargo: sparq-solid with --features odrl-bridge
use sparq_solid::{PodStore, Session, Mode};
use sparq_policy::Request;
let req = Request::new("http://www.w3.org/ns/odrl/2/read")
.on("https://pod.ex/notes/n1").by("https://alice.ex/card#me");
// On a definite Permit, appends `alice auth:read n1` to the auth view, then reindexes.
let out = store.materialize_odrl_permission(&policy, &req);
assert!(out.granted);
// …now honoured by the unchanged enforcement path:
assert!(!store.accessible(&Session { agent: Some("https://alice.ex/card#me"), client: None }, Mode::Read).is_empty());
Action → mode (the ODRL request action; conservative — narrowest mode only): read/display/present/print/play → acl:Read; append → acl:Append; modify/delete/write → acl:Write; anything else (incl. the odrl:use umbrella) is unmapped → no grant. use is left unmapped because it subsumes a whole subtree of actions (mapping it would have to pick the widest mode) — request odrl:read explicitly; a use permission still grants that concrete request (provided the request action is in the use subtree — not sell/give/transfer).
Fail-closed: a grant is materialized only on a definite Permit AND a mappable action AND a concrete party (WebID) + target graph. A Deny, unsatisfied constraint, undischarged duty, unmapped action, or partyless/targetless request materializes nothing.
Interactive surface — [FABLE-5] sq-ixc3.15: the desktop GUI's Policies (ODRL) tool (gui/, opt-in odrl cargo feature on gui/src-tauri) drives exactly this one-shot bridge path end-to-end: author/validate a policy, evaluate a request, and preview the same SPARQL query per requester through query_json_as next to the ungated rows (gui/src-tauri/src/odrl.rs).
Prohibitions → explicit auth:deny* (deny-overrides) — [OPUS-4.8] sq-w693
A matched ODRL Prohibition is materialized as the dual triple — principal auth:deny<Mode> target — via materialize_odrl_prohibition (or materialize_odrl_policy, which does both sides at once). The same action→mode mapping picks the mode, so the deny predicate is auth:denyRead / auth:denyWrite / auth:denyAppend / auth:denyControl.
// Prohibition side: appends `alice auth:denyWrite n1`, then reindexes.
let dreq = Request::new("http://www.w3.org/ns/odrl/2/modify")
.on("https://pod.ex/notes/n1").by("https://alice.ex/card#me");
let out = store.materialize_odrl_prohibition(&policy, &dreq);
assert!(out.prohibited);
// Both sides of a policy at once (permit grant + matched-prohibition deny):
let out = store.materialize_odrl_policy(&policy, &req);
Deny-overrides: the deny is honoured by the existing, unchanged enforcement — the session layer already computes ∪ allow ∖ ∪ deny (AuthIndex::accessible) and Mode::from_pred already parses auth:deny*, so a materialized deny beats any allow grant for the same principal+target+mode. No new enforcement engine; the bridge only emits the triple. (Within one policy the ODRL evaluator also applies deny-overrides upstream, so the permit allow triple is never even emitted when a prohibition carves the request out.)
Loud refusal of an unimplementable conflict strategy ([OPUS-4.8] sq-ihqbl): the bridge implements only odrl:conflict odrl:prohibit (deny-overrides). Every materialize_* entry point first runs sparq_policy::conflict_admissibility(policy); a policy declaring a strategy the bridge cannot honour (odrl:perm, odrl:invalid with a detected conflict, or an unknown odrl:conflict IRI) materializes NOTHING — neither grant nor deny — and the returned BridgeOutcome carries refused = true plus a REFUSED (odrl:conflict): … reason. This is fail-closed and distinct from a plain deny (refused vs granted == false): silently coercing odrl:perm into deny-overrides would mis-apply the author's intent. An unset odrl:conflict is treated as prohibit and is not refused (the common case).
Fail-closed (deny): a deny is materialized only when a prohibition matches the request (decided by sparq_policy::matched_prohibition — the evaluator's own conflict test, not Decision.allow == false, which conflates a carve-out with a plain no-permission deny) AND the action is mappable AND the party+target are concrete. An unmatched / unmapped / partyless / targetless prohibition materializes nothing — and an unmappable carve-out is reported in reasons, never silently dropped (dropping a deny would widen access).
Persisting a constraint as a re-checked ACP condition (materialize_odrl_permission_conditional) — [OPUS-4.8] sq-hiz4
The one-shot materialize_odrl_permission freezes every constraint into a single allow scoped to the supplied request party. materialize_odrl_permission_conditional instead persists a faithfully-mappable constraint as an ACP auth:ConditionalGrant (the same noneOf machinery the ACP materializer emits), so the granted agent is re-checked per session through the unchanged enforcement path — not re-running the ODRL evaluator. A constraint with no faithful ACP analogue keeps the one-shot behaviour (checked once, frozen).
// The recipient constraint names carol — NOT whoever materializes the grant.
let req = Request::new("http://www.w3.org/ns/odrl/2/read")
.on("https://pod.ex/notes/n1").by("https://alice.ex/card#me");
store.materialize_odrl_permission_conditional(&policy, &req); // policy: recipient eq carol
// Re-checked per session: carol is granted; alice (the materializer) is NOT.
Constraint → ACP condition mapping table (fail-closed: map ONLY when the ACP analogue is the same or stricter):
| ODRL constraint | Operator | ACP analogue | Faithful? | Behaviour |
|---|
odrl:recipient / odrl:assignee | eq / isA | auth:agent <webid> on a ConditionalGrant (agent matcher) | ✅ recipient-of-data IS the session agent | persisted, re-checked per session |
odrl:recipient / odrl:assignee | isPartOf / isAnyOf (static set) | one auth:agent head per member (OR) | ✅ set membership = agent ∈ set (the evaluator matches both operators as the same flat lexical set) | persisted (one grant/member; isAnyOf [FABLE-5] sq-5fkpp) |
odrl:recipient / odrl:assignee | neq ("everyone EXCEPT X") / isNoneOf (everyone except a static set) | auth:Public ConditionalGrant + one auth:exceptMatcher per excluded member (ACP noneOf) | ✅ everyone-except is exactly the ACP noneOf shape | persisted, re-checked per session ([OPUS-4.8] sq-5037; list-valued isNoneOf [FABLE-5] sq-5fkpp — a numeric/dateTime operand or an EMPTY member set stays one-shot, fail-closed) |
odrl:recipient / odrl:assignee | order (lt/gt/…) | (none — not meaningful on a recipient) | ❌ | one-shot (frozen) |
odrl:purpose | any (incl. isPartOf DPV hierarchy) | (none — a client app ≠ a purpose-of-use) | ❌ ACP session carries no purpose dimension; client-matcher would over-grant | one-shot (frozen) |
odrl:spatial | any (incl. isPartOf region hierarchy) | (none — ACP session carries no region) | ❌ no spatial dimension to re-check | one-shot (frozen) ([OPUS-4.8] sq-wukl) |
odrl:dateTime (ALLOW) | lteq ("until T") / gteq ("from T") | auth:notAfter / auth:notBefore window on the ConditionalGrant, re-checked vs Session::now | ✅ the window IS the request instant — a live-clock dimension | persisted, re-checked per request ([OPUS-4.8] sq-0q7n) |
odrl:dateTime | strict (lt / gt) | (none — no inclusive notBefore/notAfter analogue) | ❌ inclusive/exclusive off-by-one | one-shot (frozen) |
odrl:dateTime (DENY) | any | (none as a live window — a lapsed live deny would fail OPEN) | ❌ unsafe to lapse a deny | one-shot (frozen) |
odrl:count | any | (none as a per-session condition — ACP is stateless; no usage counter) | ❌ as a re-checked condition | NOT a ConditionalGrant (no per-session counter to re-check), but stateful through the bridge via the refresh ledger: PodStore::materialize_odrl_permission_counted consumes atomically at exercise and refresh_odrl_grants() re-checks the budget read-only, so the bridged grant self-retracts on exhaustion ([OPUS-4.8] sq-58mh; count-enforcement feature — see the §odrl:count enforcement section above). In materialize_odrl_permission_conditional (the condition path) a count constraint still forces the rule one-shot (frozen). |
| any unrecognised left-operand | any | (none) | ❌ | one-shot (frozen) |
| no constraint | — | auth:agent auth:Public (action/target/duties already held) | ✅ | persisted (public) |
The neq/isNoneOf / "everyone-except" → noneOf shape ([OPUS-4.8] sq-5037; list-valued isNoneOf [FABLE-5] sq-5fkpp): a recipient neq X (or recipient isNoneOf <set>) rule emits a ConditionalGrant whose head is the positive recipient set (or auth:Public if there is none) plus one auth:exceptMatcher <m> per excluded X; the matcher <m> carries the accept-set facts the session layer reads (solidx:acceptsAgentP <X> + solidx:acceptsClientP auth:AnyClient). AuthIndex then suppresses the grant for any session the matcher accepts — i.e. for X under any client — so everyone keeps the grant except X. This is byte-for-byte the shape the ACP noneOf rules (rules/acp-c.n3) emit, re-checked by the same cond_applies code path. A neq recipient inside the reserved pair encoding cannot become an enforceable matcher (it would impersonate a minted pair principal), so rather than emit an exception that silently fails to bite (which would re-admit X), the whole rule falls back to the one-shot path — fail-closed: never widen to a public everyone-except grant on an unenforceable exclusion (a reserved-encoded member anywhere in an isNoneOf set sinks the whole rule the same way, as does a numeric/dateTime operand — which the evaluator never satisfies — or the degenerate EMPTY set).
Fail-safe on mixed constraints: a persisted condition is emitted ONLY when every constraint on the rule maps faithfully — but a faithfully-mapped recipient AND a faithfully-mapped inclusive dateTime window now COMPOSE on one grant (the recipient head carries the auth:notBefore/auth:notAfter window — [OPUS-4.8] sq-0q7n). A rule mixing a mappable constraint with an unmappable purpose/count/strict dateTime still falls back entirely to the one-shot path — persisting only the mappable part would silently drop the other bound and over-grant. Recipient IRIs inside the reserved pair encoding (urn:sparq: / &client=) are dropped from the grant head (anti-impersonation). The two ODRL "any recipient" sentinels fold onto auth principals: odrl:All/odrl:Group → auth:Public, odrl:AllConnections → auth:Authenticated.
Why recipient/assignee and an inclusive dateTime map: the ACP session re-check carries (agent, client, issuer) plus the request clock Session::now. The recipient-of-data is precisely the session agent, and an odrl:dateTime window is the request instant — both re-check with identical semantics. Purpose and count have no stateless per-session analogue, so persisting them would require either freezing the check (= the one-shot path, already correct) or a looser approximation that could over-grant — rejected. A dateTime window is mapped only on an ALLOW (a lapsed deny would fail open).
Constraint-conditional DENY (materialize_odrl_prohibition_conditional) — [OPUS-4.8] sq-4r70
The dual of the conditional grant: a matched ODRL prohibition whose recipient/assignee constraints map faithfully is persisted as a re-checked auth:ConditionalGrant with auth:effect auth:Deny (rather than a frozen one-shot auth:deny*). The carve-out is re-verified per session through the SAME accessible/query_as path, and composes with deny-overrides: the session layer subtracts ∪ deny from ∪ allow, so a conditional deny that applies to a session removes the target from its accessible set — beating any allow for the same principal+target+mode.
- A prohibition
recipient eq carol → a deny condition headed by carol (only carol's sessions are denied); recipient neq bob → a deny on everyone EXCEPT bob (an exceptMatcher carving bob back IN to access). Same mapping table as the allow path (recipient/assignee eq/isA/isPartOf/isAnyOf/neq/isNoneOf are faithful; purpose/dateTime/count are unmappable).
- Fail-safe fallback: a prohibition carrying an unmappable constraint (
purpose/dateTime/count) falls back to the one-shot materialize_odrl_prohibition (frozen, materialized iff the prohibition currently matches) so the bound is still enforced — a persisted deny condition is emitted ONLY when every constraint maps faithfully. A reserved-encoded recipient cannot become a matcher, so the rule falls back to one-shot rather than emit a deny that silently fails to bite (which would FAIL OPEN — a dropped deny widens access).
- Refresh. Tracked as
BridgeKind::ProhibitionConditional. On refresh the recipient carve-out is re-checked per session, so the deny is re-emitted while the prohibition still structurally names the request; a withdrawn prohibition re-emits nothing → the deny condition is retracted → access restored. A one-shot fallback uses the asymmetric deny-retraction rule below (re-emit on Ambiguous, retract only on a definite Withdrawn).
Refresh / REVOCATION of bridged grants on policy change — [OPUS-4.8] sq-dpk4
The materialize_odrl_* calls only ever append. When the underlying ODRL policy changes — a permission is withdrawn, a time window lapses, or a re-evaluation now Denies — the previously-materialized grant would otherwise stay in the auth view, so access that should be gone persists (the sq-h3uk/#280 correctness gap). And a wholesale static WAC/ACP re-materialization rebuilds <urn:sparq:auth> and would drop every bridged grant. Both are reconciled by a bridge ledger + a refresh entry point.
- Provenance. Every auth triple the bridge writes into
<urn:sparq:auth> is mirrored verbatim into a separate reserved graph <urn:sparq:auth-bridged> (AUTH_BRIDGED_GRAPH). A triple is bridged iff it appears there, static otherwise — so bridged and static grants are structurally distinguishable without inspecting predicate shape, and the enforcement reader (AuthIndex) is unchanged (it still reads <urn:sparq:auth>). The provenance graph is in the reserved urn:sparq: space, so a loaded dataset cannot forge it.
- Refresh / retract.
PodStore::refresh_odrl_grant(&new_policy, &new_request, kind) updates the tracked grant slot (kind, target, party) with the new policy / request context, then rebuilds the view as static_baseline ∪ replay(still-valid bridged entries): it resets <urn:sparq:auth> to the static baseline captured at the last materialize_wac/materialize_acp, clears the provenance graph, and re-evaluates every tracked (policy, request) through its original bridge entry point. An entry that no longer holds emits nothing → it is retracted (access gone). refresh_odrl_grants() (no args) replays everything as-tracked (used to reconcile after a static re-materialization, which is automatic).
- Fail-closed (security-sensitive — access retraction). A withdrawn / lapsed / now-Denied / now-prohibited / ambiguous re-evaluation of an allow grant loses access; the underlying evaluator is fail-closed, so on any doubt the grant is retracted, never left stale. A static WAC/ACP grant is never in the ledger, never re-evaluated, and always in the captured baseline (captured as the
install_auth_view output verbatim, not by subtracting provenance — so a static grant byte-identical to a bridged one still survives) — refresh can neither widen nor drop it.
Deny RETRACTION is asymmetric to grant retraction — [OPUS-4.8] sq-2pcf
A materialized auth:deny* (from a BridgeKind::Prohibition / Policy entry) is retracted on the OPPOSITE rule: a deny carves access out, so retracting it restores access — that must happen only when the ODRL Prohibition is definitely withdrawn or lapsed, never on doubt. Reusing the grant rule (drop the deny whenever matched_prohibition no longer matches) would be fail-OPEN: an ambiguous re-eval — a prohibition still structurally naming the request but carrying a constraint the refresh request gives no evidence for — would silently restore access.
So deny retraction consults sparq_policy::prohibition_status, a three-valued refinement of matched_prohibition:
ProhibitionStatus | meaning | deny on refresh |
|---|
Applies | a prohibition still carves the request out | kept (re-emitted) |
Ambiguous | still structurally names it, but a constraint is unprovable (no evidence) | kept (re-emitted) |
Withdrawn | no prohibition names it, or every one is definitely false given the evidence | retracted (dropped) |
"Definitely false" means the refresh request supplied evidence for the dimension and the comparison failed (e.g. a dateTime < 2026-01-01 window with an actual time of 2026-06-01 — provably lapsed). A retracted deny composes with deny-overrides: it may re-expose an allow grant for the same principal+target+mode — correct, because the prohibition is genuinely gone. Static (non-bridged) auth:deny* rules are never in the ledger and so are never re-evaluated or retracted.
// alice was bridged a read grant; the policy then WITHDRAWS the permission.
let (matched, retracted) =
store.refresh_odrl_grant(&withdrawn_policy, &req, BridgeKind::Permission);
// matched == true, retracted == 1 → alice can no longer read (through accessible/query_as).
// A bridged DENY is the dual: retracted only when the Prohibition is DEFINITELY gone.
let (matched, retracted) =
store.refresh_odrl_grant(&withdrawn_prohibition, &write_req, BridgeKind::Prohibition);
// definite withdrawal → retracted == 1 (deny gone, access restored if an allow exists);
// ambiguous re-eval (no constraint evidence) → retracted == 0 (deny KEPT, fail-closed).
Pattern-scoped ODRL targets (sub-graph result masking) — [OPUS-4.8] sq-f9u1y
An ODRL target can denote a triple-pattern sub-graph of a source graph rather than the whole graph — a pattern asset. Two layers, at different maturity:
Shipped — the pattern-scope masking primitive (opt-in pattern-scope feature of sparq-solid, library-level, off by default). A GraphScope is a set of allow/deny ScopePatterns; each pattern's subject/predicate/object is a concrete Term or a wildcard (None = matches anything in that position). A triple is visible iff it matches ≥1 allow pattern and 0 deny patterns — deny overrides allow. Matching is term-identity, not value equality ("01"^^xsd:integer does not match a pattern naming the canonical 1). masked_graph(base, &scope) materializes the masked sub-graph — it does not filter at query time — so the engine evaluates a real Graph = D ∖ masked-triples: OPTIONAL / EXISTS / MINUS / aggregate / COUNT / ASK equivalence is an identity, inherited by every engine fast path.
// Only phone triples are visible in this scope; every other triple is masked out.
let scope = GraphScope::allow_only(vec![
ScopePattern::new(None, Some(phone_pred), None), // wildcard S/O, fixed predicate
]);
let masked = masked_graph(&contacts, &scope);
Fail-closed: GraphScope::allow_only(vec![]) (an empty allow-set) grants nothing — never a whole-graph fallback.
Designed — the ODRL→pattern-grant bridge vocabulary (research §5). The policy parsing + auth:PatternGrant materialization are follow-up beads, not yet wired, so an ODRL policy cannot yet target a pattern asset end-to-end; the intended triples-native vocabulary (sparq ODRL-profile namespace) is:
<urn:ex:asset:contacts-sans-phone> a sparq:PatternAsset ;
sparq:sourceGraph <https://pod.ex/contacts> ; # exactly one
sparq:pattern [ sparq:predicate <https://ex.dev/ns#phone> ] . # >=1; absent component = wildcard
odrl:target <PatternAsset> on a permission yields allow-patterns over sourceGraph; on a prohibition, deny-patterns (deny overrides allow, as in the graph-granular bridge). The materialized output (in the reserved <urn:sparq:auth> graph) is an auth:PatternGrant node (auth:agent / auth:mode / auth:graph + ≥1 auth:allowPattern / auth:denyPattern blank nodes), structurally parallel to the existing auth:ConditionalGrant.
Fail-closed parse rule (designed): a PatternAsset with zero parseable patterns, an ambiguous sourceGraph, or any non-concrete component ⇒ the rule materializes nothing (absence-of-grant, never a whole-graph fallback).
Honest scope: clear-path authorisation — no cryptographic / privacy / unlinkability guarantee is claimed. Design record: research/odrl-pattern-scoped-targets-2026-07.md.
Conformance — SolidLab ODRL Test Suite — [OPUS-4.8] sq-tmsd6
The evaluator is ratcheted against the MIT-licensed
SolidLab ODRL Test Suite
(crate-local runner crates/sparq-policy/tests/odrl_test_suite.rs). Each of the 68
self-describing Turtle cases references — by UUID — a policy / request /
state-of-the-world / expected compliance report; the runner loads the policy
through the real parse_policy_str, derives a real Request, evaluates it through
the real evaluate, and asserts the decision matches the oracle the expected
report encodes:
| expected rule report | report:activationState | expected decision |
|---|
report:PermissionReport | report:Active | ALLOW |
report:ProhibitionReport | report:Active | DENY (prohibition fires) |
| either kind | report:Inactive | DENY |
At the pinned suite revision 67/68 pass (floor ODRL_SUITE_FLOOR, may only
RISE; mirrored in the central conformance scoreboard). The constraint-matching batch
([OPUS-4.8] sq-euhr3 / sq-k7itg / sq-a0zef) raised the floor from 59 to 67 by
implementing — through the real evaluate path — odrl:LogicalConstraint
(odrl:and/odrl:or/odrl:xone, incl. nesting) compound constraints,
odrl:PartyCollection / odrl:AssetCollection membership matching, and the
odrl:use action hierarchy (use subsumes its sub-actions but not the
ownership-transfer subtree — so a use permission is Active for read/write but
Inactive for sell). The 1 remaining case is a documented not-implemented
divergence — a duty whose discharge state is unknown (report:NonSet); sparq is
fail-closed (a duty must be explicitly discharged) where SolidLab treats a
not-violated duty as active. It does NOT count as a failure and does NOT gate. Fetch
with bash scripts/fetch-odrl-suite.sh (the data is never committed); the runner
self-skips cleanly when the fetched dir is absent. Run:
cargo test -p sparq-policy --test odrl_test_suite -- --nocapture.
Learn more
- Crate README:
crates/sparq-policy/README.md
- Design record:
research/feature-research-odrl-policy.md (epic sq-3183)
- Enforcement consumers:
sparq-server's opt-in /authz/* ODRL lane and sparq-lws-core's opt-in read/query gate seam (authz::odrl, LdpState::set_odrl_gate — deny-overrides / permit-extends over WAC), both behind an odrl-authz feature ([SONNET-4.6] sq-elg47)
- Sibling access-control skill:
skills/http-server (Solid WAC/ACP via sparq-solid)
- W3C ODRL Information Model 2.2 · Formal Semantics