agentsclimarketplace

Access control

Skill sparq-org/sparq/skills/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

Install
npx -y skills add sparq-org/sparq --skill access-control

Assembled 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: 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> {
// 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 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>. ACP uses one three-stratum in-memory reasoning run; MaterializeStats::strata_facts reports its term-level closure size after each stratum, before RDF list expansion and interning. 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.materialize_acp_with_credentials(&AccessProvenance, &VerifiedCredentials) — ([SONNET-4.6] sq-ysv3u, issue #2935) the same, ALSO resolving acp:vc matchers. VerifiedCredentials::hold(agent_webid, requirement_iri) asserts that the TRUSTED caller has already verified a credential proving agent satisfies the requirement; the loader synthesizes <agent> solidx:holdsVc <requirement> from THAT map only, so the same solidx: reserved-predicate guard blocks a forged holding in an .acr. Requirements are matched by exact IRI (structured claim comparison such as age >= 18 is deliberately not interpreted in the rules — the requirement IRI names the whole predicate and the verifier decides). Within one matcher, acp:vc values are disjunctive and combine conjunctively with acp:agent/acp:client/acp:issuer. materialize_acp_with(&prov) is materialize_acp_with_credentials(&prov, &VerifiedCredentials::new()) — no credential ⇒ no acp:vc matcher accepts anybody (fail-closed; and note this corrected an over-grant: before sq-ysv3u acp:vc was an unrecognized attribute, so such a matcher looked agent-unconstrained and granted auth:Public, i.e. everyone including anonymous). Opt-in acp-vc feature — the trust-the-issuer backend that populates the map: VcRequirement::new(requirement, issuer_did, credential_type).with_claim(pred, obj) plus VerifiedCredentials::admit_data_integrity(credential_triples, proof, resolver, reqs), which verifies a W3C Data Integrity eddsa-rdfc-2022 proof via sparq-vc and records the requirements the credential satisfies for its credentialSubject — 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-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).
  • 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 only acl:Append, but an access-control document is governed by acl:Control, so an Append-only principal who POSTs Slug: secret.acl passes 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-only decide cannot see it. decide_create runs the name through is_control_document_name BEFORE the mode question and refuses .acl/.acr for every principal, Control holders included (the legitimate route to author an ACL is a Control-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 contains acl are not. Unusable child names (empty, ./.., any decoded path separator) and a non-slash-terminated container are refused the same way. The refusal is DEFINITIVE (Resolved ⇒ 403), never retryable — it does not depend on the ACL state, so a transient/Unloaded condition can never downgrade it to "try again". When the name is benign the decision is exactly decide(container, mode), fail-closed contract unchanged. mode is what the create verb requires on the CONTAINER (Append for POST, Write for a PUT-create); this crate's rules do not materialize WAC's Write ⇒ Append subsumption, so ask for the mode you mean. is_control_document_name is 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-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; AclStatus carries no authentication state, so the shipped solid-authz shell 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-Authenticate when 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 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). 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 same PodStore once per ring generation (so an .acl write re-materialises by construction) and refusing — never silently un-enforcing — an ODRL-carrying store or a trust block. 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 operates under prohibit (not refused) — sparq's own fail-closed default, a deliberate divergence from the ODRL 2.2 IM default of invalid, 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 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.
  • 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 stratified reason_n3 calls (rules/odrl-{a0,a,b,c,d}.n3), mirroring the WAC/ACP N3-stratification pattern instead of the Rust evaluator. The relationship to materialize_policy is two claims, both checked over a GENERATED corpus in tests/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 ∖ ∪ deny that is exactly "never widens access".
    • Decision-equivalent — within a NARROWED scope: permissions AND prohibitions over odrl:dateTime lteq/gteq and odrl:recipient eq/neq, combined by ≤1 odrl:or/odrl:and logical constraint per rule, each constraint node carrying exactly one operand tuple and at most one combinator, under an admissible odrl:conflict strategy, for a concrete mapped action (the odrl:use umbrella is excluded — the Rust evaluator lets a use permission permit a read request while the strata match action IRIs exactly). Fail-closed & injection-safe: policy_ttl is parsed strictly as Turtle (N3 => rules and every Turtle-extension are a syntax Err) and only the validated ground terms are re-serialized into the reasoner input, so a crafted policy cannot smuggle rules or derive auth:* directly; a triple mentioning a reserved/engine-owned IRI term, a non-canonical xsd:dateTime lexical form (only YYYY-MM-DDTHH:MM:SSZ compares 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), an odrl:conflict strategy the bridge cannot honour (odrl:perm, an unknown strategy IRI, or odrl:invalid with a detected conflict — the same sparq_policy::conflict_admissibility verdict 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 return Err and materialize nothing. Grants/denies land in <urn:sparq:auth> + <urn:sparq:auth-bridged> exactly as the Rust bridge writes them.
  • 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;

// 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: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. [SONNET-4.6] sq-ysv3u: matchers may also constrain on acp:vc — the context agent must hold a verified credential satisfying the named requirement, resolved against the TRUSTED holdings supplied via materialize_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 (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:

  1. the engine — materialize_{wac,acp} + AuthIndex::accessible (the N3-rules paradigm — rules/*.n3 run by sparq-reason);
  2. 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;
  3. 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 offers claim-level trust in place of ACP's type-only acp:vc matcher. (acp:vc itself is now implemented — [SONNET-4.6] sq-ysv3u, above — as exact-IRI requirement matching over the trusted VerifiedCredentials channel; this trust-graph form differs in reasoning over a credential's claims directly.) 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.

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_verdict is a pure function; ldp::handler's authorize_read/authorize_mode call 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 neither sparq-solid nor sparq-trust enters the dependency graph (strict additivity, as for sparq-solid's G6).
  • Allow-only union. Decision::Allow(m) gains modes and never loses any; Decision::Forbidden becomes Allow(granted) — the authenticated-but-unauthorized case a credential-gated resource exists for, and the ONLY route by which a WAC denial becomes an allow. A TrustGrantSet has no public constructor, so that route is unforgeable from outside the module.
  • Bound to the pair it was issued for. A verdict is Clone and a caller may hold it across requests, so union_onto/union_trust_grants take the CURRENT authenticated WebID and requested resource and re-check them against the grant's stored holder()/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/resourceX cannot lift the denial on Jesse/resourceY or on Mallory/resourceX.
  • Unauthenticated is NEVER lifted. Decision::Unauthenticated is returned unchanged even for a Control grant — 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 any session.agent it liked. Authenticate first (skills/solid-lws-server/SKILL.md § Authenticate requests), then the seam can apply.
  • Fail-closed, None never an error. A forged or malformed issuer signature, a credential whose subject is not the authenticated requester, one outside the rule's freshness window or trust:scope, a revoked one, an unparseable .acr rule, or a grant derived for a different requester or resource each admit nothing. A rule deriving an auth: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-xc4y split. The seam is consulted per request, so it uses sparq_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):

  1. 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);
  2. 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;                              // --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), 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.)

Keep looking

Skills are one crate of 627,801. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.