Access control
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).From its SKILL.md
npx -y skills add sparq-org/sparq --skill access-controlAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 13 stars13 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its file declares
Copied from the file, not written here
The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
87.3 KB, as published. Nobody here has run it
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: aSession.agentis 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 toquery_as. Treat aSessionas a trusted claim, not a verified one. - The
Session.issuerfield (ACPacp: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 ACPacp:issuermatcher 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 withagent, 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=acldiscovery 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 andcargo doc -p sparq-solidare 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> {
// A pod is a dataset, one named graph per document (here the bundled fixture).
let graph = Graph::load_dataset(&wac_fixture(), "nquads")?;
let mut store = PodStore::new(graph);
store.materialize_wac()?; // run the N3 rules → install <urn:sparq:auth>
let q = "SELECT ?title WHERE { ?s <https://ex.dev/ns#title> ?title }";
// WebID / client / issuer are all CALLER-ASSERTED — sparq-solid does NOT authenticate them.
// `now` is the request clock (xsd:dateTime), used only by time-windowed conditional grants.
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)?; // alice's authorized graphs
let public_only = store.query_as(&Session::default(), Mode::Read, q)?; // anonymous: public graphs only
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 firstmaterialize_*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>. ACP uses one three-stratum in-memory reasoning run;MaterializeStats::strata_factsreports its term-level closure size after each stratum, before RDF list expansion and interning. Onwasm32-unknown-unknownthe informationalMaterializeStats::millisis reported as0.0(nostd::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 resolvesacp:CreatorAgent/acp:OwnerAgentmatchers 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-internalsolidx:namespace (§2.4), so a writer who embeds<r> solidx:creator <self>in a content document OR in the.acrthey control cannot self-grant (this also blocks forgedsolidx:appliesToResourcepolicy redirection). Each grant is RESOURCE-SCOPED (the creator ofR1is never grantedR2) and composes with the matcher's ownacp:client/acp:issuerconstraints.materialize_acp()ismaterialize_acp_with(&AccessProvenance::new())— no provenance ⇒ noCreatorAgent/OwnerAgentgrant (fail-closed).store.materialize_acp_with_credentials(&AccessProvenance, &VerifiedCredentials)— ([SONNET-4.6] sq-ysv3u, issue #2935) the same, ALSO resolvingacp:vcmatchers.VerifiedCredentials::hold(agent_webid, requirement_iri)asserts that the TRUSTED caller has already verified a credential provingagentsatisfies the requirement; the loader synthesizes<agent> solidx:holdsVc <requirement>from THAT map only, so the samesolidx:reserved-predicate guard blocks a forged holding in an.acr. Requirements are matched by exact IRI (structured claim comparison such asage >= 18is deliberately not interpreted in the rules — the requirement IRI names the whole predicate and the verifier decides). Within one matcher,acp:vcvalues are disjunctive and combine conjunctively withacp:agent/acp:client/acp:issuer.materialize_acp_with(&prov)ismaterialize_acp_with_credentials(&prov, &VerifiedCredentials::new())— no credential ⇒ noacp:vcmatcher accepts anybody (fail-closed; and note this corrected an over-grant: before sq-ysv3uacp:vcwas an unrecognized attribute, so such a matcher looked agent-unconstrained and grantedauth:Public, i.e. everyone including anonymous). Opt-inacp-vcfeature — the trust-the-issuer backend that populates the map:VcRequirement::new(requirement, issuer_did, credential_type).with_claim(pred, obj)plusVerifiedCredentials::admit_data_integrity(credential_triples, proof, resolver, reqs), which verifies a W3C Data Integrityeddsa-rdfc-2022proof viasparq-vcand records the requirements the credential satisfies for itscredentialSubject— refused (VcAdmitError::AmbiguousSubject, nothing recorded) unless the graph names exactly ONE distinct subject, since the proof binds to the RDFC-1.0 CANONICAL form and a two-subject credential would otherwise bind its holding to whichever subject was presented first. Honest scope: authenticity + integrity + non-repudiation ONLY — it reveals the whole credential to the verifier and makes no privacy, unlinkability, zero-knowledge or selective-disclosure claim; revocation and expiry are not checked (a holding lasts until re-materialization). The zero-knowledge backend (prove an attribute without revealing the credential) is NOT wired: it needs the ZK estate, whose external accredited-cryptographer audit is PENDING (sq-qhy4).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-copyDatasetViewfiltered 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 withFROM <http://www.w3.org/ns/solid/sparql#union-default-graph>(the spec-mintedUNION_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 explicitGRAPH ?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
FROMandFROM NAMEDpositions before evaluation and never bindsGRAPH ?g. InFROM NAMEDposition it is treated as absent (stripped, not intersected) soGRAPH ?gkeeps 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-uriprotocol-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
GRAPHwrap; the engine view keeps itsEmptydefault graph — noUnionOfVisibleengine 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-graphfeature (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 useGRAPH ?gor 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 injeswr/solid-sparql-query:test-suite/query-semantics/) and passes all 15 cases.
- the reserved IRI is a signal, never a graph name: it is stripped from BOTH
store.query_as_rewrite(&Session, Mode, sparql)— the v1FROM NAMEDrewrite portability path: enforces the same policy on any standard SPARQL 1.1 engine (one deliberate semantic difference noted in-source: a callerFROM <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/.acrwrites.store.put_acl(acl_iri, content, format) -> AclWriteOutcome/store.delete_acl(acl_iri)(+…_acpvariants) — authoritative ACL write-through ([OPUS-4.8] issue #992 FR-3, sq-snopa.5): the LDPPUT/DELETE /resource.aclSTORAGE primitive. Replaces (or removes) the.acl/.acrGRAPH 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.aclgraphs — no stale grant survives a rule change).contentis 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/.acrtarget IRI is rejected (write content graphs throughupdate_as). It performs NO session authorization — the server authorizes the request itself (e.g.decide(.., Mode::Control)) andupdate_asremains the session-checked write path. Always-present API (no cargo gate; mirrorsupdate_as/decide— adds no dependency). Scale ([OPUS-4.8] issue #1571, sq-b7k7u): an atomic single-.acl/.acrwrite 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_withdiffs old vs newAuthIndexper-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 (WACacl:agentGroupmembership 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 makesdecidecost 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 oneArc<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::SessionCachestripes the memoized per-session graph sets across independentRwLockshards keyed by the session hash, so a warm hit is a shard read lock (+ twoArcclones) 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-requestnowtimestamps 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 anRwLock<PodStore>/arc-swap (readers take the read lock, a write takes the write lock). Snapshot consistency (fail-closed, no torn set): each read pins ONEArc<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 bytests/concurrent_readers.rs(16 threads on oneArc<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 publicWAC-Allowresponse-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). ReturnsWacDecision { allow, granted_modes: Vec<Mode>, governing_acl: Option<NamedNode>, scope: Option<AclScope>, status: AclStatus }. The verdict reuses the SAME fail-closedAuthIndex::accessibleoracle, so adecideallow is never wider thanquery_aswould grant;granted_modescarries the full mode set in one decision (build aWAC-Allowbody without four sweeps).decide_batchbuilds the structural ACL index ONCE for a page of resources. Always-present API (no cargo gate; mirrorswac_allow). API tier-1 (proposed-stable) — the proposed semver-stable per-resource decision surface (freeze pending maintainer ratification, #1346 / #1248; seedocs/api-stability.md).store.decide_create(&Session, container: &str, child_name: &str, Mode) -> WacDecision/is_control_document_name(name: &str) -> bool— the CREATE decision + its control-document guard ([OPUS-5] sq-gg0qq.5, issue #2571). Minting a container child needs onlyacl:Append, but an access-control document is governed byacl:Control, so an Append-only principal who POSTsSlug: secret.aclpasses the container's mode check and still ends up authoring<container>/secret.acl— the document the ACL resolver afterwards consults as<container>/secret's governing ACL. The escalation is carried by the NAME, so a mode-onlydecidecannot see it.decide_createruns the name throughis_control_document_nameBEFORE the mode question and refuses.acl/.acrfor every principal,Controlholders included (the legitimate route to author an ACL is aControl-gated write of the governed resource's own ACL, never a child mint — so the create path stays uniformly closed). The guard is deliberately broader than the exact-case suffix the resolver derives: case variants (secret.ACL), the container-child trailing slash (secret.acl/), the bare.acl/.acr, and percent-encoded spellings (secret%2Eacl,secret.ac%6C, double-encoded, or a smuggled%2F) are all refused; a mid-path.acl(x.acl/child) and a name that merely containsaclare not. Unusable child names (empty,./.., any decoded path separator) and a non-slash-terminatedcontainerare refused the same way. The refusal is DEFINITIVE (Resolved⇒ 403), never retryable — it does not depend on the ACL state, so a transient/Unloadedcondition can never downgrade it to "try again". When the name is benign the decision is exactlydecide(container, mode), fail-closed contract unchanged.modeis what the create verb requires on the CONTAINER (Appendfor POST,Writefor a PUT-create); this crate's rules do not materialize WAC'sWrite⇒Appendsubsumption, so ask for the mode you mean.is_control_document_nameis public so a resource server applies the SAME predicate at its own mint chokepoint and the two cannot drift.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-8288Linkvalue a server emits alongsidewac_allowso 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 thegoverning_acl/scopeFR-1 already puts on the decision (so it can never point at a document discovery did not find). Fail-closed:Nonewhen no governing ACL was discovered (NoAcl/Transient) — nothing to advertise;SomeonUnloaded(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 mapsResolved(authoritative —allowis the real verdict; a deny is 403),NoAcl(no governing ACL anywhere up the chain — definitive 403;AclStatuscarries no authentication state, so the shippedsolid-authzshell emits that 403 for anonymous requesters too. On an LDP resource-serving path that is a known non-conformance, not a permitted stricter choice: Solid requires 401 +WWW-Authenticatewhen a request lacks the credentials a protected resource needs, so a resource server must add that lane from its own authentication state),Unloaded(a governing ACL exists butmaterialize_*was never run — a retryable 503), andTransient(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 == falsefor every non-Resolvedstatus, independent of whether the caller inspectsstatus— 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/.acrdiscovery +acl:defaultinheritance, returningEffectiveAcl { acl: NamedNode, scope: AclScope }—AccessTowhen the resource has its OWN ACL,Defaultwhen it inherits from the nearest ancestor container — instead of N round-trips. The walk is exactly the loader's slash-semanticsparent_irichain, so per-resource discovery and the materialize-time inheritance can never disagree;None⇒ un-protected ⇒ fail closed. Feedsdecide'sgoverning_acl/scope, whichWacDecision::acl_link_header()turns into the FR-5Link: rel="acl"surface (above). Honest scope: Phase-1 implements the WACaccessTo+ container-defaultsubset 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 assparq-server's opt-insolid-authzfeature —POST /authz/decide+/wac-allow+/query, a fail-closed thin wrapper that maps these very verdicts onto HTTP status codes (anAclStatus403/503split). The ODRL lane on/authz/*(sq-lrtc3.1 + sq-3mu76) has LANDED assparq-server's opt-inodrl-authzfeature — dataset-carried ODRL policies are fed throughmaterialize_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-allowrun the same lane (read-scoped advertisement), so they never report an allow the query lane would refuse to honour. The FR-5Link: <acl-iri>; rel="acl"response header (sq-snopa.7) is now wired:/authz/decideand/authz/wac-allowemit it fromacl_link_header()/resolve_acl()when a governing ACL was discovered;None⇒ no header (fail-closed). The STATEFUL lane (sq-snopa.8) has also LANDED:"source":"server"authorises over the server's OWN loaded store instead of a body dataset, materialising this samePodStoreonce per ring generation (so an.aclwrite re-materialises by construction) and refusing — never silently un-enforcing — an ODRL-carrying store or atrustblock. See thehttp-serverskill. That HTTP layer does NOT authenticate — it takes an already-resolved session, exactly as this library does. Thedecidescopeis 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-bridgefeature, OFF by default; [OPUS-4.8] sq-h3uk): run thesparq-policyODRL evaluator and, on a definite Permit, materialize the equivalentprincipal auth:<mode> graphgrant into the auth view, then reindex — so this same enforcement path applies it. Fail-closed (Deny / unmapped action / partyless / targetless → no grant). See theusage-control-policyskill 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 dualprincipal auth:deny<Mode> graphtriple, honoured by this enforcement under deny-overrides (∪ allow ∖ ∪ deny— a deny beats any allow for the same principal+target+mode).…_policydoes both sides at once. Same fail-closed rules; no new enforcement engine.- Loud refusal of an unimplementable
odrl:conflictstrategy ([OPUS-4.8] sq-ihqbl): the bridge implements onlyodrl:conflict odrl:prohibit(deny-overrides). A policy declaringodrl:perm,odrl:invalidwith a detected conflict, or an unknown strategy IRI is REFUSED — everymaterialize_odrl_*entry materializes nothing and returnsBridgeOutcome { refused: true, .. }with aREFUSED (odrl:conflict): …reason, rather than silently coercing it into deny-overrides. An unsetodrl:conflictoperates underprohibit(not refused) — sparq's own fail-closed default, a deliberate divergence from the ODRL 2.2 IM default ofinvalid, not a spec default the bridge is following (the ODRL Formal Semantics CG report supplies no conflict default either — its conflict-resolution machinery is explicitly pending). See theusage-control-policyskill. 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 ACPauth: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); anodrl:dateTimeinclusive bound (lteq→auth:notAfter,gteq→auth:notBefore) maps to a live-clock window re-checked againstSession::nowper request ([OPUS-4.8] sq-0q7n — a lapsed window denies immediately, norefresh_odrl_grantneeded);odrl:purpose/count/a strictdateTimebound, and any compoundodrl: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 bareodrl:assigneerule PROPERTY (with zero constraints) scopes the grant head to that ONE assignee — notauth: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 grantsauth:Public. Mapping table in theusage-control-policyskill.odrl_bridge::materialize_odrl_n3(&mut Graph, policy_ttl: &str, &Request) -> Result<BridgeOutcome, String>— opt-in (odrl-bridge; [SONNET-4.6] sq-zgbso.2, [OPUS-5] sq-zgbso.2): an alternative materialization path that runs the stateless ODRL core as five stratifiedreason_n3calls (rules/odrl-{a0,a,b,c,d}.n3), mirroring the WAC/ACP N3-stratification pattern instead of the Rust evaluator. The relationship tomaterialize_policyis two claims, both checked over a GENERATED corpus intests/odrl_n3_differential.rs(a hand-picked sample previously missed two fail-open defects, so the scope statement below is deliberately explicit):- Never more permissive — everywhere. For every policy and request, either the call returns
Err(refusing the policy outright; the caller materializes nothing and must fail closed), or every allow it emits is also emitted by the Rust path AND every deny the Rust path emits is also emitted by it. Under∪ allow ∖ ∪ denythat is exactly "never widens access". - Decision-equivalent — within a NARROWED scope: permissions AND prohibitions over
odrl:dateTimelteq/gteqandodrl:recipienteq/neq, combined by ≤1odrl:or/odrl:andlogical constraint per rule, each constraint node carrying exactly one operand tuple and at most one combinator, under an admissibleodrl:conflictstrategy, for a concrete mapped action (theodrl:useumbrella is excluded — the Rust evaluator lets ausepermission permit areadrequest while the strata match action IRIs exactly). Fail-closed & injection-safe:policy_ttlis parsed strictly as Turtle (N3=>rules and every Turtle-extension are a syntaxErr) and only the validated ground terms are re-serialized into the reasoner input, so a crafted policy cannot smuggle rules or deriveauth:*directly; a triple mentioning a reserved/engine-owned IRI term, a non-canonicalxsd:dateTimelexical form (onlyYYYY-MM-DDTHH:MM:SSZcompares correctly lexically), an ambiguous constraint node (several operand triples in one position, or several combinators — satisfaction is keyed on the constraint NODE, so such a node would be decided from one cross-product combination: "some operand satisfied" where "every operand satisfied" is required), anodrl:conflictstrategy the bridge cannot honour (odrl:perm, an unknown strategy IRI, orodrl:invalidwith a detected conflict — the samesparq_policy::conflict_admissibilityverdict the Rust path refuses on), an out-of-scope prohibition construct (which the Rust evaluator might satisfy — silently ignoring it would drop a deny and WIDEN access), or an invalid request-field IRI all returnErrand materialize nothing. Grants/denies land in<urn:sparq:auth>+<urn:sparq:auth-bridged>exactly as the Rust bridge writes them.
- Never more permissive — everywhere. For every policy and request, either the call returns
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 asstatic_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 materializedauth: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 OIDCacp:issuer;None= anonymous / any client / any issuer respectively). [OPUS-4.8] sq-3jtd.6: theissuerfield 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:nowis the request clock (anxsd:dateTimelexical string), consulted only by time-windowed conditional grants — a windowed grant withnow == Nonefails closed; set it withSession::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#AnyIssueretc.). [OPUS-4.8] sq-3jtd.6: an ACP grant with noacp:issuermatcher is issuer-unconstrained ⇒ANY_ISSUER(the issuer-dimension top); a session withissuer: Nonematches 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;
// One `Session` per request; `client`/`issuer` come from the auth layer (None = any).
fn session_from_request<'a>(webid: Option<&'a str>, origin: Option<&'a str>) -> Session<'a> {
Session { agent: webid, client: origin, issuer: None, now: None }
}
// Does this session have `mode` on the graph backing `resource`? (fail-closed)
// [FABLE-5] sq-cnuqd: `&PodStore` (shared) — many request handlers call this at once.
fn may(store: &PodStore, s: &Session, mode: Mode, resource: &NamedNode) -> bool {
store.accessible(s, mode).iter().any(|g| g == resource)
}
// Build the WAC-Allow header value for the request, e.g. `user="read write",public="read"`.
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:defaultinheritance, theacl:Read/Write/Append/Controlmodes. - ACP (
.acr) — policies / matchers with theallOf/anyOf/noneOfcombinators and normative deny-overrides. [OPUS-4.8] sq-3jtd.6: matchers may now also constrain onacp:issuer(the caller-asserted OIDC issuer), the third principal dimension. [OPUS-4.8] sq-3jtd.5: matchers may also useacp:agent acp:CreatorAgent/acp:OwnerAgent— the context agent must be the resource's creator / owner, resolved against the TRUSTED per-resource provenance supplied viamaterialize_acp_with(above), resource-scoped and fail-closed. [SONNET-4.6] sq-ysv3u: matchers may also constrain onacp:vc— the context agent must hold a verified credential satisfying the named requirement, resolved against the TRUSTED holdings supplied viamaterialize_acp_with_credentials(above), and fail-closed with none supplied. - 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 (withAnyClientas 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.aclcorpus builder; see its section below. The two share theconformance::{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:memberAccessControlfor 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)— theacp:allOf/acp:anyOf/acp:noneOfcombinators overacp:agent/acp:clientmatchers, withPUBLIC_AGENT/AUTHENTICATED_AGENTconsts for theacp:PublicAgent/acp:AuthenticatedAgentmatcher 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 failingDecisionResults),.name(), and a readable mismatchDisplay.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.aclcorpus as N-Quads.acl.access_to(resource, |a| …)/acl.default_for(resource, |a| …)/acl.access_to_and_default(resource, |a| …)attach anacl:Authorization(acl:accessToon the resource's own ACL /acl:defaultfor 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 thevcard:hasMembergroup document),origin(client)(theacl:origin(user, app) pair).PUBLIC_AGENT(foaf:Agent) /AUTHENTICATED_AGENTconsts name the two recognised classes.WacScenario—new(name),.acl(AclBuilder)/.nquads(&str),.expect(Expect)/.expect_all(iter),.run() -> Result<ScenarioReport, String>(materializes via the freeAuthIndexpath + checks), and.run_via_podstore()(the same check through the fullPodStoremethod form — proves the path a PSS integration uses).run_corpus(&[WacScenario])runs a whole corpus. Read-only accessors.name(),.nquads_str() -> &strand.expects() -> &[Expect]expose the raw corpus + decision table to the differential oracle (below).- The
Expectbuilder,Decision, andScenarioReportare 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/*.n3run bysparq-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 withmaterialize.rs/loader.rs/rules/*.n3, and parses the corpus with its own tiny N-Quad reader (notGraph::load_dataset), so a shared bug cannot hide in both deciders; - the hand
Expecttable — 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:TrustRulenode groupingtrust:source+trust:forShape/forPredicatetrust:scope+trust:freshWithin(+ key). Keeps scope/freshness per-rule.
-
Claim-level relational (
trust:trustsSourceFor, the foundational primitive, design §2.3.1) — atrust:Sourcenode carriestrust:trustsSourceFor <shape-or-predicate>directly, alongside its shared key +trust:scope+trust:freshWithin; EACHtrustsSourceForstatement is one rule. This is the compact per-(source, statement-type) form that offers claim-level trust in place of ACP's type-onlyacp:vcmatcher. (acp:vcitself is now implemented — [SONNET-4.6] sq-ysv3u, above — as exact-IRI requirement matching over the trustedVerifiedCredentialschannel; this trust-graph form differs in reasoning over a credential's claims directly.) The object is ash:NodeShapenode (carried likeforShape) or a predicate IRI (desugared likeforPredicate); a blank-node source is rejected fail-closed. It composes with the opt-indidkey binding (trust:issuerDidon 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 (thesq-xc4ystatic/dynamic split). It runs only the session-INDEPENDENT class (signature, type-scope, scope) and installs each derived grant as anauth:ConditionalGrantwhose holder (auth:agent) and freshness (auth:notAfter) are re-checked per request by the shipped sq-0q7ncond_appliespath — 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 liveSessionand 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.
LWS-server admission seam — opt-in trust-graph on sparq-lws-core ([OPUS-5] sq-hed3q)
The pod-side wiring above lives in sparq-solid's PodStore. The native LWS server
(sparq-lws-core, skills/solid-lws-server/SKILL.md) decides access with the flat-.acl WAC
authorizer, which has no notion of an issuer-signed credential. sparq_lws_core::authz::trust_admit
(behind that crate's own default-OFF trust-graph feature, distinct from the sparq-solid
feature of the same name) is the adapter between the two: it runs the sparq_trust::admit gate and
re-expresses its output in this crate's AccessMode/Decision vocabulary.
sparq-lws-core = { path = "crates/sparq-lws-core", features = ["trust-graph"] }
use sparq_lws_core::authz::trust_admit::{trust_admit_verdict, union_trust_grants};
use sparq_lws_core::authz::trust_admit::{PresentedCredential, TrustRule, TrustSession};
use sparq_trust::policy::{parse_policy, ControlGate};
// Build rules ONLY from the Control-gated authoring channel. `parse_policy` demands a
// `ControlGate`, whose sole constructor `assert_control_gated()` is the caller ASSERTING it has
// verified the policy graph was authored behind `acl:Control` — a loudly-named assertion, not an
// enforced proof. `TrustRule`'s fields are public, so a caller can also build one by hand; doing
// so from anything but the Control-gated `.acr` channel is the one mistake that re-opens the
// trust root of the whole pipeline.
let rules: Vec<TrustRule> = parse_policy(&policy_triples, ControlGate::assert_control_gated())?;
let session = TrustSession { agent: requester_webid, now_unix_secs: now };
let verdict = trust_admit_verdict(&credential, &rules, &session, &target, abac_rule_n3);
// The only way a grant reaches a decision: an ADDITIVE union onto whatever WAC decided, and only
// for the (requester, resource) pair the grant was issued for — pass the CURRENT request's
// authenticated WebID (`None` when anonymous) and requested resource, which are re-checked against
// the grant's stored holder/target.
let decision = union_trust_grants(wac_decision, verdict.as_ref(), Some(&session.agent), &target);
abac_rule_n3 is the controller-authored rule carried in the same Control-gated .acr channel,
e.g. { ?x schema:age ?y . ?y math:greaterThan 18 } => { ?x auth:read <https://pod.ex/resourceX> } .
Load-bearing behaviour, each adversarially tested in
crates/sparq-lws-core/tests/lws_trust_admit.rs:
- NOT handler-wired — enabling the feature changes no request's outcome.
trust_admit_verdictis a pure function;ldp::handler'sauthorize_read/authorize_modecall sites are untouched, so nothing consults it unless you call it. Hot-path wiring is a separate, soundness-sensitive follow-up. With the feature OFF the module is not compiled and neithersparq-solidnorsparq-trustenters the dependency graph (strict additivity, as forsparq-solid's G6). - Allow-only union.
Decision::Allow(m)gains modes and never loses any;Decision::ForbiddenbecomesAllow(granted)— the authenticated-but-unauthorized case a credential-gated resource exists for, and the ONLY route by which a WAC denial becomes an allow. ATrustGrantSethas no public constructor, so that route is unforgeable from outside the module. - Bound to the pair it was issued for. A verdict is
Cloneand a caller may hold it across requests, sounion_onto/union_trust_grantstake the CURRENT authenticated WebID and requested resource and re-check them against the grant's storedholder()/target(); the raw union is private. On a mismatch — another resource, another authenticated agent, or none — the WAC decision is returned unchanged, so a grant minted for Jesse/resourceXcannot lift the denial on Jesse/resourceYor on Mallory/resourceX. - Unauthenticated is NEVER lifted.
Decision::Unauthenticatedis returned unchanged even for aControlgrant — a deliberate fail-closed narrowing: that variant means no verified WebID, so the credential's holder binding has nothing to bind against and a caller could otherwise name anysession.agentit liked. Authenticate first (skills/solid-lws-server/SKILL.md§ Authenticate requests), then the seam can apply. - Fail-closed,
Nonenever an error. A forged or malformed issuer signature, a credential whose subject is not the authenticated requester, one outside the rule's freshness window ortrust:scope, a revoked one, an unparseable.acrrule, or a grant derived for a different requester or resource each admit nothing. A rule deriving anauth:deny*for this requester and resource refuses the whole verdict — an allow-only union cannot express the denial, so honouring only its sibling allows would invert the controller's intent. - The combined (not static) half of the
sq-xc4ysplit. The seam is consulted per request, so it usessparq_trust::admit, which evaluates the session-dependent class (holder binding, freshness) live rather than freezing it into a long-lived<urn:sparq:auth>view.
Honest scope. RESEARCH prototype, the clear path — the credential and the holder binding are
both in the clear. No privacy, unlinkability, anonymity, or ZK-soundness claim; the ZK estate whose
commitment/signature primitives it composes with is externally UNAUDITED (sq-qhy4, external
accredited-cryptographer sign-off pending) and the issuer key is operator-asserted. Do not read an
admitted grant as a security guarantee.
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). Since
#3705 the registry, the Turtle and that one drift
test live in the dependency-free sparq-secprop-vocab leaf crate, and sparq_trust::secprop
re-exports it — so the import path above is unchanged, and sparq_policy::secprop's DIM_* and
sparq_zk::secprop's secx: terms are now aliases of the SAME constants rather than three copies
kept in step by cross-package include_str! reads. The Phase 2 N3 admissibility
reasoner over this vocabulary now ships — see below.
The registry also names the three vendored (not minted) dimension IRIs the estate uses as
secx:property values — SEC_PROP_POST_QUANTUM_FORGERY, SEC_PROP_POST_QUANTUM_SNOOPING,
SEC_PROP_SIGNATURE_TYPE_LEAKAGE (paper §7.7 #3/#4/#5) — which are members of ALL_SECPROP_IRIS
and are re-asserted in secprop-ext.ttl as sec-prop:SecurityProperty subjects with the vendored
IRI, label and type verbatim (issue #3441). That re-assertion adds no type the vendored source
lacks — in particular they are not typed owl:ObjectProperty as the minted secx: dimensions
are — so the extension still mints and refines nothing on his terms (extend, do not fork). Their
purpose is to make the file self-contained for the secx:property range and to let the
dimension-IRI drift guards (sq-mgxz8) check subject presence directly. Since #3705 those guards
are ordinary crate-edge checks against sparq_secprop_vocab::ALL_SECPROP_IRIS — in
sparq_policy::secprop for the ODRL profile's dimensions, and in sparq_zk::secprop for the
annotation graph's — so neither reads another package's files, and the VENDORED_SEC_PROP_DIMS
exemption list #3441 made redundant is retired.
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 assecx:strongerThanfacts, transitively closed by one N3 rule, then lifted to the reflexivesecx:atLeastpartial order (LEVEL_ORDERS+CLOSURE_RULES); - a discharge rule — method
Msatisfies agteqconstraintCiffM's asserted level forC's dimension issecx:atLeastC's required level (DISCHARGE_RULE); the leftOperand→dimension map is onesecx:overDimensionfact per leftOperand.
use sparq_trust::admissible; // --features secprop-admissibility
let a = admissible(method_iri, &constraint_iris, policy_n3, method_annotations_n3)?;
assert!(!a.admissible); a.unsatisfied; // the constraints M fails (honest "why not")
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; see the
StatusDelta paragraph below for the bounded incremental selection step). 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).
Incremental status delta — re-check only the affected grants (StatusDelta, same status-list
feature, [OPUS-5] sq-pfae.14). Retraction is still re-materialisation, but an epoch bump no longer
has to re-run the gate over every grant. StatusDelta::between(list_iri, &prev, &next) (or
between_with_limit(.., max_changed_indices); the default cap is DEFAULT_MAX_CHANGED_INDICES) diffs two
StatusBitstring snapshots of ONE list and names the slots that moved: changed_indices(),
requires_full_recheck(), unbounded_reason() (a DeltaUnbounded), successor_as_of_unix_secs(), and
the two predicates that form the skip contract — skip an entry's re-check only when
delta.valid_at(now, max_age_secs) && !delta.affects(&entry). affects is the per-slot bit selection;
valid_at is the whole-list freshness precondition, and it is load-bearing rather than decorative:
staleness is a property of the snapshot, not of a slot, so a future-dated successor moves an unchanged
slot from Live to Stale without affects ever seeing it. Under that contract a skipped entry
satisfies verdict(prev).admits() ⇒ verdict(next).admits(), i.e. skipping can never leave a grant
admitted that the full re-run would deny (the stronger verdict(prev) == verdict(next) holds when both
snapshots are fresh). Why this does not break stratification: it is a selection computed from two input
snapshots — it derives no verdict, reads no derived fact, and retracts nothing in the reasoner, so the
input-stratified / one-side-bound seeding discipline (sq-tu4e: no NAF over derived facts) is untouched.
Fail-closed: a successor that is not strictly newer (NotNewer), a change in the list's bit coverage
(CoverageChanged — indices moving in/out of range flip to/from Unknown), or more changes than the
limit (LimitExceeded) all yield requires_full_recheck(), i.e. the pre-sq-pfae.14 full re-run, and
affects then reports every entry on the list as affected. Honest residue: the delta does NOT
close the §4.4 stale-authority window — an entry whose bit did not change still ages into
LiveStatus::Stale and affects will say false for it, so valid_at (and the caller's own max_age_secs-driven
re-check) is still required; a delta binds exactly one statusListCredential; and deep-chain delegation
revocation is still not incremental. The skip contract is pinned by an exhaustive differential test over
every single-byte snapshot pair × a now sweep covering both-fresh, prev-aged-out and future-successor
instants (status_list::tests::unaffected_entries_are_safe_to_skip_across_the_bump), plus
valid_at_rejects_a_future_dated_successor_that_affects_cannot_see.
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), thenScopedDataset::{query, query_json, ask, view}on the samewrap_read/empty-default path asquery_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 withsparq-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: wheresparq-solidanswers "may this agent read graph G?",sparq-policyanswers "may this party use this asset for purpose P, with obligation O, until time T?".inference—sparq-reasonruns 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.)