| name | access-control |
| description | Graph-level Solid-style access control over a sparq RDF dataset with the opt-in sparq-solid crate: store pods as named-graph-per-document, keep WAC (.acl) / ACP (.acr) documents as queryable triples, materialize their semantics to a queryable authorization view (urn:sparq:auth) via N3 rules, then filter every SPARQL query/update per (WebID, client, issuer) session to the authorized graph set — fail-closed. Use when gating which named graphs a session may read/append/write, mapping WAC/ACP to an allow-deny model, or querying the materialized auth view. RESEARCH/architecture track — this is the authorization LAYER, NOT a production Solid Pod HTTP server, and it does NOT authenticate (the caller asserts the WebID, client, and issuer). |
| license | MIT |
| metadata | {"version":"0.1.0","homepage":"https://github.com/jeswr/sparq"} |
sparq-solid — graph-level WAC/ACP access control
sparq-solid is an opt-in crate that enforces Solid-style access control over a
sparq RDF dataset at the named-graph granularity. A pod is stored as
one named graph per document; the WAC (.acl) and ACP (.acr) access-control
documents stay as plain queryable triples; their semantics are encoded as N3 rules
(run by sparq-reason) that materialize a queryable authorization view in the
reserved <urn:sparq:auth> graph. Every query/update is then filtered per
(WebID, client, issuer) session to the named graphs that session may access —
fail-closed, with zero Solid-specific code in the engine itself (the crate is a
dependency of nothing in the workspace).
What this is — and is NOT (read first)
This is an architecture / research-track layer, not a deployable Solid server.
- It is the authorization half only. It answers "may this
(WebID, client, issuer)
session read/append/write named graph G?" by materializing WAC/ACP into a queryable
view and filtering the dataset. It does NOT authenticate: a Session.agent is a
caller-asserted WebID string — there is no WebID-OIDC / DPoP / token verification.
The relying application is responsible for authenticating the WebID before it hands one
to query_as. Treat a Session as a trusted claim, not a verified one.
- The
Session.issuer field (ACP acp:issuer) does NOT add WebID-OIDC
authentication. It is one more caller-asserted matcher dimension: the caller states
which OIDC issuer it claims vouched for the WebID, and an ACP acp:issuer matcher
decides whether that asserted issuer is acceptable. sparq-solid never contacts an IdP,
validates an ID token, or verifies the issuer binding — it only string-matches the
asserted issuer against the grant. As with agent, the relying application must verify
the issuer↔WebID binding before asserting it. WAC has no issuer notion, so a WAC pod
ignores the field entirely.
- It is NOT a Solid Pod HTTP server. There is no HTTP resource protocol, no LDP
container CRUD, no
Link rel=acl discovery over the wire, no Solid notifications. The
document→named-graph naming is a storage convention (design §2.2), not an HTTP server.
- The honest support matrix, threat model, security boundaries, and measured baseline
live in the design record
(
research/solid-access-control-design.md);
the README's support matrix and cargo doc -p sparq-solid are the user docs. Do not
present this crate as a production Solid implementation.
Quickstart
crates/sparq-solid/Cargo.toml (consumes sparq-core / sparq-engine / sparq-reason;
no cargo features of its own):
[dependencies]
sparq-core = { path = "../sparq-core" }
sparq-solid = { path = "../sparq-solid" }
The same query, different sessions, different results — fail-closed:
use sparq_core::Graph;
use sparq_solid::{Mode, PodStore, Session, wac_fixture};
# fn main() -> Result<(), String> {
let graph = Graph::load_dataset(&wac_fixture(), "nquads")?;
let mut store = PodStore::new(graph);
store.materialize_wac()?;
let q = "SELECT ?title WHERE { ?s <https://ex.dev/ns#title> ?title }";
let alice =
Session { agent: Some("https://alice.ex/card#me"), client: None, issuer: None, now: None };
let authorized = store.query_as(&alice, Mode::Read, q)?;
let public_only = store.query_as(&Session::default(), Mode::Read, q)?;
let _ = (authorized.rows.len(), public_only.rows.len());
# Ok(()) }
Public API
Materialize the authorization view from the access-control documents, then enforce:
PodStore::new(graph) -> PodStore — wrap a loaded dataset. Before the first
materialize_* call every session (including the owner) sees nothing.
store.materialize_wac() / store.materialize_acp() -> Result<MaterializeStats, _> —
run the N3 rules to (re)install <urn:sparq:auth>. On wasm32-unknown-unknown the
informational MaterializeStats::millis is reported as 0.0 (no std::time::Instant);
the auth view is identical ([OPUS-4.8] sq-7agop).
store.materialize_acp_with(&AccessProvenance) -> Result<MaterializeStats, _> —
([OPUS-4.8] sq-3jtd.5) ACP materialization that ALSO resolves acp:CreatorAgent /
acp:OwnerAgent matchers against per-resource creator/owner WebIDs supplied by the
TRUSTED caller. AccessProvenance::set_creator(resource, webid) /
set_owner(resource, webid) build the map; the loader synthesizes
<r> solidx:creator|owner <w> facts from THAT map ONLY. The loader hard-rejects any
control-document triple whose predicate is in the derivation-internal solidx: namespace
(§2.4), so a writer who embeds <r> solidx:creator <self> in a content document OR in the
.acr they control cannot self-grant (this also blocks forged solidx:appliesToResource
policy redirection). Each grant is RESOURCE-SCOPED (the creator of R1 is never
granted R2) and composes with the matcher's own acp:client/acp:issuer constraints.
materialize_acp() is materialize_acp_with(&AccessProvenance::new()) — no provenance ⇒
no CreatorAgent/OwnerAgent grant (fail-closed).
store.query_as(&Session, Mode, sparql) → QueryResult (.rows);
store.query_json_as(...) → JSON string; store.ask_as(...) → bool. These evaluate
through the engine's zero-copy DatasetView filtered to the session's authorized
graphs (the default, fast path).
- Empty default graph + union-default-graph opt-in — SPEC-COMPLIANT BY DEFAULT
([OPUS-4.8] sq-gq28y, issue #1546; maintainer decision "spec compliant - empty by default").
The read path (
query_as/query_json_as/ask_as) implements the Access-Controlled SPARQL
Query over a Solid Pod Editor's Draft
empty-default + explicit-union semantics out of the box. The standing default graph is
empty (a bare { ?s ?p ?o } matches nothing) UNLESS the query opts in with
FROM <http://www.w3.org/ns/solid/sparql#union-default-graph> (the spec-minted
UNION_DEFAULT_GRAPH_IRI), which makes the default graph the union of the authorized named
graphs for that request only (a pure function of the query's dataset clause — no
cross-request state leak). An explicit GRAPH ?g / GRAPH <g> pattern is unaffected either
way. Semantics + honest scope:
- the reserved IRI is a signal, never a graph name: it is stripped from BOTH
FROM
and FROM NAMED positions before evaluation and never binds GRAPH ?g. In FROM NAMED
position it is treated as absent (stripped, not intersected) so GRAPH ?g keeps
ranging over the full authorized set (draft: "when only the reserved IRI is given, the
named-graph set remains the authorized set");
- fail-closed, exact-match: a near-miss IRI is a normal (absent, per-pod-model) dataset
reference that contributes nothing and never widens — it does NOT silently enable the
union default graph. At this string API there is no "malformed opt-in value" (the signal
is one exact IRI, present-or-absent); the
default-graph-uri protocol-parameter
equivalent and its fail-closed validation are the Solid HTTP layer's responsibility
(solid-server-rs), not sparq;
- emulated at the query layer (the opt-in triggers the existing per-pattern
GRAPH
wrap; the engine view keeps its Empty default graph — no UnionOfVisible engine mode
is added, design record §5.4(a)). Default public surface: UNION_DEFAULT_GRAPH_IRI
(const) + wrap_for_view_opt_in(sparql) -> Result<String, _> (the spec-conformant
read-path rewrite, the differential oracle for the enforcement path).
- security invariant (preserved by the flip): empty-by-default is strictly MORE
conservative than union-always — it can only ever REMOVE a bare pattern's default-graph
solutions, never add graphs. Both variants evaluate over exactly the session's authorized
named-graph set (the view enforces that at the graph-name layer before evaluation); the
default-graph choice changes what the default graph sees, never which graphs are
readable, so it cannot widen the readable set.
- legacy escape hatch: the opt-in
legacy-union-default-graph feature (OFF by default)
reinstates the pre-flip union-always default (a bare pattern ranges over the authorized
union) for downstream deployments that relied on that idiom and need to migrate on their
own schedule; new code should use GRAPH ?g or the spec-minted opt-in.
- conformance: the query-semantics conformance class of the spec is vendored + run in
crates/sparq-solid/tests/conformance_solid_sparql_query.rs (upstream suite in
jeswr/solid-sparql-query:test-suite/query-semantics/) and passes all 15 cases.
store.query_as_rewrite(&Session, Mode, sparql) — the v1 FROM NAMED rewrite
portability path: enforces the same policy on any standard SPARQL 1.1 engine (one
deliberate semantic difference noted in-source: a caller FROM <g> can only restrict
the view, never widen it). NB the spec empty-default flip applies to the view path
(query_as) only — this v1 portability path keeps its union-always default-graph
emulation (it predates the draft and is a standard-SPARQL-portability oracle, not the
spec-conformant surface).
store.update_as(&Session, sparql) / store.update_as_acp(...) — write-path
gating: check every graph an update could mutate before applying, and
auto-re-materialize on .acl/.acr writes.
store.put_acl(acl_iri, content, format) -> AclWriteOutcome /
store.delete_acl(acl_iri) (+ …_acp variants) — authoritative ACL write-through
([OPUS-4.8] issue #992 FR-3, sq-snopa.5): the LDP PUT/DELETE /resource.acl STORAGE
primitive. Replaces (or removes) the .acl/.acr GRAPH and re-materializes
<urn:sparq:auth> as ONE atomic, fail-closed unit, so SPARQ stays the source of
truth (the auth view is always a pure function of the present .acl graphs — no stale
grant survives a rule change). content is parsed FIRST (malformed ⇒ Err, nothing
mutated); a re-materialization failure (e.g. a reserved-encoding principal) ROLLS BACK to
the prior content + prior auth view (all-or-nothing). A non-.acl/.acr target IRI is
rejected (write content graphs through update_as). It performs NO session
authorization — the server authorizes the request itself (e.g. decide(.., Mode::Control))
and update_as remains the session-checked write path. Always-present API (no cargo gate;
mirrors update_as/decide — adds no dependency).
Scale ([OPUS-4.8] issue #1571, sq-b7k7u): an atomic single-.acl/.acr write still
re-materializes the whole auth view (the incremental materializer is deferred), but the
session-cache invalidation is diff-based ([SONNET-4.6] sq-b7k7u fix): reindex_with
diffs old vs new AuthIndex per-origin and invalidates exactly the origins whose (allow,
deny, cond) buckets changed — so a write to one pod does not cold-start every other
pod's cached view, and cross-origin dependencies (WAC acl:agentGroup membership hosted on
a different pod, foreign-subject grant triples, ACP cross-document policy/matcher
indirection) are caught automatically: if a write at origin A changes B's effective grants,
B's new index buckets differ → B is invalidated too. If the matcher maps change (not
origin-bucketed), it falls back to a full cache clear. A differential property test asserts
the scoped-cache state equals a from-scratch rebuild after every write (incl. the
fail-open-critical revoke direction and cross-origin agentGroup revoke).
The same origin-partitioned index makes decide cost one pod's grants, not the whole store.
store.accessible(&Session, Mode) -> Arc<Vec<NamedNode>> /
store.accessible_set(...) / store.view_for(...) -> DatasetView /
store.auth() -> &AuthIndex — inspect the authorized graph set or the materialized
index directly.
- Concurrent reads — the read side is
&self ([FABLE-5] sq-cnuqd, issue #1569). Every
read entry point (accessible, accessible_set, view_for, query_as, query_json_as,
ask_as, query_as_rewrite, wac_allow, decide) takes &self, so N threads sharing
one Arc<PodStore> query the same materialized generation at once — a per-request
Solid/SPARQL server no longer serialises every reader through one exclusive borrow. The
session cache behind these is sharded + bounded: session_cache::SessionCache stripes
the memoized per-session graph sets across independent RwLock shards keyed by the session
hash, so a warm hit is a shard read lock (+ two Arc clones) and only a miss/re-derive
takes that one shard's write lock; each shard is LRU-bounded (SHARD_CAP, with a
compacted recency queue) so a server keying per-request now timestamps cannot grow the
cache without bound. Writes stay &mut self (materialize_*, put_acl/delete_acl,
update_as) — they need exclusive access, so a server interleaves reads and writes by
wrapping the store in an RwLock<PodStore>/arc-swap (readers take the read lock, a write
takes the write lock). Snapshot consistency (fail-closed, no torn set): each read pins
ONE Arc<AuthIndex> snapshot for the whole set computation, and because writes are
&mut self (exclusive), a read observes either the whole pre-write generation or the whole
post-write one, never a half-and-half mix (generation pinning composes with #1584);
invalidation (SessionCache::clear / invalidate_origin, the diff-based #1585 path) visits
every shard so it reaches a session regardless of which shard it hashed into, and an
evicted/dropped set is re-derived on the current auth view — never assumed still authorized.
Covered by tests/concurrent_readers.rs (16 threads on one Arc<PodStore>; a revoke under
concurrent read observes only the two atomic snapshots and converges on the revoked view).
store.wac_allow(&Session, &NamedNode) -> String — build the public
WAC-Allow response-header value
(user="…",public="…") advertising the modes the session (and the public) hold on a
resource ([OPUS-4.8] sq-i7k08); fail-closed, only the modes actually held. See the
request-pipeline section below.
store.decide(&Session, resource: &str, Mode) -> WacDecision /
decide_batch(&Session, &[(&str, Mode)]) -> Vec<WacDecision> — per-REQUEST WAC
decision ([OPUS-4.8] issue #992 FR-1, sq-snopa.1): the point-query "may principal X do
mode M on resource R?" an LDP resource server asks (NOT graph filtering). Returns
WacDecision { allow, granted_modes: Vec<Mode>, governing_acl: Option<NamedNode>, scope: Option<AclScope>, status: AclStatus }. The verdict reuses the SAME fail-closed
AuthIndex::accessible oracle, so a decide allow is never wider than query_as would
grant; granted_modes carries the full mode set in one decision (build a WAC-Allow
body without four sweeps). decide_batch builds the structural ACL index ONCE for a page
of resources. Always-present API (no cargo gate; mirrors wac_allow). API tier-1
(proposed-stable) — the proposed semver-stable per-resource decision surface (freeze
pending maintainer ratification, #1346 / #1248; see
docs/api-stability.md).
WacDecision::acl_link_header() -> Option<String> / AclScope::as_acl_predicate() -> &'static str — effective-ACL provenance surface (FR-5, sq-snopa.4): the <acl-iri>; rel="acl" RFC-8288 Link value a
server emits alongside wac_allow so a client can discover/edit the governing ACL, plus
the WAC predicate IRI (acl:accessTo/acl:default) for the scope in structured logs.
Built straight from the governing_acl/scope FR-1 already puts on the decision (so it
can never point at a document discovery did not find). Fail-closed: None when no
governing ACL was discovered (NoAcl/Transient) — nothing to advertise; Some on
Unloaded (the ACL is discovered, even on a retryable 503). It surfaces where the ACL
is, never a verdict, so it is safe to emit on a deny.
AclStatus — the typed fail-closed load/error contract (FR-6, sq-snopa.2): a server
maps Resolved (authoritative — allow is the real verdict; a deny is 403), NoAcl
(no governing ACL anywhere up the chain — definitive 403/401), Unloaded (a governing
ACL exists but materialize_* was never run — a retryable 503), and Transient
(a typed transient error, e.g. a malformed resource IRI — a retryable 503).
AclStatus::is_retryable() separates the 503s from the definitive denies. It never
fails OPEN: allow == false for every non-Resolved status, independent of whether the
caller inspects status — absent ⇒ deny, present-but-unloaded ⇒ deny, transient ⇒ deny.
store.resolve_acl(resource: &str) -> Option<EffectiveAcl> — ACL-discovery walk in ONE
indexed call (FR-7, sq-snopa.3): the up-the-container-chain .acl/.acr discovery +
acl:default inheritance, returning EffectiveAcl { acl: NamedNode, scope: AclScope } —
AccessTo when the resource has its OWN ACL, Default when it inherits from the nearest
ancestor container — instead of N round-trips. The walk is exactly the loader's
slash-semantics parent_iri chain, so per-resource discovery and the materialize-time
inheritance can never disagree; None ⇒ un-protected ⇒ fail closed. Feeds decide's
governing_acl/scope, which WacDecision::acl_link_header() turns into the FR-5
Link: rel="acl" surface (above). Honest scope: Phase-1 implements
the WAC accessTo + container-default subset of issue #992
(FR-1/6/7) — the per-resource decision + fail-closed contract + ACL walk. The HTTP shell
over this library surface (FR-4, sq-snopa.6) has LANDED as sparq-server's opt-in
solid-authz feature — POST /authz/decide+/wac-allow+/query, a fail-closed thin wrapper
that maps these very verdicts onto HTTP status codes (an AclStatus 403/503 split). The
ODRL lane on /authz/* (sq-lrtc3.1 + sq-3mu76) has LANDED as sparq-server's opt-in
odrl-authz feature — dataset-carried ODRL policies are fed through materialize_odrl_policy
(deny-overrides, fail-closed) before the access-controlled query runs, so an ODRL policy gates a
SPARQL query end-to-end over HTTP; since sq-3mu76 the advisory /authz/decide +
/authz/wac-allow run the same lane (read-scoped advertisement), so they never report an allow
the query lane would refuse to honour. The
FR-5 Link: <acl-iri>; rel="acl" response header (sq-snopa.7) is now wired: /authz/decide
and /authz/wac-allow emit it from acl_link_header() / resolve_acl() when a governing ACL
was discovered; None ⇒ no header (fail-closed). See the http-server skill. That HTTP layer
does NOT authenticate — it takes an already-resolved session, exactly as this library does. The
decide scope is the ACL-document discovery scope, while whether a grant within that ACL
applies is the verdict the oracle computes.
store.materialize_odrl_permission(&Policy, &Request) -> BridgeOutcome — opt-in
(odrl-bridge feature, OFF by default; [OPUS-4.8] sq-h3uk): run the sparq-policy ODRL
evaluator and, on a definite Permit, materialize the equivalent principal auth:<mode> graph grant into the auth view, then reindex — so this same enforcement path applies it.
Fail-closed (Deny / unmapped action / partyless / targetless → no grant). See the
usage-control-policy skill for the action→mode mapping.
store.materialize_odrl_prohibition(&Policy, &Request) / materialize_odrl_policy(...) —
same opt-in feature ([OPUS-4.8] sq-w693): a matched ODRL Prohibition materializes the dual
principal auth:deny<Mode> graph triple, honoured by this enforcement under deny-overrides
(∪ allow ∖ ∪ deny — a deny beats any allow for the same principal+target+mode). …_policy
does both sides at once. Same fail-closed rules; no new enforcement engine.
- Loud refusal of an unimplementable
odrl:conflict strategy ([OPUS-4.8] sq-ihqbl): the bridge
implements only odrl:conflict odrl:prohibit (deny-overrides). A policy declaring odrl:perm,
odrl:invalid with a detected conflict, or an unknown strategy IRI is REFUSED — every
materialize_odrl_* entry materializes nothing and returns BridgeOutcome { refused: true, .. }
with a REFUSED (odrl:conflict): … reason, rather than silently coercing it into deny-overrides.
An unset odrl:conflict defaults to prohibit (not refused). See the usage-control-policy skill.
store.materialize_odrl_permission_conditional(&Policy, &Request) -> BridgeOutcome —
opt-in (odrl-bridge; [OPUS-4.8] sq-hiz4): persists a faithfully-mappable ODRL
constraint as a re-checked ACP auth:ConditionalGrant (agent matcher) instead of a
one-shot allow — so the granted agent is verified per session, not frozen to the
materializing party. odrl:recipient/odrl:assignee
(eq/isA/isPartOf/isAnyOf/neq/isNoneOf — the set operators one head /
exception per member, [FABLE-5] sq-5fkpp) maps
faithfully (recipient-of-data = session agent); an odrl:dateTime inclusive bound
(lteq → auth:notAfter, gteq → auth:notBefore) maps to a live-clock window
re-checked against Session::now per request ([OPUS-4.8] sq-0q7n — a lapsed window denies
immediately, no refresh_odrl_grant needed); odrl:purpose/count/a strict dateTime
bound, and any compound odrl:LogicalConstraint (odrl:and/odrl:or/odrl:xone —
no faithful single-head analogue; [OPUS-4.8] sq-izzak) have no faithful analogue and STAY
one-shot; a rule mixing mappable + unmappable
constraints falls back entirely to one-shot (fail-safe — never drops a bound). A
dateTime window is mapped only on an allow (a lapsed deny would fail open). A bare
odrl:assignee rule PROPERTY (with zero constraints) scopes the grant head to that ONE
assignee — not auth:Public ([OPUS-4.8] sq-9n1q4; the deny dual likewise scopes to the
assignee, never an over-broad public deny). Only a rule with no recipient constraint AND
no assignee grants auth:Public. Mapping
table in the usage-control-policy skill.
store.refresh_odrl_grant(&Policy, &Request, BridgeKind) / refresh_odrl_grants() —
opt-in (odrl-bridge; [OPUS-4.8] sq-dpk4): re-evaluate bridged ODRL grants when
the policy changes and retract the ones that no longer hold (a withdrawn permission, a
lapsed time window, a re-evaluation that now Denies) while preserving static WAC/ACP grants
and still-valid bridged grants. Bridged triples are tracked in a ledger and mirrored into a
provenance graph <urn:sparq:auth-bridged> (AUTH_BRIDGED_GRAPH) so they are structurally
distinguishable from static grants; refresh rebuilds the view as static_baseline ∪ replay(valid bridged entries). Fail-closed: any ambiguous re-eval of an allow grant
retracts (access never left stale); a static grant is never re-evaluated or dropped. A
wholesale static re-materialization (materialize_wac/materialize_acp) auto-reconciles —
valid bridged grants are replayed back on top. A bridged deny ([OPUS-4.8] sq-2pcf) is
the dual and uses the OPPOSITE fail-closed rule: a materialized auth:deny* is retracted
only when the ODRL Prohibition is definitely withdrawn/lapsed (prohibition_status == Withdrawn); an ambiguous re-eval keeps the deny (never restore access on missing
evidence). A retracted deny may re-expose an allow grant — correct, since the prohibition
is genuinely gone.
Session { agent: Option<&str>, client: Option<&str>, issuer: Option<&str>, now: Option<&str> }
(agent/client/issuer caller-asserted: WebID + acl:origin/acp:client + the OIDC
acp:issuer; None = anonymous / any client / any issuer respectively). [OPUS-4.8]
sq-3jtd.6: the issuer field is the third matcher dimension (ACP only — WAC ignores it)
and is a STRING MATCH on a caller-asserted issuer, not an authentication step (see
"What this is — and is NOT"). [OPUS-4.8] sq-0q7n: now is the request clock (an
xsd:dateTime lexical string), consulted only by time-windowed conditional grants —
a windowed grant with now == None fails closed; set it with Session::at(now).
Mode::{Read, Write, Append, Control}; wac_fixture() / acp_fixture() (bundled demo
pods).
triple_principal(agent, client, issuer) -> String / pair_principal(agent, client) -> String — the deterministic minted principal IRIs (urn:sparq:triple?… /
urn:sparq:pair?…) that the ACP/WAC N3 rules emit for an issuer- / client-constrained
grant, kept in sync with the rules. Both use RFC 3986 percent-encoding and are
INJECTIVE, so no agent/client/issuer value can smuggle a &client=/&issuer=
delimiter into another principal's term. Exposed for inspection/round-trip tests.
ANY_ISSUER / ANY_CLIENT / PUBLIC / AUTHENTICATED — the principal-lattice top
IRIs (https://sparq.dev/ns/auth#AnyIssuer etc.). [OPUS-4.8] sq-3jtd.6: an ACP grant
with no acp:issuer matcher is issuer-unconstrained ⇒ ANY_ISSUER (the issuer-dimension
top); a session with issuer: None matches it.
The materialized view is itself just triples — "who can read G?" is one SPARQL
pattern: GRAPH <urn:sparq:auth> { ?who <https://sparq.dev/ns/auth#read> ?doc }.
Request-pipeline integration for a Solid server (incl. WAC-Allow headers) — issue #1126
examples/quickstart.rs shows the load → materialize_wac → query_as/accessible
loop. A Solid request handler wraps that with three steps: (1) build a Session
from the authenticated request, (2) gate the request with accessible(...) /
query_as(...), (3) emit a WAC-Allow
response header advertising the modes the agent (and the public) hold on the target
resource. Step (3) is the public helper
PodStore::wac_allow(&Session, &NamedNode) -> String
([OPUS-4.8] sq-i7k08) — it builds the RFC-style user="…",public="…" value over the
existing accessible API (the user list is the authenticated session's modes; the
public list is an anonymous Session::default()'s), fail-closed and with only the
modes actually held:
use sparq_solid::{Mode, PodStore, Session};
use oxrdf::NamedNode;
fn session_from_request<'a>(webid: Option<&'a str>, origin: Option<&'a str>) -> Session<'a> {
Session { agent: webid, client: origin, issuer: None, now: None }
}
fn may(store: &PodStore, s: &Session, mode: Mode, resource: &NamedNode) -> bool {
store.accessible(s, mode).iter().any(|g| g == resource)
}
fn wac_allow_header(store: &PodStore, s: &Session, resource: &NamedNode) -> String {
store.wac_allow(s, resource)
}
Per request: session_from_request(...) → may(&store, &s, Mode::Read, &resource)
to allow/deny (a deny is 403/404 per your fail-closed policy) → set the response's
WAC-Allow header to store.wac_allow(&s, &resource). wac_allow runs four
accessible sweeps that share the per-session cache (each an O(1) hash check over the
materialized index), so it is cheap. Because these read helpers take &self (sq-cnuqd),
a concurrent server shares ONE Arc<PodStore> (or Arc<RwLock<PodStore>> if it also serves
writes) across all request workers — they read in parallel through the sharded session cache;
only a put_acl/materialize write needs the exclusive (&mut/write-lock) side. To pair the WAC-Allow advertisement with the WAC
ACL-discovery header, run store.decide(&s, "<resource-iri>", Mode::Read) and emit its
acl_link_header() (Some("<acl-iri>; rel=\"acl\"")) as the response Link header value
when present (FR-5) — fail-closed: it is None when no governing ACL was discovered, so
nothing leaks on an un-protected resource. Scope caveat: sparq-solid is a
library-level authoriser — there is no HTTP surface here (no .well-known, no served
acl: resource); acl_link_header() builds the Link header value from the discovered
governing ACL, but emitting it (and mapping a request path to its named graph, and
authenticating the WebID) is the server's job (see research/sparq-solid-scope.md §4).
wac_allow/acl_link_header build header values from the authorization verdict +
discovery; neither is itself a conformance-tested HTTP layer.
Capability notes (WAC + ACP)
- WAC (
.acl) — acl:agent (WebID), acl:agentClass foaf:Agent (public) /
acl:AuthenticatedAgent, acl:agentGroup, acl:default inheritance, the
acl:Read/Write/Append/Control modes.
- ACP (
.acr) — policies / matchers with the allOf / anyOf / noneOf
combinators and normative deny-overrides. [OPUS-4.8] sq-3jtd.6: matchers may now
also constrain on acp:issuer (the caller-asserted OIDC issuer), the third principal
dimension. [OPUS-4.8] sq-3jtd.5: matchers may also use acp:agent acp:CreatorAgent /
acp:OwnerAgent — the context agent must be the resource's creator / owner, resolved
against the TRUSTED per-resource provenance supplied via materialize_acp_with (above),
resource-scoped and fail-closed.
- Principal lattice — three independent dimensions (agent, client, issuer):
Public ⊒ Authenticated ⊒ concrete-WebID, AnyClient ⊒ concrete-client, and
([OPUS-4.8] sq-3jtd.6) AnyIssuer ⊒ concrete-issuer. A session expands to the agent
principals, plus — when a client is given — the minted (agent, client) pair, plus —
when an issuer is given — the minted (agent, client, issuer) triple (with
AnyClient as the client component when no client was supplied), for ≤12 grant lookups.
Only an issuer-CONSTRAINED grant mints a triple principal; an issuer-unconstrained grant
(AnyIssuer) reuses the agent / pair term, so issuer-blind pods are unaffected.
ACP conformance harness — [OPUS-4.8] sq-3jtd.9
sparq_solid::conformance is a library-level ACP conformance harness: a table-driven
scenario runner over this crate's own ACP engine (materialize_acp +
AuthIndex::accessible). A scenario is declared as data — an ACR-document corpus plus a
table of expected (agent, client, mode, resource) → Allow | Deny decisions — and the
harness asserts the engine reproduces every expected decision, reporting all mismatches
at once. The module is always compiled (no feature gate; it depends only on the
always-present ACP path). The scenario corpus is a single reusable source in
crates/sparq-solid/tests/common/ (common::acp_corpus(), 13 scenarios / 45 decisions),
consumed by crates/sparq-solid/tests/conformance_acp.rs (the parity test, plus a negative
control asserting a wrong expectation is reported, not panicked) so a second test target —
the differential oracle (sq-t58w.7) — can run the IDENTICAL scenarios without copy-paste.
What it is — and is NOT (honest scope, mirrors the module/README/research/sparq-solid-scope.md §4):
- It is the realistic, achievable conformance signal for an authorization oracle —
library-level decision parity against the Solid ACP spec
semantics. It is NOT a normative-CTH pass and makes no such claim.
- CTH-over-HTTP (the Solid Conformance Test Harness /
solid/specification-tests, which
drives a server and asserts on HTTP outputs) is out-of-scope — this crate has no HTTP
surface; that belongs to a Solid server, conformance-tested through it.
- A JS-reference differential oracle (Community Solid Server / Inrupt ESS over the same
corpus) is the credible second oracle but is research-open (JS-toolchain cost) and is
not built here — captured as a follow-up.
- It is a test/oracle harness over the existing engine — it does not change authorization
behaviour. It is complementary to
tests/acp.rs (a hand-derived access matrix over one
realistic pod): this harness gives spec-construct coverage with small, independently-failing
cases.
- The WAC conformance harness (
sq-3jtd.8, sparq_solid::wac_conformance) mirrors this
one — the same harness shape with a WAC .acl corpus builder; see its section below. The
two share the conformance::{Decision, Expect, ScenarioReport} vocabulary.
Public surface (pub mod conformance, re-exported from the crate root for the entry types):
AcrBuilder — build the ACR-document corpus as N-Quads. acr.access_control(resource, |p| …) / acr.member_access_control(resource, |p| …) attach a policy (acp:accessControl
/ acp:memberAccessControl for cumulative ancestor inheritance); acr.document(resource)
declares the protected resource graph; acr.into_nquads() emits the corpus.
PolicyBuilder<'a> (the |p| … closure argument) — allow(Mode) / deny(Mode),
any_of_agent / all_of_agent / none_of_agent, any_of_client / all_of_client,
all_of_pair(agent, client) — the acp:allOf / acp:anyOf / acp:noneOf combinators over
acp:agent / acp:client matchers, with PUBLIC_AGENT / AUTHENTICATED_AGENT consts for
the acp:PublicAgent / acp:AuthenticatedAgent matcher IRIs.
AcpScenario — new(name), .acr(AcrBuilder) / .nquads(&str) (the corpus),
.expect(Expect) / .expect_all(iter) (the decision table), .run() -> Result<ScenarioReport, String> (materializes + checks). run_corpus(&[AcpScenario]) runs
a whole corpus. Read-only accessors .name(), .nquads_str() -> &str (the raw N-Quad
corpus the engine loads) and .expects() -> &[Expect] (the decision table) let a SECOND
consumer — the differential oracle — feed the IDENTICAL corpus to an independent decider.
Expect — one expected decision, built fluently: Expect::agent(webid) /
Expect::anonymous() / Expect::pair(agent, client), then .read/.write/.append/.control( resource), then .is(Decision::Allow | Decision::Deny).
ScenarioReport — .passed(), .checked(), .mismatches() (the failing
DecisionResults), .name(), and a readable mismatch Display. Decision::{Allow, Deny}
is the binary the harness compares against the engine verdict (graph in the session's
accessible set ⇒ Allow).
WAC conformance harness — [OPUS-4.8] sq-3jtd.8
sparq_solid::wac_conformance is the WAC sibling of the ACP harness above: the same
table-driven scenario runner, but over this crate's WAC engine (materialize_wac +
AuthIndex::accessible) against the Solid WAC spec
semantics. A scenario is an .acl-document corpus (AclBuilder) plus a table of expected
(agent, client, mode, resource) → Allow | Deny decisions; the harness asserts the engine
reproduces every one, reporting all mismatches at once. Always compiled (no feature gate).
The corpus is a single reusable source in crates/sparq-solid/tests/common/
(common::wac_corpus(), 13 scenarios / 51 decisions), consumed by
crates/sparq-solid/tests/conformance_wac.rs (the parity test, a run_via_podstore parity
test over the full PodStore method-form path, and a negative control) and reusable by a
second test target — the differential oracle (sq-t58w.7) — without copy-paste. The
honest-scope caveats are identical to the ACP harness's (library
decision parity, NOT a normative-CTH pass; CTH-over-HTTP out-of-scope; CSS differential oracle
research-open).
Public surface (pub mod wac_conformance, re-exported from the crate root for the entry types;
the decision/expectation/report types are the shared conformance::{Decision, Expect, ScenarioReport}):
AclBuilder — build the .acl corpus as N-Quads. acl.access_to(resource, |a| …) /
acl.default_for(resource, |a| …) / acl.access_to_and_default(resource, |a| …) attach an
acl:Authorization (acl:accessTo on the resource's own ACL / acl:default for
nearest-ancestor inheritance over members / both); acl.document(resource) declares the
protected resource graph + inheritance anchor; acl.into_nquads() emits the corpus.
AuthBuilder<'a> (the |a| … closure argument) — mode(Mode), agent(webid),
public() / authenticated() / agent_class(class) (acl:agentClass foaf:Agent /
acl:AuthenticatedAgent), agent_group(group, &[members]) (emits the vcard:hasMember
group document), origin(client) (the acl:origin (user, app) pair). PUBLIC_AGENT
(foaf:Agent) / AUTHENTICATED_AGENT consts name the two recognised classes.
WacScenario — new(name), .acl(AclBuilder) / .nquads(&str), .expect(Expect) /
.expect_all(iter), .run() -> Result<ScenarioReport, String> (materializes via the free
AuthIndex path + checks), and .run_via_podstore() (the same check through the full
PodStore method form — proves the path a PSS integration uses). run_corpus(&[WacScenario])
runs a whole corpus. Read-only accessors .name(), .nquads_str() -> &str and .expects() -> &[Expect] expose the raw corpus + decision table to the differential oracle (below).
- The
Expect builder, Decision, and ScenarioReport are the same shared types as the ACP
harness — Expect::agent(webid) / Expect::anonymous() / Expect::pair(agent, client),
.read/.write/.append/.control(resource), .is(Decision::Allow | Deny).
Differential oracle — [OPUS-4.8] sq-t58w.7
crates/sparq-solid/tests/differential_oracle.rs is a three-way agreement check that
runs the shared parity corpus (common::wac_corpus() / common::acp_corpus()) through
THREE deciders for every (agent, client, mode, resource) request and asserts they all
agree:
- the engine —
materialize_{wac,acp} + AuthIndex::accessible (the N3-rules
paradigm — rules/*.n3 run by sparq-reason);
- an independent reference evaluator —
crates/sparq-solid/tests/reference/{wac,acp}.rs,
a from-scratch PROCEDURAL reading of the WAC/ACP spec over a hand-parsed model. Its
whole value is being a different paradigm: it shares no code with materialize.rs
/ loader.rs / rules/*.n3, and parses the corpus with its own tiny N-Quad reader (not
Graph::load_dataset), so a shared bug cannot hide in both deciders;
- the hand
Expect table — the human-authored expected decision in each scenario.
A divergence is recorded whenever any two disagree; an unclassifiable/erroring request
(failed load/materialize, or a reference parse error) counts as a divergence — fail-closed.
The test asserts divergences == 0 and prints, in the SHACL/geo runner shape (so a CI ratchet
can grep it):
WAC differential pairs <N> / divergences 0 (floor 0)
ACP differential pairs <N> / divergences 0 (floor 0)
It is a correctness oracle over the parity corpus, not a security audit, and the reference
evaluator implements exactly the constructs the corpus exercises (anything it does not
recognise fails closed → a divergence). Pure in-crate Rust — no JS toolchain, no network, no
clock, no Docker. A JS-reference differential twin (vs. @solidlab/policy-engine /
@solid/acl-check) is a separate research-open follow-up (sq-t58w.9), NOT built here.
Security posture — fail-closed
Absence of a grant means a graph is invisible, and a non-authorized graph is
indistinguishable from an absent one. The reasoner is fed only ACL/ACR + structural
facts — never pod content — so no writable document can grant itself access; the
reserved urn:sparq: namespace is rejected on input and any forged <urn:sparq:auth>
graph is stripped at load. A session whose agent/client/issuer falls inside the
reserved urn:sparq: encoding (or carries the &client=/&issuer= delimiter) fails
CLOSED — it cannot impersonate a minted pair/triple principal. (Again: this is the
authorization boundary — authenticating the WebID and verifying the issuer↔WebID
binding are the relying application's job.)
Trust-graph authorisation PoC — [OPUS-4.8] sq-pfae (issue #940, opt-in trust-graph)
The sparq-trust crate (crates/sparq-trust) is a research proof-of-concept that adds
an admission stratum ahead of the WAC/ACP derivation stratum: "is this externally-attested
fact from a source I trust for this statement-type?" On success it injects the issuer-tagged
fact so the shipped N3 reasoner merges it with the .acr rules to derive access — the age>18
worked example (a trusted-government VC <Jesse> age 25 + a trust policy + the .acr rule
{ ?x age ?y . ?y math:greaterThan 18 } => { ?x auth:read R } ⇒ <Jesse> gains read).
It is wired into sparq-solid behind the default-OFF trust-graph cargo feature. With the
feature off, sparq-solid is byte-identical to WAC/ACP today (strict additivity); with it
on, PodStore gains two install methods. The admission gate is the §6.0 algorithm:
RDFC-1.0 canonicalise (sparq-canon) → a checked issuer signature over the commitment
(sparq-zk, never a self-asserted triple) → statement-type scoping via a real SHACL shape
(sparq-shacl) → freshness (a per-request check) → the clear-WebID holder binding.
Two equivalent policy-authoring forms (parse_policy / resolve_rule_keys accept both;
sq-pfae.4). A Control-gated trust policy may name a rule either way, and both desugar to the
SAME Vec<TrustRule> the gate consumes:
-
Reified — a trust:TrustRule node grouping trust:source + trust:forShape/forPredicate
trust:scope + trust:freshWithin (+ key). Keeps scope/freshness per-rule.
-
Claim-level relational (trust:trustsSourceFor, the foundational primitive, design §2.3.1)
— a trust:Source node carries trust:trustsSourceFor <shape-or-predicate> directly, alongside
its shared key + trust:scope + trust:freshWithin; EACH trustsSourceFor statement is one
rule. This is the compact per-(source, statement-type) form that replaces ACP's type-only,
unimplemented acp:vc matcher with claim-level trust. The object is a sh:NodeShape node
(carried like forShape) or a predicate IRI (desugared like forPredicate); a blank-node source
is rejected fail-closed. It composes with the opt-in did key binding (trust:issuerDid on the
source node). Every soundness side-condition (checked signature, type-scope, holder binding,
reserved-predicate guard) is identical to the reified form — the claim-level form is NOT a weaker
admission path.
-
admit_trust_credential_static(credential, rules, target, abac_rule_n3) — the
materialise-time path (the sq-xc4y static/dynamic split). It runs only the
session-INDEPENDENT class (signature, type-scope, scope) and installs each derived grant as an
auth:ConditionalGrant whose holder (auth:agent) and freshness (auth:notAfter) are
re-checked per request by the shipped sq-0q7n cond_applies path — so holder/freshness are
never frozen into the materialise-once view (a stale or wrong-holder request is denied at query
time). Use this for a long-lived view.
-
admit_trust_credential_with_rule(credential, rules, session, target, abac_rule_n3) — the
single-request snapshot path: it runs the COMBINED gate against one live Session and
installs an UNCONDITIONAL grant valid for that request only (do not use it to populate a
long-lived view).
Both install on top of the unchanged auth view (the ODRL-bridge precedent). The
public surface is sparq_trust::{vocab, policy, admit, wire, delegation} (+ the opt-in
delegation_prov / did / store / secprop / framework_vocab modules) — see
crates/sparq-trust/README.md and the design record
research/solid-trust-graph-authz-design.md §6.0 (tracked in
issue #940; landing via design PR
https://github.com/jeswr/sparq/pull/951).
Trust-document storage / authoring model (the opt-in store feature, sq-pfae.5,
design §3.2). sparq_trust::store::TrustStore decides where trust rules live and which
version is in force: a server-wide default (the ceiling) plus zero-or-more per-.acr
documents (TrustLayer::{ServerWide, Container}), where a per-.acr document may only
NARROW, never broaden the server default — it can tighten a freshness window or restrict
scope, but can NOT trust a (source, statement-type) the server default did not
(TrustStore::effective_rules, the load-bearing soundness property). Documents are
Control-gated (same TrustDocument::new-requires-ControlGate channel as .acl/.acr),
monotonically versioned (a stale put is rejected), and revoked by removal. The
AdmissionCacheKey = (evidence hash + policy version) composes with the sparq-solid epoch
cache: a re-materialise bumps the epoch, a trust-document edit/revoke bumps the
policy_version for affected targets — so a cached admission verdict is never served stale.
Pure-Rust, no new dependency; OFF in the default build.
Honest scope (read first). This is a RESEARCH prototype, NOT a security guarantee. It
does not provide privacy, unlinkability, or anonymity: the credential is admitted in the
clear and the holder binding authenticates the WebID in the clear (the non-anonymous degraded
path, sq-wvne). The ZK estate it composes with is externally unaudited
(sq-qhy4, pending external accredited-cryptographer sign-off). Issuer keys are
operator-asserted by default (trust:issuerKey hex, the live forgery vector D′); the opt-in
did feature (sq-pfae.3) lets a rule bind its key from a trust:issuerDid instead —
sparq_trust::{DidKeyResolver, DidWebResolver, resolve_rule_keys}, did:key offline self-cert +
a pluggable did:web fetcher (no HTTP client forced on the default build). This narrows D′ but
is no absolute anchor: did:key is self-certifying, did:web only as strong as host/TLS. Open
problems are respected as documented limitations, never solved: sq-tu4e (no in-reasoner NAF over
derived facts; revoked is input-only; no deny-on-disagreement) and sq-wvne (ZK privacy) are
out of PoC scope. sq-xc4y (per-request holder/freshness admission vs the materialise-once view)
is RESOLVED by the static/dynamic split: see admit_trust_credential_static above and design
§3.3 A′. sq-l5og (the delegation invocation-binding gate, sparq_trust::delegation) IS
implemented: it binds each hop's delegate_key into the delegator-signed preimage, defeating the
key-substitution stolen-chain replay — but it does not claim full non-replayability, because
the delegator's own key is still operator-asserted (sq-pfae.3); see the crate README Honest scope.
Delegation PROV-O audit for human + AI agents (the opt-in delegation-prov feature,
sq-pfae.6, design §4.2/§4.3). sparq_trust::delegation_prov::audit_invocation(chain, effective, config) renders an invocation-bound delegation chain + its surviving EffectiveCapability as a
minimal W3C PROV-O audit graph: each hop's delegate prov:actedOnBehalfOf delegator (the
on-behalf-of lineage sparq-prov lacks), the invocation as a prov:Activity the terminal delegate
prov:wasAssociatedWith, and the effective grant as a prov:Entity wasGeneratedBy/wasAttributedTo
the delegate carrying the auth:* actions over its target — the §4.3 value-add of rendering the
delegated authority as RDF that reasons with the SAME .acl rules. Human vs AI is an attested
attribute on the delegate (§4.2: an AI agent is a distinct principal holding an attenuated child of
its human's authority) — recorded trust:HumanPrincipal / trust:AiAgent via PrincipalClassification.
Two honesty boundaries: (i) it is an audit record, never an authority source — produced after a
successful invoke, it can only ever describe authority that already passed the gate, and it never
renders a grant broader than the gate conferred (when effective_against_current narrows the chain to
the CURRENT delegator grant, the audit drops the revoked actions); (ii) it adds no security property
and no privacy — principals are named in the clear. Pure oxrdf (no sparq-prov dependency — that
would drag sparq-engine onto the crate's dep graph); OFF in the default build.
Security-properties vocabulary — opt-in secprop-vocab ([OPUS-4.8] sq-5oru9, epic sq-0dksu)
The sparq_trust::secprop module (behind the default-OFF secprop-vocab cargo feature) is the
sparq sec-prop: extension vocabulary: the orthogonal proof-system dimensions (ZK-type,
soundness, completeness, hiding/binding, anonymity, setup, interactivity, selective disclosure,
single-use) plus the machine-reasonable assurance / audit-status axis that the vendored
sec-prop: ontology (the ISWC 2025 ZKP-SPARQL paper's vocabulary,
crates/sparq-trust/ontologies/zkp-sparql/) lacks — under the same
https://w3id.org/zkp-sparql/sec-prop# namespace (extend, do not fork; design
research/security-properties-ontology-design.md §4.1). It is data + Rust constants only (a
const &str registry + the canonical secprop-ext.ttl,
pinned together by a drift test) — it is not an ODRL profile or a per-method annotation graph
(those are the downstream Phase 3/4 beads sq-bevd3/sq-uor3g). The Phase 2 N3 admissibility
reasoner over this vocabulary now ships — see below.
The assurance axis is the honesty mechanism: Proven ⊐ Claimed ⊐ Conjectured, one axis
orthogonal to every property. The default assurance for a sparq-asserted ZK property is
secx:Claimed (decision #1001 Option A) with audit status secx:ExternalSignOffPending — and no
sparq ZK method may be labelled secx:Proven while the external accredited-cryptographer audit
(sq-qhy4) is open. The vocabulary records a claim and its epistemic basis; it is NOT a
proof of any property. DPV alignment is Light (#1002 Option 2): skos:closeMatch cross-refs to
W3C DPV CryptographicMethods where a near-match exists, not a full regulation→requirement chain.
trustx: certification-scope vocabulary — opt-in framework-vocab ([FABLE-5] sq-6syab.2, issue #1592)
The sparq_trust::framework_vocab module (behind the default-OFF framework-vocab cargo feature)
is the trust-expression program's certification-scope layer (design record
research/trust-expression-spec.md §3.4 / D4): the vocabulary a verifier uses to ask "prove X can be
attested by unrevoked attributes issued by parties [X, Y, Z] OR by certified issuers WITHIN the eIDAS /
DIATF framework, that have only issued what they are certified to issue" (#1592). It extends the
trust: vocabulary (shared https://sparq.dev/ns/trust# base — trustx: is a prose sub-prefix, not a
new namespace) and rdfs:seeAlsos the vendored sec-req: eIDAS 2.0 / UK-DVS individuals rather than
duplicating them (extend, do not fork; the design's D5). Like secprop, it is data + Rust constants
only — a const &str registry (ALL_TRUSTX_IRIS) pinned to the canonical
trust-framework.ttl by a byte-drift
test — not an evaluator (the holder-side contract evaluation is the later, sq-3kd2g-gated
sq-6syab.4).
The three surfaces the layer names: (1) the trust requirements (trustx:TrustRequirements + question /
trustsIssuer / trustsFramework / requiresScopeConformance / requiresValidStatusAt) — the
verifier→holder contract carrier; the trust conditions live in the requirements graph, not in the query (no
new query syntax); (2) the two trust modes — enumerated parties (trustsIssuer) OR
framework-certified issuers (trustsFramework), composing by plain OR; (3) the certification-scope
terms (trustx:Certification + certifies / underFramework / scope / validFrom / validUntil),
where scope ranges from service-level (trustx:AnyServiceScope, the honest DIATF granularity — DIATF
certifies a service, not an attribute list) down to a predicate set / SHACL shape reusing the existing
trust:forShape idiom. Non-revocation is a positive, time-windowed trustx:StatusAttestation —
existence of a covering window at requiresValidStatusAt, never evidence-of-absence (OWA/monotonicity).
Honesty (load-bearing): framework-anchored trust is anchored, not proven — a
trustx:Certification bottoms out in the framework operator's signed trusted-list / register artifacts
(a trust anchor, not cryptography); the scope-conformance check constrains what the verifier accepts
and, via the published certification, what the issuer was authorised to issue, but cannot retroactively
prove an issuer never mis-issued elsewhere. No term asserts a settled cryptographic soundness or
privacy guarantee (sq-qhy4 external audit open; sparq-mpc semi-honest only). All trustx: IRIs are
NON-STANDARD placeholders a WG would rehome.
N3 proof-admissibility ruleset — opt-in secprop-admissibility ([OPUS-4.8] sq-ufsi9, Phase 2)
The sparq_trust::admissibility module (behind the default-OFF secprop-admissibility feature,
which enables secprop-vocab) is the §4.3 ODRL → admissible-proof-set reduction run as a
genuinely runnable Notation3 ruleset on sparq-reason's N3 engine
(reason_n3_terms) — not pseudocode. Two parts (design §4.3.2):
- a level-order fact base — each dimension's
⊐ order as secx:strongerThan facts,
transitively closed by one N3 rule, then lifted to the reflexive secx:atLeast partial order
(LEVEL_ORDERS + CLOSURE_RULES);
- a discharge rule — method
M satisfies a gteq constraint C iff M's asserted level for
C's dimension is secx:atLeast C's required level (DISCHARGE_RULE); the leftOperand→dimension
map is one secx:overDimension fact per leftOperand.
use sparq_trust::admissible;
let a = admissible(method_iri, &constraint_iris, policy_n3, method_annotations_n3)?;
assert!(!a.admissible); a.unsatisfied;
The reasoner is monotone (it only derives secx:satisfies); the "satisfies every constraint"
universal (default-deny) is a Rust side-condition over the materialised facts (admissible), not
in-reasoner negation — the same discipline as the admit gate's freshness/holder side-conditions, and
for the same soundness reason. It reasons over annotations, not crypto: requiresAssurance gteq secx:Proven mechanically empties the admissible set for the whole sparq ZK estate today (sq-qhy4
open), and requiresPostQuantumForgery gteq … removes the DL-signature methods — the value is a
principled refusal ("no admissible proof") over silently serving a non-conforming one. The §4.3.3
worked example is the golden test: empty under Alice's strict preference, non-empty under the relaxed one.
Property-admissibility PRE-CHECK in the admission gate — opt-in secprop-precheck ([OPUS-4.8] sq-dt5hv Phase 5 + sq-nrwqs Phase 5.1, design §5b)
sparq_trust::admit_with_precheck(cred, rules, session, target, preference) (behind the default-OFF
secprop-precheck feature, which enables secprop-admissibility and sparq-zk/secprop-annotations)
wires the admissibility reduction above into the REAL admission path as an optional pre-admission check.
The preference: Option<&AdmissibilityPreference> carries the requester's machine-reasonable ODRL privacy
preference — the presented proof's method_iri (the zk:scheme/zk:cryptosuite the registry records)
and a constraints: Vec<AdmissibilityConstraint>, each a structured gteq constraint of two validated
IRIs (left_operand = a secx:requires… operand, right_operand = the required level).
Phase 5.1 (sq-nrwqs) — bundled annotations + structured policy = tamper-resistant. The caller supplies
no raw N3 at all: the method's secprop property annotations are resolved from the bundled, drift-pinned
sparq-zk ontologies/secprop-methods.ttl (the canonical source of truth) keyed on method_iri, and the
policy triples are synthesised internally from the structured constraints (each operand a validated
NamedNode emitted only as an odrl: object). Because there is no caller-supplied raw-N3 channel — neither
for the annotations nor for the policy — a caller cannot inject a secx:hasProperty / secx:atLeast /
secx:satisfies triple (or an N3 rule) to widen a method's recorded posture. (The Phase-5 annotations_n3 /
policy_n3 raw-string fields, which raw-concatenated caller text into the reasoning document, are gone — that
closed a real widening channel, not merely renamed it.) sq-ddbm8 defence in depth: the operand fields are
pub and NamedNode::new_unchecked bypasses NamedNode::new's RFC-3987 validation, so the synthesiser
re-validates every operand as a well-formed IRIREF at the emission site and fails closed
(PrecheckOutcome::MalformedConstraint { operand }) on any operand carrying a term-breaking character
(> / whitespace / < " { } | ^ ` \) that could otherwise break out of its <…> object term — so the
escape channel is structurally absent even off the NamedNode::new path. An unknown method (no bundled block) fails closed
with PrecheckOutcome::UnknownMethod { method } — regardless of the (possibly empty) constraint set. Only
secx:QueryProofLayer assertions are used (the §5a non-transfer rule); the method-wide secx:AssuranceLevel
the requiresAssurance dimension reads is DERIVED as the weakest assurance across the method's positive
query-proof claims (so every sparq method is Claimed while sq-qhy4 is open, and settled-NEGATIVE Proven
levels like PQForgeable never inflate it). This hardens the input trust boundary; it makes no new
soundness/privacy claim.
Before the existing signature / freshness / holder checks, the gate synthesises the policy from the structured
constraints and calls admissible over the method IRI, that policy, and the bundled annotation graph, and
fails closed when the method does not satisfy every constraint — returning an EMPTY admitted set + a
PrecheckOutcome::{Admitted, UnknownMethod { method }, Denied { unsatisfied }, ReductionError { error }, MalformedConstraint { operand }} (an unknown method / an unsatisfied constraint / a reduction error / a
non-IRIREF-safe operand are ALL fail-closed denials — a pre-check that cannot be evaluated never admits;
Denied.unsatisfied lists the failed constraints' left_operand dimensions; MalformedConstraint is the
sq-ddbm8 input-boundary guard above). OPT-IN strict additivity: with preference == None it is byte-identical to
admit (no reasoning runs); the pre-check can only ever DENY, never broaden, and never weakens a
downstream crypto check (admit still verifies the checked issuer signature, scope, freshness, and holder
binding). The golden e2e invariant (tests/secprop_precheck_e2e.rs): a perfectly valid credential is
admitted with None, under a relaxed preference, and under a requiresSoundness gteq secx:KnowledgeSound
preference satisfied purely from the bundled graph — but requiresAssurance gteq secx:Proven (Alice's
strict preference, every sparq ZK method is Claimed-only while sq-qhy4 is open) admits nothing and
derives no grant — the principled refusal, in the data flow, not just the prose. Research-grade, externally
unaudited (sq-qhy4); it reasons over recorded ANNOTATIONS, not cryptography.
Live status / revocation + minimal denial justification — opt-in status-list ([OPUS-4.8] sq-pfae.7, design §6.1 P6)
The sparq_trust::status_list module (behind the default-OFF status-list cargo feature) gates
admission on a live W3C Bitstring Status List instead of the PoC's per-credential revoked: bool
flag. A credential's StatusListEntry ({ status_list_credential, status_list_index, purpose }) is
checked by a LiveStatusCheck::new(resolver, decoder, max_age_secs) — a pluggable
StatusListResolver (the network seam; the crate ships no HTTP client) + a pluggable GzipDecoder
(the compression seam; the multibase base64url/base58btc decode is hand-rolled and dependency-free, the
built-in Flate2GzipDecoder lives behind the nested status-list-flate2 feature that pulls flate2).
LiveStatusCheck::check(&entry, now) returns a LiveStatus, and LiveStatus::admits() is true for
LiveStatus::Live only — fail-closed on Set (revoked), Unknown (unresolvable / undecodable /
out-of-range), and Stale (snapshot older than max_age_secs), exactly as the bead requires. The
bitstring is MSB-first per the spec (StatusBitstring), distinct from the LSB-first
sparq-zk-compose::StatusListSnapshot ZK mirror — this is the clear-index gate, not the hidden-index
ZK proof. sparq_trust::admit_with_status(cred, rules, session, target, &entry, &check) wires the gate
into the REAL admission path: it runs the live-status check FIRST (deny the whole credential on a
non-admitting status) and otherwise delegates to the unchanged admit — strictly additive (it can only
NARROW). justify_status_decision(grant, &entry, status, at_time) renders a minimal PROV-O
justification for the allow OR the deny (a prov:Activity typed trust:StatusCheck, prov:generated
the grant, with trust:statusDecision = the reason token + the checked index/purpose) — the bead's
minimal denial justification. Revocation propagates by full re-materialise (the §4.4 stale window
is bounded by max_age_secs, not closed — no in-reasoner incremental retraction). Pure-Rust base layer
(no new default dep); OFF in the default build. One honesty boundary remains: it adds no privacy (the
index + list are clear; the resolver learns which credential is checked).
Verified status-list issuer signature (VerifyingLiveStatusCheck, same status-list feature,
[OPUS-4.8] sq-pfae.13). The base LiveStatusCheck trusts the list AS FETCHED. VerifyingLiveStatusCheck
closes that gap: it resolves the status-list VC as a signed graph (a SignedStatusList { graph, issuer_signature_hex, salt } over a pluggable VerifiedStatusListResolver), and before trusting any
bit, verifies the list's OWN issuer signature over its RDFC-1.0 commitment — the SAME
commit_triples → commitment_message → verify Schnorr-over-RDFC-1.0 path admit uses — against a
trusted status-authority key. Only on a valid signature does it read the status:encodedList from the
verified graph and run the identical freshness + bit logic. Fail-closed: an unsigned / bad-signature /
wrong-key / unresolvable-issuer list VC, or a verified graph with no encodedList, all yield
LiveStatus::Unknown (deny) — never trusted. The trusted key is caller-supplied, or bound from a
status-authority did:key/did:web issuer DID via VerifyingLiveStatusCheck::with_did_issuer(.., did_resolver, authority_did, ..) (the did feature, same binding the admission gate uses). Research-grade, externally
UNAUDITED (sq-qhy4): a verified issuer signature, NOT a privacy/unlinkability guarantee.
Pattern-scoped masking spike — [FABLE-5] sq-lrtc3.3 (opt-in pattern-scope)
Sub-named-graph (triple-pattern / row-level) result masking: an ODRL-style target as a
set of allow/deny triple patterns over a source graph, so a session can be granted
"the contacts graph except phone numbers". Design record (options analysis, ODRL
vocabulary, production caching path, follow-up beads):
research/odrl-pattern-scoped-targets-2026-07.md. Measured envelope:
bench/pattern-scope/ (work-box JSONs, non-canonical).
Mechanism — masked-subgraph materialization, NOT per-scan filtering. A scoped graph
is decoded, filtered, and rebuilt as a physical replica; the engine evaluates a dataset
in which masked triples are physically absent, so equivalence with an oracle store
(the source with those triples deleted) under SELECT / OPTIONAL / EXISTS / MINUS /
aggregates / COUNT / ASK / GRAPH ?g holds by construction (differential
battery: crates/sparq-solid/tests/pattern_scope.rs). Zero engine/core changes; the
graph-granular enforcement walk is reused unchanged.
ScopePattern::new(s, p, o) / ::any() — per-triple predicate, None = wildcard,
term-identity matching (no join variables, no value equality).
GraphScope::allow_only(patterns) (permission shape) / ::deny_within(patterns)
(prohibition carving a hole); a triple is visible iff it matches ≥1 allow AND 0 deny
(deny overrides allow; empty allow grants nothing — fail-closed).
masked_graph(&Graph, &GraphScope) -> Graph; masked_dataset(&Graph, &decisions)
(explicit-decision assembly: absent from the map = absent from the dataset; a fully
masked graph is omitted — indistinguishable from absent).
store.scoped_dataset(&Session, Mode, &scopes) -> ScopedDataset — refinement-only
(a scope can only shrink the session's graph-level accessible set, never widen), then
ScopedDataset::{query, query_json, ask, view} on the same wrap_read/empty-default
path as query_as. Build once per (session × scopes), query many; rebuild after any
store mutation. READ path only (UPDATE stays graph-granular, record §2.4).
Related skills
http-server — the sparq SPARQL HTTP server has no
per-user authz of its own; front it with sparq-solid (or a gateway) for that.
usage-control-policy — sparq-policy (W3C ODRL
2.2) is the usage-control layer above this access-control layer: 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?".
inference — sparq-reason runs the N3 rules that
materialize the auth view.
(status: RESEARCH / architecture track. The crate is shipped per the design record
(research/solid-access-control-design.md) — the zero-copy DatasetView query path and
the write-gating path are implemented and tested (tests/update.rs + the crate suite) —
but the SCOPE is the graph-level authorization layer ONLY: it does NOT authenticate (the
WebID, client, and acp:issuer are all caller-asserted — the issuer dimension matches a
presented credential issuer, it does NOT add WebID-OIDC authentication) and it is NOT a
Solid Pod HTTP server (no HTTP/LDP/OIDC). Verified against
crates/sparq-solid/src/{lib,authindex,fixture}.rs + README on branch
feat/sq-3jtd-acp-issuer (2026-06-16; the (agent, client, issuer) three-dimension
principal model, sq-3jtd.6); workspace v0.1.0, opt-in (depends on nothing in
the workspace), native-side. Conservative WAC/ACP sub-cases are noted in design §4.4; perf
is measured by cargo run -p sparq-solid --example bench and NOT baked into docs. Code
carries [OPUS-4.8] review markers pending re-review.)