usage-control-policy · git:20261009.facfe78 · 2026-10-09 · sha256 47091399c1615d6f

usage-control-policy git:20261009.facfe78A

Immutable. This exact content never changes and is served at /api/v1/blob/47091399c1615d6f.

---
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](https://www.w3.org/TR/odrl-model/) policy.

It parses an ODRL policy from RDF into a typed model (`Policy → {permissions, prohibitions}`, each `Rule` carrying an `Action`, `target`, `assignee`/`assigner`, `Constraint`s and `Duty`s) and evaluates an access `Request` to a **fail-closed** `Decision { allow, matched_rules, unmet_constraints, permit }` through the one decision point `decide` (`evaluate` is the same function). 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

- **Cargo dep** — `sparq-policy` is `publish = false`, a non-default workspace member (nothing in core depends on it; `cargo tree -p sparq-core` never shows it). Add it as a path dep:
  ```toml
  sparq-policy = { path = "crates/sparq-policy" }
  ```
- No external toolchain. It depends only on `sparq-core` + `sparq-engine` + `oxrdf`; zero `unsafe`.

## Quickstart

Parse an ODRL `Set` and evaluate a time-windowed permission. Compiles and runs against the current API.

```rust
use sparq_policy::{evaluate, parse_policy_str, Request, Value};

// alice MAY read asset-X, on or before 2026-12-31, for research purpose.
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);                      // in-window, right party, right purpose
assert_eq!(d.matched_rules.len(), 1);  // the granting permission, for audit
```

## The model

- **`ValidatedPolicy`** — what every decision entry point takes (`decide`/`evaluate`, `matched_prohibition`, `prohibition_status`, `evaluate_and_exercise`). Only `Policy::validate` builds one; `parse_policy*` validate before returning, and a hand-built `Policy` calls `.validate()`. It refuses an empty `and`/`or`/`xone`, a prohibition carrying an unrepresentable constraint, and a prohibition with a blank-node action, target or assignee. It derefs to `Policy`.
- **`Permit`** — carried by a granting `Decision` (`permit: Some(..)`, naming the rule, action, target and party). Its constructor is private and `Decision` is `non_exhaustive`, so only `decide` produces a grant. `Permit::lasting()` says whether the grant depends on nothing that can change, so a stored grant (which records only party, mode and target) stands for it. Exactly one shape is lasting: a permission with no constraints, logical constraints or duties, assigned to exactly the requesting party (a named IRI, not a declared `odrl:PartyCollection`), targeting exactly the requested asset or every asset, decided without party or asset membership evidence, in a policy with no prohibitions. A stored grant needs it. `Policy::validate` refuses two rules sharing an id, since a decision names its rule by id.
- **`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](https://www.w3.org/TR/odrl-vocab/)* — 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. 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 `ConstraintNode`s (each atomic **or** nested compound). 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)

1. 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).
2. A `Permission` grants iff it matches **and** all its `Duty`s are discharged.
3. 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).
4. **Three-valued constraints:** every constraint and compound is True, False or Unknown (missing evidence, an unsupported or malformed constraint, an incomparable pair). `and`/`or` are Kleene; `xone` is True only for exactly one True with no Unknown, and False once two operands are True. A permission grants only on True; a prohibition fires on True **or** Unknown. A constrained duty is never discharged. `decide` also denies under a declared `odrl:conflict` strategy `conflict_admissibility` refuses.
5. **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; 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` (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); an empty list (`rdf:nil`) stays the unmatchable nil IRI. A **malformed** collection right operand (broken/dangling tail, cycle, forked cons cell, or a member-less head asserting `rdf:rest` with no `rdf:first`) **REFUSES the whole parse** (`Err`) rather than contributing its valid prefix — or, for the member-less head, being read as an ordinary unmatchable blank node (sq-srjuc): dropping authored members widens decisions on *both* rule kinds — `isNoneOf` on a permission would exclude fewer values than authored, and any set-op constraint gating a prohibition would narrow the carve-out (deny-overrides bypassed) — and degrading the single constraint to the unsatisfiable guard is no better, since that *disables* a prohibition. A **nested-list member** of a right-operand collection (`( ( <a> ) <b> )`, including an empty nested `( )`) is likewise **refused** (#3982): kept as an opaque blank node it was unmatchable, which silently dropped an `isNoneOf` exclusion. A constraint node carrying several **distinct** `odrl:leftOperand` or `odrl:operator` objects is also refused (#3832) rather than evaluating one arbitrarily chosen binding. Same refusal precedent as the combinator fold below. Tests: `tests/odrl_right_operand_sets.rs`. A list-valued *combinator* object (`odrl:or ( … )`) is folded into its member operands under the same well-formedness rule (sq-dkuff; `tests/odrl_constraint_matching.rs`).

## `odrl:spatial` region enforcement + region `isPartOf` trees — 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.

```rust,ignore
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 — sq-euhr3

`odrl:use` is the ODRL "umbrella" action, but it does **not** subsume *every* action — only those the [ODRL 2.2 vocabulary](https://www.w3.org/TR/odrl-vocab/) 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 — 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).
- **Reading the evidence back:** `req.party_collection_members(collection)` returns the parties the request evidenced as members of `collection` (sorted; empty for a plain party IRI or an un-evidenced collection). This is the read side a consumer needs when it must *persist* a collection-valued rule as a per-session-rechecked head rather than call the evaluator — see the ODRL→ACP bridge's `odrl:PartyCollection` head expansion below (sq-rf9uv), including why the DENY direction must not use it. It is **not a collection test**: the empty result is shared by a plain party IRI and by a collection this request happened to supply no edges for.
- **Collection identity, separate from membership:** `Policy::party_collections` is the set of IRIs the policy DOCUMENT identifies as an `odrl:PartyCollection` — every subject typed `a odrl:PartyCollection`, plus every object of an `odrl:partOf` edge the document states (minus anything explicitly typed `a odrl:AssetCollection`). `parse_policy_str`/`parse_policy` fill it; a hand-built `Policy` leaves it empty. This is what a consumer needs when it must fail CLOSED on collection-valued input: membership evidence is per-request and possibly partial, so "has ≥1 known member" is a lower bound on collection-ness, never a test for it (sq-rf9uv).
- **Evidence for collection targets:** `target_memberships(&policy, &graph)` returns the `member odrl:partOf collection` edges a graph states (default graph) whose collection is one of the policy's rule targets, and `parse_policy_str_with_memberships(rdf, format)` returns them with the parsed policy from one load. An enforcement point that evaluates per target graph also evaluates each such member with the edge as its asset evidence. Both ODRL enforcement consumers below do this: the server lane reads the edges from the request dataset, and `PolicyOdrlGate` reads them from its policy document plus any added with `with_asset_membership(member, collection)`.

```rust,ignore
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 — 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 `and`s — 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.

```rust,ignore
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) — 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 — 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, a purpose-gated permission is **never stored**: the stored allow does not record the purpose, so a later request with another purpose would ride it. Call `decide` per request for purpose-scoped access (#6743 tracks doing that in the auth view).

## `odrl:recipient` enforcement + `neq` / "everyone-except" — 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** (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, a recipient constraint is decided for the requesting party only, and the stored grant names that party (see the bridge section below).

## `odrl:dateTime` time-window enforcement (faithful, fail-closed) — 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, a permission with any `dateTime` constraint stores nothing: the stored triple is never re-checked against the clock (`Permit::lasting`).

## `odrl:count` enforcement (stateful, opt-in `count-enforcement`) — 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.

```rust,ignore
// 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 — sq-5z1q), and `BackendCounterStore<B: AtomicCounterBackend>` (cross-**host** over Redis/SQL — 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` (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` (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`.
- **The bridge never stores a count-limited grant.** A stored `principal auth:<mode> graph` allow is never re-checked, so it would let every later read through after one exercise. `base_decision(&policy, &request)` is the count-free decision `evaluate_and_exercise` grants on, and its permit is not `lasting()` when the granting permission carries a count limit. `PodStore::materialize_odrl_permission_counted(&policy, &request, &store)` (behind `sparq-solid`'s `count-enforcement` feature) refuses such a grant before spending any budget, and bridges a permission with no count limit as the plain bridge would. Enforce a usage count per request with `evaluate_and_exercise`.

## Static conflict + containment analysis (request-free) — sq-zabv

## Decision reports (opt-in `decision-report`) — 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.

```rust,ignore
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](https://arxiv.org/html/2509.05139v1)). Both are always compiled (no feature, no deps) and both are **sound / fail-closed — never over-claimed**.

```rust,ignore
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 prohibition names every target and assignee the permission does (unpinned, or the same IRI) AND 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). Only a pair with disjoint concrete actions is omitted: unequal targets or assignees still possibly overlap, because a request's membership evidence can place one party or asset inside another. Conflict is strictly across the permission/prohibition divide — two permissions never conflict.
- **`contains(outer, inner) -> Containment`** (both `&ValidatedPolicy`) — does `outer` permit everything `inner` permits (refinement / requester-vs-provider containment)? `Containment::NotContained` is backed by a concrete request the comparison builds from an unconstrained, duty-free `inner` permission (its own action, target and assignee, reserved IRIs where it leaves one open, no evidence), which `decide` grants under `inner` and not under `outer`. `Containment::Contains` is claimed **only** when neither side declares an `odrl:PartyCollection` or carries a duty or compound constraint, and every `inner` permission has an `outer` one whose action permits its action, whose target and assignee are open or the same IRIs, and whose every constraint appears identically on it, with no `outer` prohibition on an overlapping action. Everything else is `Containment::Unknown`. An `inner` with no permissions is contained vacuously. `tests/odrl_compare_witness.rs` checks every definite verdict against `decide` over generated policy pairs and sampled requests.
- **`conflict_admissibility(&policy) -> Result<(), String>`** (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](https://github.com/sparq-org/sparq/issues/1375), sq-zjtu1): deny-overrides never authorises what a prohibition forbids, but it is not as strict as `invalid`: a policy with a conflict on one asset still grants an uncontested permission on another, where `invalid` would void the whole policy (declare `odrl:conflict odrl:invalid` for that); 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, so the comparison claims only the shapes above and returns `Possible` / `Unknown` for everything else, including a tighter bound under a looser one (`count lteq 3` under `count lteq 5`) and set refinements: it **never** reports `Certain` / `Contains` it cannot prove (the fail-OPEN failure mode: claiming an ask is covered when it is not).

## Security-property ODRL profile — `secx:requires…` leftOperands (opt-in `secprop-leftoperands`) — 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`](https://www.w3.org/TR/odrl-model/#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`):

```rust,ignore
// 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.

### Proof-discharge obligations — the ODRL half of the ZK↔ODRL envelope (same feature) — sq-yh427

`discharge_requirements(&Policy)` answers *"before I can decide this request, what would a presented proof have to establish?"* — it projects a parsed policy to the `secx:requires…` constraints it carries, each resolved to its dimension, operator and required level, in rule order (permissions before prohibitions). Rules that mention no `secx:` property are omitted, so an empty result means no presentation is needed.

```rust,ignore
// cargo: sparq-policy with --features secprop-leftoperands
use sparq_policy::secprop::{discharge_requirements, Deontic, DischargeExpr};

for r in discharge_requirements(&policy) {
    // r.rule / r.deontic / r.requirement — WALK the tree; the combinator decides.
    match &r.requirement {
        DischargeExpr::Any(alts) => { /* establishing ANY ONE of `alts` suffices */ }
        DischargeExpr::ExactlyOne(alts) => { /* establishing TWO of `alts` FAILS */ }
        DischargeExpr::All(conjuncts) => { /* every one must hold */ }
        DischargeExpr::Atomic(o) => { let _ = (o.dimension, &o.required); }
        DischargeExpr::Other(c) => { let _ = (&c.left, c.operator, &c.right); }
    }
}
```

- **The `odrl:` combinator is preserved, and is load-bearing.** The result is a `DischargeExpr` **tree per rule**, not a flat list: under `odrl:or` establishing *either* branch suffices, and under `odrl:xone` establishing more than one is a *failure*. `DischargeExpr::obligations()` gives a flat inventory of the leaves, but it is explicitly **NON-decisional** — `and`, `or` and `xone` over the same leaves flatten identically, so reading it as the requirement overstates what a presentation must prove.
- **Non-`secx:` branches survive WHOLE as `DischargeExpr::Other(Constraint)`** rather than being pruned or thinned: dropping a core-ODRL operand out of an `odrl:or` would turn an alternative into a mandate, and keeping only its `leftOperand` would leave `recipient eq Bob` and `recipient eq Carol` indistinguishable. The full `(leftOperand, operator, rightOperand)` triple is carried, so the branch is decidable from the tree alone — which is what makes `odrl:xone` readable, since there the *count* of holding branches, not the set of dimensions mentioned, is the verdict.
- **`Deontic` is load-bearing.** An obligation on a *prohibition* is not a bar to clear: a prohibition fires when its whole constraint expression holds, and deny-overrides. A host must not read the two the same way — and one leaf alone does not fire a multi-constraint prohibition.
- **Duty constraints are excluded** — the single-node base case checks a `Duty` by *action* discharge and never evaluates the duty's own constraints, so listing them would misdescribe what `evaluate` will check.
- **It reports; it does not discharge.** Extraction verifies nothing, orders no levels (the `secx:atLeast` partial order and the admissibility reduction live in `sparq-trust`, `sq-ufsi9`), and leaves the stateless `evaluate` path unchanged — a `secx:` constraint with no supplied evidence stays fail-closed unsatisfied.
- **The wire half of the envelope is deferred, deliberately.** A VC 2.0 Verifiable Presentation carrying a Data-Integrity proof under a `sparql-zk`-style cryptosuite is **not** implemented: no such cryptosuite exists in this workspace (`sparq-vc` implements `eddsa-rdfc-2022` only), its identifier and claim shape are still under cross-agent design with the Solid-server sibling (`#890`(b)), and sparq's ZK estate has **no** external accredited-cryptographer sign-off (`sq-qhy4`). <!-- privacy-claims-allow: NEGATIVE — records that the ZK presentation envelope is unimplemented and the estate unaudited; sq-qhy4 -->

## Bridge to WAC/ACP enforcement (opt-in `odrl-bridge`) — 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.

```rust,ignore
// 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, issuer: None, now: 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`).

**SPARQL-query action contract (sq-lrtc3.2): use `odrl:read`.** sparq does not mint a
profile-specific SPARQL-query action IRI: executing a query observes graph content and maps exactly
to `Mode::Read`, while a private action would add interoperability cost without a distinct
enforcement capability. An `odrl:use` **request** does not transitively become query access and
materializes no grant, so `query_as` sees nothing from it. The hierarchy still applies while
evaluating a policy (`odrl:use` permission can cover a concrete `odrl:read` request), but the bridge
maps the concrete request action rather than guessing which descendant of the broad `use` umbrella
was intended. Always construct query requests with the concrete read action:
`Request::new("http://www.w3.org/ns/odrl/2/read")`.

**Fail-closed:** a grant is materialized only on a *definite Permit* AND a *mappable action* AND a *concrete party (WebID) + target graph*. The party must be a single agent (not `auth:Public`/`auth:Authenticated` or a reserved encoding) and the recipient the decision checked, and the permit must be `lasting()`, since the stored triple is never re-checked. A count-limited grant is never lasting, so the counted entry point refuses it before any usage is spent. A Deny, unsatisfied constraint, undischarged duty, unmapped action, or partyless/targetless request materializes **nothing**.

**Interactive surface** — 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) — 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`.

```rust,ignore
// 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** (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).

### `materialize_odrl_permission_conditional` stores the one-shot grant only

`materialize_odrl_permission_conditional` used to persist recipient, assignee and `dateTime` constraints as re-checked `auth:ConditionalGrant` heads (agent matchers, `noneOf` exceptions, live-clock windows). A head re-checks identity and clock but not the rest of the decision, so it could grant a session `decide` denies: a prohibition on another party or a later time, or an assignee and a recipient that must both hold. It now stores exactly what `materialize_odrl_permission` stores: the one `party auth:<mode> target` triple from the `Permit` that `decide` issued for that request, and only when the permit is `lasting()` (an unconstrained grant to the named party, in a policy with no prohibitions). Constrained grants, and grants under any prohibition, are tracked in [#6743](https://github.com/sparq-org/sparq/issues/6743) (per-request evaluation through `decide`).

### Request-time prohibitions (`PodStore::attach_odrl_policy`) — #6743

`PodStore::attach_odrl_policy(policy)` keeps a `ValidatedPolicy` on the store and checks its prohibitions with `matched_prohibition` for the **accessing session** on every authorizing call. Nothing is materialized, so a party-scoped prohibition denies only the parties it applies to, and an asset needs no materialization to be covered.

- **Deny-only.** The layer removes modes the static WAC/ACP decision grants and never adds one, so it composes with ACP denies, origin restrictions and `Control` as an intersection. Attached permissions grant nothing here; a lasting grant still goes through `materialize_odrl_*`.
- **One gate.** Every entry point goes through it:
  - the reads behind the cached session set: `accessible`, `accessible_set`, the views and queries, `wac_allow` and `scoped_dataset`;
  - point decisions: `decide`, `decide_batch` and `decide_create`;
  - every update path, including the graphs an update's `WHERE` reads (it is evaluated under the same session read set).
  
  `decide_create` refuses control-document names first, then checks prohibitions on the container and on the child. `tests/odrl_request_decide.rs` fails when a new public function that takes a `Session` is not classified.
- **Session context.** The session's agent is the party and default recipient, and its `now` is the request time. `WAC-Allow`'s `public` field keeps the request clock. `add_odrl_asset_membership(asset, collection)` adds `odrl:partOf` evidence to every request, so an asset that joins a prohibited collection is denied at once. No party-membership evidence reaches a request, so when a policy names an `odrl:PartyCollection` a prohibition applies if it applies under any membership the agent could have: a prohibition on a collection reaches every authenticated agent, and one excluding a collection still reaches them too. Every membership subset is evaluated, so a policy with prohibitions may name at most four party collections; `attach_odrl_policy` refuses a larger one.
- **Mode mapping.** A mode is removed when a prohibition applies (True or Unknown) to any ODRL action of that mode:
  - the read family maps to `Read`;
  - `append`/`modify`/`delete`/`write` map to both `Append` and `Write`.
- **Never touched:** `Control`, `.acl`/`.acr` documents and the reserved `urn:sparq:` graphs, so a policy cannot lock an owner out of their own rules.
- **Refused at attach time:** a policy whose `odrl:conflict` strategy `decide` cannot honour.
- **Tests.** `tests/odrl_request_decide.rs` runs every session and clock across the generated policy space:
  - the result equals the static verdict narrowed by `matched_prohibition`;
  - it never denies a session that the materialized conditional deny lets through.
- **Not cached yet:** each call re-evaluates the attached prohibitions for each candidate graph.

### Constraint-conditional DENY (`materialize_odrl_prohibition_conditional`) — sq-4r70

The dual of the conditional grant: each ODRL **prohibition** whose action covers the request's action and whose target covers the request's target (the asset itself, an asset collection the request's `odrl:partOf` evidence puts it in, or no target, as `decide` reads it) is persisted as an **unconditional** `auth:ConditionalGrant` with **`auth:effect auth:Deny`** on `auth:Public`: every agent and every anonymous session is denied that target and mode. The deny does not depend on who triggered materialization. It **composes with deny-overrides**: the session layer subtracts `∪ deny` from `∪ allow`, beating any allow for the same target and mode.

- **Over-deny until #6743.** The assignee and constraints are not re-checked per session, so a prohibition scoped to one party (an assignee, a `recipient` constraint) also denies every other party on that asset. Re-checking the party per request through `decide` is #6743.
- The one-shot deny for the materializing request is stored too. An anonymous request has no party to freeze a one-shot deny to, so `materialize_odrl_prohibition` stores the same unconditional deny for it.
- `tests/odrl_deny_superset.rs` generates prohibitions over every constraint left operand, assignees (a named agent, an unseen agent, a party collection, wildcard principals) and targets (the asset, a collection holding it, no target, an unrelated asset), materializes as one party or anonymously, and checks that every session `decide` denies is denied after materialize, refresh and a ledger replay.
- **Refresh.** Tracked as `BridgeKind::ProhibitionConditional`. On refresh the deny is re-derived from the policy, so it stays while a prohibition still covers the target; a withdrawn prohibition re-emits nothing and the deny is **retracted**, restoring access.

### Refresh / REVOCATION of bridged grants on policy change — 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 — 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.

```rust,ignore
// 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) — 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 `ScopePattern`s; 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.

```rust,ignore
// 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:

```turtle
<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`](../../research/odrl-pattern-scoped-targets-2026-07.md).

## Conformance — SolidLab ODRL Test Suite — sq-tmsd6

The evaluator is ratcheted against the MIT-licensed
[SolidLab ODRL Test Suite](https://github.com/SolidLabResearch/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
(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`](../../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 (sq-elg47)
- Sibling access-control skill: [`skills/http-server`](../http-server/SKILL.md) (Solid WAC/ACP via `sparq-solid`)
- W3C [ODRL Information Model 2.2](https://www.w3.org/TR/odrl-model/) · [Formal Semantics](https://w3c.github.io/odrl/formal-semantics/)