From f283ef6b6d8ac0444f49e386bf63b5482a08153d Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 21:19:14 -0400 Subject: [PATCH 01/11] feat(shacl): novelty-aware RDFS hierarchy for enforcement (schema epoch cache) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SHACL subclass targeting used snapshot.schema_hierarchy() — frozen at the last index build — so a shape targeting Employee silently skipped Manager-typed records when 'Manager rdfs:subClassOf Employee' was committed but not yet indexed. Mechanism (zero cost on the common path): - Novelty::schema_epoch bumps only when a commit asserts or retracts rdfs:subClassOf / rdfs:subPropertyOf (one integer + bounded name compare per flake, inside the existing routing loop). - SchemaHierarchyCache (core) holds the current hierarchy keyed by (indexed schema t, novelty schema epoch); Arc-shared on LedgerState and carried across commits, so non-schema commits keep it warm and rebuilds (two bound-predicate scans + closure) happen only after a schema-touching commit, lazily on the next consumer. - Transaction-time SHACL and the validate facade construct the engine with the cached hierarchy (ShaclEngine::from_dbs_with_hierarchy). - compute_schema_hierarchy_with_overlay moves to fluree-db-core so enforcement layers below the query crate can share it. Per the enforcement rule, the hierarchy is committed-state only: schema asserted in the same transaction as the data does not entail for that transaction (two-transaction workflow; pinned by test). --- fluree-db-api/src/ledger_view.rs | 5 + fluree-db-api/src/shacl_tests.rs | 109 +++++++++++++++ fluree-db-api/src/tx.rs | 15 +- fluree-db-api/src/validate.rs | 11 +- fluree-db-core/src/lib.rs | 4 +- fluree-db-core/src/namespaces.rs | 12 ++ fluree-db-core/src/schema_hierarchy.rs | 183 +++++++++++++++++++++++++ fluree-db-ledger/src/lib.rs | 11 ++ fluree-db-novelty/src/lib.rs | 16 +++ fluree-db-shacl/src/validate.rs | 20 ++- fluree-db-transact/src/commit.rs | 3 + 11 files changed, 382 insertions(+), 7 deletions(-) diff --git a/fluree-db-api/src/ledger_view.rs b/fluree-db-api/src/ledger_view.rs index 5c9f843626..6f393f02e6 100644 --- a/fluree-db-api/src/ledger_view.rs +++ b/fluree-db-api/src/ledger_view.rs @@ -78,6 +78,9 @@ pub struct LedgerView { pub novelty: Arc, /// Dictionary novelty layer (subjects and strings since last index build) pub dict_novelty: Arc, + /// Shared cache of the current RDFS schema hierarchy (see + /// `LedgerState::schema_hierarchy_cache`). + pub schema_hierarchy_cache: Arc, /// Ledger-scoped runtime IDs for predicates and datatypes. pub runtime_small_dicts: Arc, /// Current transaction t value @@ -108,6 +111,7 @@ impl LedgerView { snapshot: Arc::clone(&state.snapshot), novelty: Arc::clone(&state.novelty), dict_novelty: Arc::clone(&state.dict_novelty), + schema_hierarchy_cache: Arc::clone(&state.schema_hierarchy_cache), runtime_small_dicts: Arc::clone(&state.runtime_small_dicts), t: state.t(), head_commit_id: state.head_commit_id.clone(), @@ -164,6 +168,7 @@ impl LedgerView { snapshot: self.snapshot, novelty: self.novelty, dict_novelty, + schema_hierarchy_cache: self.schema_hierarchy_cache, runtime_small_dicts: self.runtime_small_dicts, head_commit_id: self.head_commit_id, head_index_id: self.head_index_id, diff --git a/fluree-db-api/src/shacl_tests.rs b/fluree-db-api/src/shacl_tests.rs index 9f7a676074..afc39c99dc 100644 --- a/fluree-db-api/src/shacl_tests.rs +++ b/fluree-db-api/src/shacl_tests.rs @@ -4337,3 +4337,112 @@ async fn validate_report_literal_target_nodes() { assert!(turtle.contains("sh:focusNode \"Hel\""), "{turtle}"); assert!(turtle.contains("sh:focusNode 123"), "{turtle}"); } + +// ============================================================ +// RDFS entailment for enforcement (schema hierarchy currency) +// ============================================================ + +#[tokio::test] +async fn shacl_subclass_target_sees_unindexed_schema() { + // Manager rdfs:subClassOf Employee committed (novelty only, never + // indexed): a shape targeting Employee must fire for Manager-typed + // records in a LATER transaction. Pre-cache, subclass expansion used the + // index-time hierarchy and Manager records slipped through. + let fluree = FlureeBuilder::memory().build_memory(); + let context = shacl_context(); + let ledger = fluree + .create_ledger("shacl/subclass-currency:main") + .await + .unwrap(); + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:EmployeeShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:Employee"}, + "sh:property": [{ + "@id": "ex:sc-name-ps", + "sh:path": {"@id": "schema:name"}, + "sh:minCount": 1 + }] + }), + ) + .await + .unwrap() + .ledger; + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:Manager", + "rdfs:subClassOf": {"@id": "ex:Employee"} + }), + ) + .await + .unwrap() + .ledger; + + // Manager without a name must now be rejected (Employee shape applies). + let err = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:grace", + "@type": "ex:Manager", + "schema:email": "grace@example.org" + }), + ) + .await + .unwrap_err(); + assert_shacl_violation(err, "at least 1"); +} + +#[tokio::test] +async fn shacl_same_transaction_schema_not_entailed() { + // Rule: enforcement uses the COMMITTED hierarchy — schema asserted in + // the same transaction as the data does not entail for that + // transaction. (Workaround: two transactions, schema first.) + let fluree = FlureeBuilder::memory().build_memory(); + let context = shacl_context(); + let ledger = fluree + .create_ledger("shacl/subclass-same-txn:main") + .await + .unwrap(); + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:EmployeeShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:Employee"}, + "sh:property": [{ + "@id": "ex:st-name-ps", + "sh:path": {"@id": "schema:name"}, + "sh:minCount": 1 + }] + }), + ) + .await + .unwrap() + .ledger; + + // subClassOf and the violating instance in ONE transaction: accepted. + fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@graph": [ + {"@id": "ex:Director", "rdfs:subClassOf": {"@id": "ex:Employee"}}, + {"@id": "ex:dana", "@type": "ex:Director"} + ] + }), + ) + .await + .expect("same-transaction schema must not entail for enforcement"); +} diff --git a/fluree-db-api/src/tx.rs b/fluree-db-api/src/tx.rs index 93947d3e14..a12e0a2985 100644 --- a/fluree-db-api/src/tx.rs +++ b/fluree-db-api/src/tx.rs @@ -575,7 +575,20 @@ pub(crate) async fn apply_shacl_policy_to_staged_view( )); } - let engine = ShaclEngine::from_dbs_with_overlay(&shape_dbs, base.ledger_id()) + // Current (novelty-aware) RDFS hierarchy: subclass targeting must see + // relations committed since the last index build. Cached on the ledger + // state; rebuilt only when a commit touched subClassOf/subPropertyOf. + let hierarchy = base + .schema_hierarchy_cache + .current( + &base.snapshot, + base.novelty.as_ref(), + base.t(), + base.novelty.schema_epoch, + ) + .await + .map_err(fluree_db_transact::TransactError::from)?; + let engine = ShaclEngine::from_dbs_with_hierarchy(&shape_dbs, base.ledger_id(), hierarchy) .await .map_err(fluree_db_transact::TransactError::from)?; let shacl_cache = engine.cache(); diff --git a/fluree-db-api/src/validate.rs b/fluree-db-api/src/validate.rs index 3a9a5df76a..0649d46871 100644 --- a/fluree-db-api/src/validate.rs +++ b/fluree-db-api/src/validate.rs @@ -427,9 +427,14 @@ pub async fn validate_view( let shapes = ShapeCompiler::compile_from_dbs(&shape_dbs) .await .map_err(TransactError::from)?; - // Hierarchy comes from the real snapshot even when shapes compile from a - // detached inline bundle — subclass expansion must reflect the data. - let hierarchy = snapshot.schema_hierarchy(); + // Hierarchy comes from the ledger's shared cache (novelty-aware) even + // when shapes compile from a detached inline bundle — subclass expansion + // must reflect the data, including unindexed schema commits. + let hierarchy = view + .schema_hierarchy_cache + .current(snapshot, novelty, to_t, view.novelty.schema_epoch) + .await + .map_err(TransactError::from)?; let cache = ShaclCache::new( ShaclCacheKey::new(ledger_id, to_t as u64), shapes, diff --git a/fluree-db-core/src/lib.rs b/fluree-db-core/src/lib.rs index d3add48be1..8f413f2187 100644 --- a/fluree-db-core/src/lib.rs +++ b/fluree-db-core/src/lib.rs @@ -158,7 +158,9 @@ pub use range::{ }; pub use range_provider::{RangeProvider, RangeQuery}; pub use runtime_small_dicts::RuntimeSmallDicts; -pub use schema_hierarchy::SchemaHierarchy; +pub use schema_hierarchy::{ + compute_schema_hierarchy_with_overlay, SchemaHierarchy, SchemaHierarchyCache, +}; pub use sid::{Sid, SidInterner}; pub use stats_view::{PropertyStatData, StatsView}; pub use storage::{ diff --git a/fluree-db-core/src/namespaces.rs b/fluree-db-core/src/namespaces.rs index 7746ecd072..72a4b6b019 100644 --- a/fluree-db-core/src/namespaces.rs +++ b/fluree-db-core/src/namespaces.rs @@ -252,6 +252,18 @@ pub fn is_reifies_list_index(sid: &Sid) -> bool { /// table over byte-prefix-disambiguated arms; cost is dominated by the /// string-equality dispatch but stays bounded — the hot path on a /// non-`FLUREE_DB` SID returns after the integer compare alone. +/// Whether a predicate affects the RDFS schema hierarchy +/// (`rdfs:subClassOf` / `rdfs:subPropertyOf`). +/// +/// Used by the commit path to bump the schema epoch that invalidates the +/// shared [`SchemaHierarchy`] cache — a single integer compare on the +/// namespace code before the (bounded) name match. +#[inline] +pub fn is_rdfs_hierarchy_predicate(sid: &Sid) -> bool { + sid.namespace_code == fluree_vocab::namespaces::RDFS + && matches!(sid.name.as_ref(), "subClassOf" | "subPropertyOf") +} + #[inline] pub fn is_reserved_reifies_predicate(sid: &Sid) -> bool { if sid.namespace_code != FLUREE_DB { diff --git a/fluree-db-core/src/schema_hierarchy.rs b/fluree-db-core/src/schema_hierarchy.rs index 0c28869d4c..908bd543a1 100644 --- a/fluree-db-core/src/schema_hierarchy.rs +++ b/fluree-db-core/src/schema_hierarchy.rs @@ -464,3 +464,186 @@ mod tests { assert!(a_transitive.contains(&c)); } } + +/// Compute the current RDFS hierarchy from full ledger state — indexed +/// snapshot plus overlay (novelty) — by scanning `rdfs:subClassOf` and +/// `rdfs:subPropertyOf` and merging with the indexed [`IndexSchema`]. +/// +/// This is the overlay-aware counterpart of +/// [`crate::LedgerSnapshot::schema_hierarchy`] (which reflects only the last +/// index build). Cost is proportional to the number of hierarchy edges (two +/// bound-predicate scans + transitive closure), not to data size. +pub async fn compute_schema_hierarchy_with_overlay( + snapshot: &crate::LedgerSnapshot, + overlay: &dyn crate::OverlayProvider, + to_t: i64, +) -> crate::error::Result> { + use crate::index_schema::SchemaPredicateInfo; + use crate::value::FlakeValue; + use crate::{GraphDbRef, IndexType, RangeMatch, RangeTest}; + use fluree_vocab::namespaces::RDFS; + + let mut subclass_of: HashMap> = HashMap::new(); + let mut subproperty_of: HashMap> = HashMap::new(); + + // Scan the full default-graph state (indexed + unindexed commits + overlay). + let db = GraphDbRef::new(snapshot, 0, overlay, to_t); + + for flake in db + .range( + IndexType::Psot, + RangeTest::Eq, + RangeMatch::predicate(Sid::new(RDFS, "subClassOf")), + ) + .await? + { + if flake.op { + if let FlakeValue::Ref(parent) = flake.o { + subclass_of.entry(flake.s).or_default().push(parent); + } + } + } + + for flake in db + .range( + IndexType::Psot, + RangeTest::Eq, + RangeMatch::predicate(Sid::new(RDFS, "subPropertyOf")), + ) + .await? + { + if flake.op { + if let FlakeValue::Ref(parent) = flake.o { + subproperty_of.entry(flake.s).or_default().push(parent); + } + } + } + + // Merge scanned edges into the indexed schema (if any): memory-backed and + // novelty-only states often carry relationships the indexed root doesn't. + let mut schema: IndexSchema = snapshot.schema.clone().unwrap_or_default(); + schema.t = to_t; + + let mut by_id: HashMap = schema + .pred + .vals + .into_iter() + .map(|spi| (spi.id.clone(), spi)) + .collect(); + + for (id, mut parents) in subclass_of { + parents.sort(); + parents.dedup(); + by_id + .entry(id.clone()) + .and_modify(|spi| { + spi.subclass_of.extend(parents.clone()); + spi.subclass_of.sort(); + spi.subclass_of.dedup(); + }) + .or_insert(SchemaPredicateInfo { + id, + subclass_of: parents, + parent_props: Vec::new(), + child_props: Vec::new(), + }); + } + + for (child, mut parents) in subproperty_of { + parents.sort(); + parents.dedup(); + + by_id + .entry(child.clone()) + .and_modify(|spi| { + spi.parent_props.extend(parents.clone()); + spi.parent_props.sort(); + spi.parent_props.dedup(); + }) + .or_insert(SchemaPredicateInfo { + id: child.clone(), + subclass_of: Vec::new(), + parent_props: parents.clone(), + child_props: Vec::new(), + }); + + for parent in parents { + by_id + .entry(parent.clone()) + .and_modify(|spi| { + spi.child_props.push(child.clone()); + spi.child_props.sort(); + spi.child_props.dedup(); + }) + .or_insert(SchemaPredicateInfo { + id: parent, + subclass_of: Vec::new(), + parent_props: Vec::new(), + child_props: vec![child.clone()], + }); + } + } + + let mut vals: Vec = by_id.into_values().collect(); + vals.sort_by(|a, b| a.id.cmp(&b.id)); + schema.pred.vals = vals; + + if schema.pred.vals.is_empty() { + Ok(None) + } else { + Ok(Some(SchemaHierarchy::from_db_root_schema(&schema))) + } +} + +/// Shared, epoch-invalidated cache of the **current** RDFS hierarchy. +/// +/// Held on the ledger state (`Arc`-shared into read views) so SHACL +/// enforcement, policy targeting, and rdfs query reasoning all pull one +/// up-to-date hierarchy. Commits that assert or retract +/// `rdfs:subClassOf` / `rdfs:subPropertyOf` bump the novelty schema epoch +/// (see `Novelty::schema_epoch`), invalidating the entry; the vastly more +/// common non-schema commits do no work and keep the cache warm. Rebuilds +/// happen lazily on the next consumer. +/// +/// Head-state only: entries are keyed by `(indexed schema t, novelty schema +/// epoch)`. Historical (time-travel) consumers must compute their own +/// hierarchy at their `t` via [`compute_schema_hierarchy_with_overlay`]. +#[derive(Debug, Default)] +pub struct SchemaHierarchyCache { + inner: parking_lot::RwLock>, +} + +#[derive(Debug, Clone)] +struct CacheEntry { + snapshot_schema_t: i64, + novelty_schema_epoch: u64, + hierarchy: Option, +} + +impl SchemaHierarchyCache { + /// The current hierarchy for head state, rebuilding if the schema + /// changed since the cached entry (or nothing is cached yet). + pub async fn current( + &self, + snapshot: &crate::LedgerSnapshot, + overlay: &dyn crate::OverlayProvider, + to_t: i64, + novelty_schema_epoch: u64, + ) -> crate::error::Result> { + let snapshot_schema_t = snapshot.schema.as_ref().map(|s| s.t).unwrap_or(0); + if let Some(entry) = self.inner.read().as_ref() { + if entry.snapshot_schema_t == snapshot_schema_t + && entry.novelty_schema_epoch == novelty_schema_epoch + { + return Ok(entry.hierarchy.clone()); + } + } + let hierarchy = compute_schema_hierarchy_with_overlay(snapshot, overlay, to_t).await?; + *self.inner.write() = Some(CacheEntry { + snapshot_schema_t, + novelty_schema_epoch, + hierarchy: hierarchy.clone(), + }); + Ok(hierarchy) + } +} diff --git a/fluree-db-ledger/src/lib.rs b/fluree-db-ledger/src/lib.rs index dadf946049..174507b5eb 100644 --- a/fluree-db-ledger/src/lib.rs +++ b/fluree-db-ledger/src/lib.rs @@ -94,6 +94,12 @@ pub struct LedgerState { /// Tracks novel dictionary entries introduced since the last index build. /// Populated during commit, read during queries, reset at index application. pub dict_novelty: Arc, + /// Shared cache of the current RDFS schema hierarchy (subclass / + /// subproperty closure over indexed + novelty state). Invalidated by + /// `Novelty::schema_epoch`; consumed by SHACL enforcement, policy + /// targeting, and rdfs query reasoning. `Arc`-shared so clones and read + /// views reuse one entry. + pub schema_hierarchy_cache: Arc, /// Ledger-scoped runtime IDs for predicates and datatypes. /// /// Persisted IDs are seeded when a binary index store is attached; novelty-only @@ -226,6 +232,9 @@ impl LedgerState { snapshot: Arc::new(snapshot), novelty: Arc::new(novelty_overlay), dict_novelty: Arc::new(dict_novelty), + schema_hierarchy_cache: Arc::new( + fluree_db_core::SchemaHierarchyCache::default(), + ), runtime_small_dicts: Arc::new(runtime_small_dicts), head_commit_id: head_id, head_index_id, @@ -243,6 +252,7 @@ impl LedgerState { snapshot: Arc::new(snapshot), novelty: Arc::new(Novelty::new(novelty_t)), dict_novelty: Arc::new(dict_novelty), + schema_hierarchy_cache: Arc::new(fluree_db_core::SchemaHierarchyCache::default()), runtime_small_dicts: Arc::new(RuntimeSmallDicts::new()), head_commit_id, head_index_id, @@ -371,6 +381,7 @@ impl LedgerState { snapshot: Arc::new(snapshot), novelty: Arc::new(novelty), dict_novelty: Arc::new(dict_novelty), + schema_hierarchy_cache: Arc::new(fluree_db_core::SchemaHierarchyCache::default()), runtime_small_dicts: Arc::new(runtime_small_dicts), head_commit_id: None, head_index_id: None, diff --git a/fluree-db-novelty/src/lib.rs b/fluree-db-novelty/src/lib.rs index a705d02f83..109dc4c5fc 100644 --- a/fluree-db-novelty/src/lib.rs +++ b/fluree-db-novelty/src/lib.rs @@ -475,6 +475,11 @@ pub struct Novelty { /// Epoch for cache invalidation - bumped once per commit pub epoch: u64, + /// Epoch for the RDFS schema-hierarchy cache — bumped only when a commit + /// asserts or retracts `rdfs:subClassOf` / `rdfs:subPropertyOf`, so the + /// shared hierarchy cache stays current without any work on the (vastly + /// more common) commits that don't touch the schema. + pub schema_epoch: u64, /// Edge-annotation attachment overlay (M1 — derived from the /// `f:reifies*` system flakes flowing through the same pipeline). @@ -497,6 +502,7 @@ impl Novelty { flake_count: 0, t, epoch: 0, + schema_epoch: 0, attachments: AttachmentNovelty::new(), fact_state: NoveltyFactState::new(), } @@ -814,6 +820,7 @@ impl Novelty { // reserved-predicate test is a single SID compare — negligible even on // ledgers that never use annotations. let mut accepted_reifies: Vec = Vec::new(); + let mut schema_touched = false; for (flake, g_id) in routed { if flake.op && self.fact_state.is_asserted(g_id, &flake) { deduped += 1; @@ -822,8 +829,14 @@ impl Novelty { if fluree_db_core::namespaces::is_reserved_reifies_predicate(&flake.p) { accepted_reifies.push(flake.clone()); } + // Asserting OR retracting a hierarchy edge changes the RDFS + // schema — invalidate the shared hierarchy cache. + schema_touched |= fluree_db_core::namespaces::is_rdfs_hierarchy_predicate(&flake.p); per_graph.entry(g_id).or_default().push(flake); } + if schema_touched { + self.schema_epoch += 1; + } // Record every kept flake (assert + retract) into the current-state // index. `record` resolves lifecycle order-independently (highest-t @@ -910,6 +923,9 @@ impl Novelty { total_flakes += flakes.len(); for flake in flakes { let g_id = Self::resolve_flake_g_id(&flake, reverse_graph)?; + if fluree_db_core::namespaces::is_rdfs_hierarchy_predicate(&flake.p) { + self.schema_epoch += 1; + } per_graph.entry(g_id).or_default().push(flake); } } diff --git a/fluree-db-shacl/src/validate.rs b/fluree-db-shacl/src/validate.rs index 859510e9f8..b7f5323beb 100644 --- a/fluree-db-shacl/src/validate.rs +++ b/fluree-db-shacl/src/validate.rs @@ -159,6 +159,24 @@ impl ShaclEngine { pub async fn from_dbs_with_overlay( dbs: &[GraphDbRef<'_>], ledger_id: impl Into, + ) -> Result { + // Hierarchy is schema-level — pick the first db's snapshot. NOTE: + // this reflects the last index build only; callers with access to + // the ledger's shared hierarchy cache should prefer + // [`Self::from_dbs_with_hierarchy`] so novelty-added subclass / + // subproperty relations are honored. + let hierarchy = dbs.first().and_then(|d| d.snapshot.schema_hierarchy()); + Self::from_dbs_with_hierarchy(dbs, ledger_id, hierarchy).await + } + + /// [`Self::from_dbs_with_overlay`] with an explicit RDFS hierarchy — + /// typically the ledger's current (novelty-aware) hierarchy from + /// `SchemaHierarchyCache`, so subclass targeting and RDFS inference see + /// relations committed since the last index build. + pub async fn from_dbs_with_hierarchy( + dbs: &[GraphDbRef<'_>], + ledger_id: impl Into, + hierarchy: Option, ) -> Result { let shapes = ShapeCompiler::compile_from_dbs(dbs).await?; @@ -168,8 +186,6 @@ impl ShaclEngine { let max_t = dbs.iter().map(|d| d.snapshot.t).max().unwrap_or(0); let key = ShaclCacheKey::new(ledger_id, max_t as u64); - // Hierarchy is schema-level — pick the first db's snapshot. - let hierarchy = dbs.first().and_then(|d| d.snapshot.schema_hierarchy()); let cache = ShaclCache::new(key, shapes, hierarchy.as_ref()); Ok(Self { diff --git a/fluree-db-transact/src/commit.rs b/fluree-db-transact/src/commit.rs index da7b5a8525..a66a4769aa 100644 --- a/fluree-db-transact/src/commit.rs +++ b/fluree-db-transact/src/commit.rs @@ -905,6 +905,9 @@ fn finalize_state_with_base( snapshot, novelty: new_novelty, dict_novelty, + // Carry the hierarchy cache across commits — the schema epoch check + // invalidates it only when a commit touched subClassOf/subPropertyOf. + schema_hierarchy_cache: base.schema_hierarchy_cache, runtime_small_dicts, head_commit_id: Some(commit_cid.clone()), head_index_id: base.head_index_id, From 4186b7f9423e20ba795289777b7e40e39596951b Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 21:28:01 -0400 Subject: [PATCH 02/11] feat(shacl): cross-transaction compiled-shape reuse (shacl epoch cache) Every transaction recompiled all shapes from scratch (~40 bound- predicate scans in ShapeCompiler). Same epoch pattern as the RDFS hierarchy cache: - Novelty::shacl_epoch bumps only when a commit carries a SHACL- affecting flake: any sh:* predicate, or an rdf:type edge to a SHACL type / rdfs:Class / owl:Class (shape registration and implicit class targets both change compile output). One integer compare per flake in the existing routing loop. - ShaclEngine holds Arc; a type-erased slot on LedgerState (carried across commits, mirrored on LedgerView) stores the entry keyed by (indexed snapshot t, shacl epoch, schema epoch, shape- source graph ids). Data-only transactions reuse the previous compile via ShaclEngine::from_shared_cache; any shape/schema change or shapesSource re-point recompiles. Inline opts.shapes and cross- ledger shape wires bypass the cache (per-transaction by nature). The ledger crate stays SHACL-agnostic (Any-erased slot, downcast in the API enforcement layer, following the TypeErasedStore precedent). --- fluree-db-api/Cargo.toml | 1 + fluree-db-api/src/ledger_view.rs | 5 ++ fluree-db-api/src/shacl_tests.rs | 86 ++++++++++++++++++++++++++++++++ fluree-db-api/src/tx.rs | 66 ++++++++++++++++++++++-- fluree-db-core/src/namespaces.rs | 24 +++++++++ fluree-db-ledger/Cargo.toml | 1 + fluree-db-ledger/src/lib.rs | 8 +++ fluree-db-novelty/src/lib.rs | 18 ++++++- fluree-db-shacl/src/validate.rs | 32 ++++++++++-- fluree-db-transact/src/commit.rs | 1 + 10 files changed, 233 insertions(+), 9 deletions(-) diff --git a/fluree-db-api/Cargo.toml b/fluree-db-api/Cargo.toml index 234e751c68..8eb964c09d 100644 --- a/fluree-db-api/Cargo.toml +++ b/fluree-db-api/Cargo.toml @@ -42,6 +42,7 @@ full = ["native", "credential", "iceberg", "shacl", "ipfs"] [dependencies] fluree-db-core = { path = "../fluree-db-core" } +parking_lot = "0.12" fluree-db-crypto = { path = "../fluree-db-crypto", features = ["nameservice"] } fluree-db-connection = { path = "../fluree-db-connection" } fluree-db-policy = { path = "../fluree-db-policy" } diff --git a/fluree-db-api/src/ledger_view.rs b/fluree-db-api/src/ledger_view.rs index 6f393f02e6..e0961142da 100644 --- a/fluree-db-api/src/ledger_view.rs +++ b/fluree-db-api/src/ledger_view.rs @@ -81,6 +81,9 @@ pub struct LedgerView { /// Shared cache of the current RDFS schema hierarchy (see /// `LedgerState::schema_hierarchy_cache`). pub schema_hierarchy_cache: Arc, + /// Cross-transaction compiled-SHACL cache slot (see + /// `LedgerState::shacl_compile_cache`). + pub shacl_compile_cache: Arc>>>, /// Ledger-scoped runtime IDs for predicates and datatypes. pub runtime_small_dicts: Arc, /// Current transaction t value @@ -112,6 +115,7 @@ impl LedgerView { novelty: Arc::clone(&state.novelty), dict_novelty: Arc::clone(&state.dict_novelty), schema_hierarchy_cache: Arc::clone(&state.schema_hierarchy_cache), + shacl_compile_cache: Arc::clone(&state.shacl_compile_cache), runtime_small_dicts: Arc::clone(&state.runtime_small_dicts), t: state.t(), head_commit_id: state.head_commit_id.clone(), @@ -169,6 +173,7 @@ impl LedgerView { novelty: self.novelty, dict_novelty, schema_hierarchy_cache: self.schema_hierarchy_cache, + shacl_compile_cache: self.shacl_compile_cache, runtime_small_dicts: self.runtime_small_dicts, head_commit_id: self.head_commit_id, head_index_id: self.head_index_id, diff --git a/fluree-db-api/src/shacl_tests.rs b/fluree-db-api/src/shacl_tests.rs index afc39c99dc..57a4af985d 100644 --- a/fluree-db-api/src/shacl_tests.rs +++ b/fluree-db-api/src/shacl_tests.rs @@ -4446,3 +4446,89 @@ async fn shacl_same_transaction_schema_not_entailed() { .await .expect("same-transaction schema must not entail for enforcement"); } + +#[tokio::test] +async fn shacl_compile_cache_invalidates_on_new_shapes() { + // Data-only transactions reuse the compiled shapes; committing new + // shapes must invalidate the reuse so the next transaction enforces + // them. Exercises the shacl_epoch invalidation path end to end. + let fluree = FlureeBuilder::memory().build_memory(); + let context = shacl_context(); + let ledger = fluree + .create_ledger("shacl/compile-cache:main") + .await + .unwrap(); + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:NameShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:User"}, + "sh:property": [{ + "@id": "ex:cc-name-ps", + "sh:path": {"@id": "schema:name"}, + "sh:minCount": 1 + }] + }), + ) + .await + .unwrap() + .ledger; + + // Two data-only transactions (compile reused on the second). + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:u1", "@type": "ex:User", "schema:name": "One" + }), + ) + .await + .unwrap() + .ledger; + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:u2", "@type": "ex:User", "schema:name": "Two" + }), + ) + .await + .unwrap() + .ledger; + + // New shape commits -> shacl_epoch bumps -> next txn must enforce it. + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:EmailShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:User"}, + "sh:property": [{ + "@id": "ex:cc-email-ps", + "sh:path": {"@id": "schema:email"}, + "sh:minCount": 1 + }] + }), + ) + .await + .unwrap() + .ledger; + let err = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:u3", "@type": "ex:User", "schema:name": "Three" + }), + ) + .await + .unwrap_err(); + assert_shacl_violation(err, "at least 1"); +} diff --git a/fluree-db-api/src/tx.rs b/fluree-db-api/src/tx.rs index a12e0a2985..0b353d5a96 100644 --- a/fluree-db-api/src/tx.rs +++ b/fluree-db-api/src/tx.rs @@ -366,6 +366,20 @@ async fn resolve_cross_ledger_shapes_for_tx( /// same mechanism — schema, policy, and SHACL shapes can live in any graph /// the ledger knows about, including the config graph itself. #[cfg(feature = "shacl")] +/// Cross-transaction compiled-SHACL cache entry, stored type-erased on +/// `LedgerState::shacl_compile_cache`. Valid while nothing shape-affecting +/// changed: same indexed snapshot, same SHACL epoch (no sh:* / shape-typing +/// flakes committed — see `Novelty::shacl_epoch`), same schema epoch (the +/// compiled target index bakes in subclass expansion), and the same shape +/// source graphs. Inline / cross-ledger shapes bypass the cache (per-txn). +struct CachedShaclCompile { + snapshot_t: i64, + shacl_epoch: u64, + schema_epoch: u64, + shapes_g_ids: Vec, + cache: std::sync::Arc, +} + pub(crate) fn resolve_shapes_source_g_ids( config: Option<&LedgerConfig>, snapshot: &fluree_db_core::LedgerSnapshot, @@ -524,6 +538,9 @@ pub(crate) async fn apply_shacl_policy_to_staged_view( // every data graph. Cross-ledger value-sets aren't supported yet, so that // branch falls back to the default graph. let membership_g_ids: Vec; + // Set on the plain same-ledger path — the only shape source eligible for + // cross-transaction compile reuse. + let mut cacheable_shape_g_ids: Option> = None; let mut shape_dbs: Vec> = if let (Some(wire), Some(staged_ns)) = (ctx.cross_ledger_shapes, ctx.staged_ns) { let bundle = wire @@ -549,6 +566,7 @@ pub(crate) async fn apply_shacl_policy_to_staged_view( // concrete graph IDs; default to `[0]` when unset. let shapes_g_ids = resolve_shapes_source_g_ids(config.as_deref(), &base.snapshot)?; membership_g_ids = shapes_g_ids.clone(); + cacheable_shape_g_ids = Some(shapes_g_ids.clone()); shapes_g_ids .iter() .map(|g_id| base.as_graph_db_ref(*g_id)) @@ -563,6 +581,7 @@ pub(crate) async fn apply_shacl_policy_to_staged_view( // staged namespace registry at `stage_with_config_shacl` // entry, so encoding is consistent with the live tx. if let Some(bundle) = ctx.inline_shape_bundle.clone() { + cacheable_shape_g_ids = None; inline_overlay_holder = Some(fluree_db_query::schema_bundle::SchemaBundleOverlay::new( base.novelty.as_ref(), bundle, @@ -588,9 +607,50 @@ pub(crate) async fn apply_shacl_policy_to_staged_view( ) .await .map_err(fluree_db_transact::TransactError::from)?; - let engine = ShaclEngine::from_dbs_with_hierarchy(&shape_dbs, base.ledger_id(), hierarchy) - .await - .map_err(fluree_db_transact::TransactError::from)?; + // Cross-transaction compile reuse: skip the ~40 predicate scans of + // ShapeCompiler when nothing shape-affecting changed since the last + // compile of the same shape-source graphs. + let reuse_key = cacheable_shape_g_ids.as_ref().map(|g_ids| { + ( + base.snapshot.t, + base.novelty.shacl_epoch, + base.novelty.schema_epoch, + g_ids.clone(), + ) + }); + let shared_compile: Option> = + reuse_key.as_ref().and_then(|key| { + let slot = base.shacl_compile_cache.read(); + let entry = slot + .as_ref()? + .clone() + .downcast::() + .ok()?; + (entry.snapshot_t == key.0 + && entry.shacl_epoch == key.1 + && entry.schema_epoch == key.2 + && entry.shapes_g_ids == key.3) + .then(|| std::sync::Arc::clone(&entry.cache)) + }); + let engine = match shared_compile { + Some(cache) => ShaclEngine::from_shared_cache(cache, hierarchy), + None => { + let engine = + ShaclEngine::from_dbs_with_hierarchy(&shape_dbs, base.ledger_id(), hierarchy) + .await + .map_err(fluree_db_transact::TransactError::from)?; + if let Some((snapshot_t, shacl_epoch, schema_epoch, shapes_g_ids)) = reuse_key { + *base.shacl_compile_cache.write() = Some(std::sync::Arc::new(CachedShaclCompile { + snapshot_t, + shacl_epoch, + schema_epoch, + shapes_g_ids, + cache: engine.shared_cache(), + })); + } + engine + } + }; let shacl_cache = engine.cache(); // No config + no shapes → skip (backward compat: shapes-exist heuristic). diff --git a/fluree-db-core/src/namespaces.rs b/fluree-db-core/src/namespaces.rs index 72a4b6b019..b9866b63fa 100644 --- a/fluree-db-core/src/namespaces.rs +++ b/fluree-db-core/src/namespaces.rs @@ -258,6 +258,30 @@ pub fn is_reifies_list_index(sid: &Sid) -> bool { /// Used by the commit path to bump the schema epoch that invalidates the /// shared [`SchemaHierarchy`] cache — a single integer compare on the /// namespace code before the (bounded) name match. +/// Whether a flake can affect compiled SHACL shapes: any `sh:*` predicate +/// (constraints, targets, paths, meta-encoded `sh:in`/list values), or an +/// `rdf:type` edge whose object is in the SHACL namespace (shape typing) or +/// is `rdfs:Class` / `owl:Class` (implicit class targets). +/// +/// Used by the commit path to bump the SHACL epoch that lets transaction +/// enforcement reuse the previously compiled shapes — cost is one integer +/// compare per flake on the common path. +#[inline] +pub fn is_shacl_affecting_flake(flake: &crate::Flake) -> bool { + use fluree_vocab::namespaces::{OWL, RDF, RDFS, SHACL}; + if flake.p.namespace_code == SHACL { + return true; + } + if flake.p.namespace_code == RDF && &*flake.p.name == "type" { + if let crate::value::FlakeValue::Ref(o) = &flake.o { + return o.namespace_code == SHACL + || (o.namespace_code == RDFS && &*o.name == "Class") + || (o.namespace_code == OWL && &*o.name == "Class"); + } + } + false +} + #[inline] pub fn is_rdfs_hierarchy_predicate(sid: &Sid) -> bool { sid.namespace_code == fluree_vocab::namespaces::RDFS diff --git a/fluree-db-ledger/Cargo.toml b/fluree-db-ledger/Cargo.toml index de5074aae0..f0647509e2 100644 --- a/fluree-db-ledger/Cargo.toml +++ b/fluree-db-ledger/Cargo.toml @@ -8,6 +8,7 @@ description = "Ledger state combining indexed Db with novelty overlay" [dependencies] fluree-db-core = { path = "../fluree-db-core" } +parking_lot = "0.12" fluree-db-novelty = { path = "../fluree-db-novelty" } fluree-db-nameservice = { path = "../fluree-db-nameservice" } fluree-vocab = { path = "../fluree-vocab" } diff --git a/fluree-db-ledger/src/lib.rs b/fluree-db-ledger/src/lib.rs index 174507b5eb..5c759d1cce 100644 --- a/fluree-db-ledger/src/lib.rs +++ b/fluree-db-ledger/src/lib.rs @@ -100,6 +100,11 @@ pub struct LedgerState { /// targeting, and rdfs query reasoning. `Arc`-shared so clones and read /// views reuse one entry. pub schema_hierarchy_cache: Arc, + /// Cross-transaction compiled-SHACL cache slot. Type-erased (the ledger + /// crate stays SHACL-agnostic); owned and downcast by the API layer's + /// enforcement path, invalidated via `Novelty::shacl_epoch`. `Arc`-shared + /// and carried across commits like the hierarchy cache. + pub shacl_compile_cache: Arc>>>, /// Ledger-scoped runtime IDs for predicates and datatypes. /// /// Persisted IDs are seeded when a binary index store is attached; novelty-only @@ -235,6 +240,7 @@ impl LedgerState { schema_hierarchy_cache: Arc::new( fluree_db_core::SchemaHierarchyCache::default(), ), + shacl_compile_cache: Arc::new(parking_lot::RwLock::new(None)), runtime_small_dicts: Arc::new(runtime_small_dicts), head_commit_id: head_id, head_index_id, @@ -253,6 +259,7 @@ impl LedgerState { novelty: Arc::new(Novelty::new(novelty_t)), dict_novelty: Arc::new(dict_novelty), schema_hierarchy_cache: Arc::new(fluree_db_core::SchemaHierarchyCache::default()), + shacl_compile_cache: Arc::new(parking_lot::RwLock::new(None)), runtime_small_dicts: Arc::new(RuntimeSmallDicts::new()), head_commit_id, head_index_id, @@ -382,6 +389,7 @@ impl LedgerState { novelty: Arc::new(novelty), dict_novelty: Arc::new(dict_novelty), schema_hierarchy_cache: Arc::new(fluree_db_core::SchemaHierarchyCache::default()), + shacl_compile_cache: Arc::new(parking_lot::RwLock::new(None)), runtime_small_dicts: Arc::new(runtime_small_dicts), head_commit_id: None, head_index_id: None, diff --git a/fluree-db-novelty/src/lib.rs b/fluree-db-novelty/src/lib.rs index 109dc4c5fc..e7503593c8 100644 --- a/fluree-db-novelty/src/lib.rs +++ b/fluree-db-novelty/src/lib.rs @@ -480,6 +480,12 @@ pub struct Novelty { /// shared hierarchy cache stays current without any work on the (vastly /// more common) commits that don't touch the schema. pub schema_epoch: u64, + /// Epoch for the compiled-SHACL cache — bumped only when a commit + /// asserts or retracts SHACL vocabulary (any `sh:*` predicate, or an + /// `rdf:type` edge to a SHACL / class type). Lets transaction + /// enforcement reuse the previously compiled shapes when nothing + /// shape-affecting changed. + pub shacl_epoch: u64, /// Edge-annotation attachment overlay (M1 — derived from the /// `f:reifies*` system flakes flowing through the same pipeline). @@ -503,6 +509,7 @@ impl Novelty { t, epoch: 0, schema_epoch: 0, + shacl_epoch: 0, attachments: AttachmentNovelty::new(), fact_state: NoveltyFactState::new(), } @@ -821,6 +828,7 @@ impl Novelty { // ledgers that never use annotations. let mut accepted_reifies: Vec = Vec::new(); let mut schema_touched = false; + let mut shacl_touched = false; for (flake, g_id) in routed { if flake.op && self.fact_state.is_asserted(g_id, &flake) { deduped += 1; @@ -830,13 +838,18 @@ impl Novelty { accepted_reifies.push(flake.clone()); } // Asserting OR retracting a hierarchy edge changes the RDFS - // schema — invalidate the shared hierarchy cache. + // schema — invalidate the shared hierarchy cache. Likewise any + // SHACL-vocabulary flake invalidates the compiled-shapes cache. schema_touched |= fluree_db_core::namespaces::is_rdfs_hierarchy_predicate(&flake.p); + shacl_touched |= fluree_db_core::namespaces::is_shacl_affecting_flake(&flake); per_graph.entry(g_id).or_default().push(flake); } if schema_touched { self.schema_epoch += 1; } + if shacl_touched { + self.shacl_epoch += 1; + } // Record every kept flake (assert + retract) into the current-state // index. `record` resolves lifecycle order-independently (highest-t @@ -926,6 +939,9 @@ impl Novelty { if fluree_db_core::namespaces::is_rdfs_hierarchy_predicate(&flake.p) { self.schema_epoch += 1; } + if fluree_db_core::namespaces::is_shacl_affecting_flake(&flake) { + self.shacl_epoch += 1; + } per_graph.entry(g_id).or_default().push(flake); } } diff --git a/fluree-db-shacl/src/validate.rs b/fluree-db-shacl/src/validate.rs index b7f5323beb..16392cb8f5 100644 --- a/fluree-db-shacl/src/validate.rs +++ b/fluree-db-shacl/src/validate.rs @@ -24,6 +24,7 @@ use fluree_vocab::rdf_names; use fluree_vocab::shacl as sh_vocab; use parking_lot::Mutex; use std::collections::{HashMap, HashSet}; +use std::sync::Arc; /// Per-transaction memo for `sh:class` value-membership verdicts. /// @@ -94,8 +95,10 @@ struct ClassMembershipCtx<'a> { /// - A shape targeting `Animal` will also apply to instances of `Dog` /// (if `Dog rdfs:subClassOf Animal`) pub struct ShaclEngine { - /// Cached compiled shapes - cache: ShaclCache, + /// Cached compiled shapes. `Arc`-shared so a transaction can reuse the + /// previous transaction's compile when no SHACL-affecting flake landed + /// in between (see `Novelty::shacl_epoch`). + cache: Arc, /// Schema hierarchy for RDFS reasoning (optional) hierarchy: Option, /// Extra graphs consulted when resolving `sh:class` value membership — @@ -113,7 +116,7 @@ impl ShaclEngine { /// For full RDFS reasoning support, use `new_with_hierarchy` instead. pub fn new(cache: ShaclCache) -> Self { Self { - cache, + cache: Arc::new(cache), hierarchy: None, membership_g_ids: Vec::new(), class_cache: Mutex::new(HashMap::new()), @@ -126,13 +129,32 @@ impl ShaclEngine { /// shapes targeting a class will also apply to instances of subclasses. pub fn new_with_hierarchy(cache: ShaclCache, hierarchy: SchemaHierarchy) -> Self { Self { - cache, + cache: Arc::new(cache), hierarchy: Some(hierarchy), membership_g_ids: Vec::new(), class_cache: Mutex::new(HashMap::new()), } } + /// Create an engine around an already-compiled, shared shape cache — + /// the cross-transaction reuse path: when no SHACL-affecting flake + /// landed since the previous compile, enforcement skips the ~40 + /// predicate scans of `ShapeCompiler` entirely. + pub fn from_shared_cache(cache: Arc, hierarchy: Option) -> Self { + Self { + cache, + hierarchy, + membership_g_ids: Vec::new(), + class_cache: Mutex::new(HashMap::new()), + } + } + + /// The shared handle to the compiled shape cache (for cross-transaction + /// reuse bookkeeping). + pub fn shared_cache(&self) -> Arc { + Arc::clone(&self.cache) + } + /// Build an engine by compiling shapes from a single-graph database with /// optional overlay (convenience over [`Self::from_dbs_with_overlay`]). /// @@ -189,7 +211,7 @@ impl ShaclEngine { let cache = ShaclCache::new(key, shapes, hierarchy.as_ref()); Ok(Self { - cache, + cache: Arc::new(cache), hierarchy, membership_g_ids: Vec::new(), class_cache: Mutex::new(HashMap::new()), diff --git a/fluree-db-transact/src/commit.rs b/fluree-db-transact/src/commit.rs index a66a4769aa..54e82f378f 100644 --- a/fluree-db-transact/src/commit.rs +++ b/fluree-db-transact/src/commit.rs @@ -908,6 +908,7 @@ fn finalize_state_with_base( // Carry the hierarchy cache across commits — the schema epoch check // invalidates it only when a commit touched subClassOf/subPropertyOf. schema_hierarchy_cache: base.schema_hierarchy_cache, + shacl_compile_cache: base.shacl_compile_cache, runtime_small_dicts, head_commit_id: Some(commit_cid.clone()), head_index_id: base.head_index_id, From bf2e51135765e4aa1b35c185dbcfd75a9b672f74 Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 21:35:34 -0400 Subject: [PATCH 03/11] feat(policy): RDFS entailment for policy enforcement (always on) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Class policies (f:onClass) now govern instances of subclasses, and property policies (f:onProperty) govern subproperties. Expansion happens once at policy-set build time — for_classes gains the subclass closure and property targets gain the subproperty closure — so the class→property union, class_check_needed computation, and all four evaluation-time class checks see the expanded sets with no per-flake cost. The hierarchy is the current (novelty-aware) one, computed in build_policy_context alongside the existing stats assembly; the cross-ledger policy wire path keeps its pre-entailment behavior (no hierarchy handle there yet). --- fluree-db-api/src/policy_builder.rs | 22 +++- fluree-db-api/tests/it_policy_allow.rs | 137 +++++++++++++++++++++++++ fluree-db-policy/src/index.rs | 37 +++++-- fluree-db-policy/src/wire.rs | 7 +- 4 files changed, 193 insertions(+), 10 deletions(-) diff --git a/fluree-db-api/src/policy_builder.rs b/fluree-db-api/src/policy_builder.rs index 4688fe06ae..e470ef0b91 100644 --- a/fluree-db-api/src/policy_builder.rs +++ b/fluree-db-api/src/policy_builder.rs @@ -334,8 +334,26 @@ async fn build_policy_context_from_opts_inner( snapshot.stats.clone() }; - let view_set = build_policy_set(restrictions.clone(), stats.as_ref(), PolicyAction::View); - let modify_set = build_policy_set(restrictions, stats.as_ref(), PolicyAction::Modify); + // Current RDFS hierarchy (always-on entailment for enforcement): class + // policies govern subclass instances, property policies govern + // subproperties. Two bound-predicate scans + closure — small next to the + // stats assembly and policy parsing above. + let hierarchy = fluree_db_core::compute_schema_hierarchy_with_overlay(snapshot, overlay, to_t) + .await + .map_err(|e| ApiError::internal(format!("policy hierarchy computation failed: {e}")))?; + + let view_set = build_policy_set( + restrictions.clone(), + stats.as_ref(), + PolicyAction::View, + hierarchy.as_ref(), + ); + let modify_set = build_policy_set( + restrictions, + stats.as_ref(), + PolicyAction::Modify, + hierarchy.as_ref(), + ); // Check if this is a root policy (unrestricted access). // diff --git a/fluree-db-api/tests/it_policy_allow.rs b/fluree-db-api/tests/it_policy_allow.rs index a87dc338b2..3686977406 100644 --- a/fluree-db-api/tests/it_policy_allow.rs +++ b/fluree-db-api/tests/it_policy_allow.rs @@ -831,3 +831,140 @@ async fn policy_onclass_applies_to_novelty_properties_without_type_restated() { }) .await; } + +/// RDFS entailment for policy enforcement (always on): a class policy +/// governs instances of subclasses. +#[tokio::test] +async fn policy_onclass_governs_subclass_instances() { + assert_index_defaults(); + let fluree = FlureeBuilder::memory().build_memory(); + let ledger_id = "policy/onclass-subclass:main"; + let ledger0 = genesis_ledger(&fluree, ledger_id); + + // Schema first (separate transaction), then data. + let ledger1 = fluree + .insert( + ledger0, + &json!({ + "@context": {"ex": "http://example.org/ns/", "rdfs": "http://www.w3.org/2000/01/rdf-schema#"}, + "@id": "ex:Manager", + "rdfs:subClassOf": {"@id": "ex:Employee"} + }), + ) + .await + .expect("schema txn"); + let _ = fluree + .insert( + ledger1.ledger, + &json!({ + "@context": {"ex": "http://example.org/ns/", "schema": "http://schema.org/"}, + "@graph": [ + {"@id": "ex:pat", "@type": "ex:Manager", "schema:name": "Pat"}, + {"@id": "ex:sam", "@type": "ex:Contractor", "schema:name": "Sam"} + ] + }), + ) + .await + .expect("data txn"); + + // Policy allows viewing Employees only; Manager ⊑ Employee must qualify. + let policy = json!([ + { + "@id": "ex:employeePolicy", + "@type": "f:AccessPolicy", + "f:action": "f:view", + "f:onClass": [{"@id": "http://example.org/ns/Employee"}], + "f:allow": true + } + ]); + let query = json!({ + "@context": {"ex": "http://example.org/ns/", "schema": "http://schema.org/"}, + "from": ledger_id, + "opts": {"policy": policy, "default-allow": false}, + "select": "?name", + "where": {"@id": "?s", "schema:name": "?name"} + }); + + let result = fluree + .query_connection(&query) + .await + .expect("query_connection"); + let ledger = fluree.ledger(ledger_id).await.expect("ledger"); + let jsonld = result.to_jsonld(&ledger.snapshot).expect("to_jsonld"); + + assert_eq!( + normalize_rows(&jsonld), + normalize_rows(&json!(["Pat"])), + "class policy on Employee must govern Manager instances (and not Contractor)" + ); +} + +/// RDFS entailment for policy enforcement (always on): a property policy +/// governs subproperties. +#[tokio::test] +async fn policy_onproperty_governs_subproperties() { + assert_index_defaults(); + let fluree = FlureeBuilder::memory().build_memory(); + let ledger_id = "policy/onproperty-subprop:main"; + let ledger0 = genesis_ledger(&fluree, ledger_id); + + let ledger1 = fluree + .insert( + ledger0, + &json!({ + "@context": {"ex": "http://example.org/ns/", "rdfs": "http://www.w3.org/2000/01/rdf-schema#"}, + "@id": "ex:homePhone", + "rdfs:subPropertyOf": {"@id": "ex:phone"} + }), + ) + .await + .expect("schema txn"); + let _ = fluree + .insert( + ledger1.ledger, + &json!({ + "@context": {"ex": "http://example.org/ns/", "schema": "http://schema.org/"}, + "@id": "ex:alice", + "schema:name": "Alice", + "ex:homePhone": "555-0100" + }), + ) + .await + .expect("data txn"); + + // Deny ex:phone; homePhone ⊑ phone must be denied too. + let policy = json!([ + { + "@id": "ex:denyPhone", + "@type": "f:AccessPolicy", + "f:action": "f:view", + "f:onProperty": [{"@id": "http://example.org/ns/phone"}], + "f:allow": false + }, + { + "@id": "ex:allowAll", + "@type": "f:AccessPolicy", + "f:action": "f:view", + "f:allow": true + } + ]); + let query = json!({ + "@context": {"ex": "http://example.org/ns/"}, + "from": ledger_id, + "opts": {"policy": policy, "default-allow": false}, + "select": "?v", + "where": {"@id": "ex:alice", "ex:homePhone": "?v"} + }); + + let result = fluree + .query_connection(&query) + .await + .expect("query_connection"); + let ledger = fluree.ledger(ledger_id).await.expect("ledger"); + let jsonld = result.to_jsonld(&ledger.snapshot).expect("to_jsonld"); + assert_eq!( + normalize_rows(&jsonld), + normalize_rows(&json!([])), + "property policy on ex:phone must deny the ex:homePhone subproperty" + ); +} diff --git a/fluree-db-policy/src/index.rs b/fluree-db-policy/src/index.rs index 7a73957960..3716d31da2 100644 --- a/fluree-db-policy/src/index.rs +++ b/fluree-db-policy/src/index.rs @@ -51,10 +51,35 @@ pub fn build_policy_set( restrictions: Vec, stats: Option<&IndexStats>, action_filter: PolicyAction, + hierarchy: Option<&fluree_db_core::SchemaHierarchy>, ) -> PolicySet { let mut set = PolicySet::new(); - for restriction in restrictions { + for mut restriction in restrictions { + // RDFS entailment (always on for enforcement): a class policy also + // governs instances of subclasses, and a property policy also + // governs subproperties. Expanding here — before indexing — means + // the class→property union, class_check_needed computation, and the + // four evaluation-time `for_classes` checks all see the closure. + if let Some(h) = hierarchy { + if !restriction.for_classes.is_empty() { + let expanded: Vec = restriction + .for_classes + .iter() + .flat_map(|c| h.subclasses_of(c).iter().cloned()) + .collect(); + restriction.for_classes.extend(expanded); + } + if restriction.target_mode == TargetMode::OnProperty { + let expanded: Vec = restriction + .targets + .iter() + .flat_map(|p| h.subproperties_of(p).iter().cloned()) + .collect(); + restriction.targets.extend(expanded); + } + } + // Filter by action match (&restriction.action, &action_filter) { (PolicyAction::Both, _) => {} // Matches any filter @@ -293,7 +318,7 @@ mod tests { make_prop_restriction("p2", make_sid(100, "age")), ]; - let set = build_policy_set(restrictions, None, PolicyAction::View); + let set = build_policy_set(restrictions, None, PolicyAction::View, None); assert_eq!(set.restrictions.len(), 2); assert_eq!( @@ -316,7 +341,7 @@ mod tests { let restrictions = vec![make_class_restriction("c1", person_class)]; - let set = build_policy_set(restrictions, Some(&stats), PolicyAction::View); + let set = build_policy_set(restrictions, Some(&stats), PolicyAction::View, None); assert_eq!(set.restrictions.len(), 1); @@ -334,7 +359,7 @@ mod tests { make_default_restriction("d1"), ]; - let set = build_policy_set(restrictions, None, PolicyAction::View); + let set = build_policy_set(restrictions, None, PolicyAction::View, None); assert_eq!(set.restrictions.len(), 2); assert_eq!(set.defaults.len(), 1); @@ -352,12 +377,12 @@ mod tests { let restrictions = vec![view_restriction, modify_restriction]; // Filter for View only - let view_set = build_policy_set(restrictions.clone(), None, PolicyAction::View); + let view_set = build_policy_set(restrictions.clone(), None, PolicyAction::View, None); assert_eq!(view_set.restrictions.len(), 1); assert_eq!(view_set.restrictions[0].id, "v1"); // Filter for Modify only - let modify_set = build_policy_set(restrictions, None, PolicyAction::Modify); + let modify_set = build_policy_set(restrictions, None, PolicyAction::Modify, None); assert_eq!(modify_set.restrictions.len(), 1); assert_eq!(modify_set.restrictions[0].id, "m1"); } diff --git a/fluree-db-policy/src/wire.rs b/fluree-db-policy/src/wire.rs index e9c3f02bcc..9757dc66b8 100644 --- a/fluree-db-policy/src/wire.rs +++ b/fluree-db-policy/src/wire.rs @@ -231,7 +231,10 @@ where R: Fn(&str) -> Option, { let restrictions = wire_to_restrictions(wire, resolve_iri, None)?; - Ok(build_policy_set(restrictions, stats, action_filter)) + // Cross-ledger wire policies apply in the data ledger's term space, but + // this path has no hierarchy handle yet — no RDFS expansion (matches the + // pre-entailment behavior for wire policies). + Ok(build_policy_set(restrictions, stats, action_filter, None)) } #[cfg(test)] @@ -389,7 +392,7 @@ mod tests { for_classes: HashSet::new(), class_check_needed: false, }; - let local_set = build_policy_set(vec![local_restriction], None, PolicyAction::View); + let local_set = build_policy_set(vec![local_restriction], None, PolicyAction::View, None); // (b) Wire form translated against the same Sid bindings. let wire = PolicyArtifactWire { From d15c81afa87fbcc772c5c1421f26c0722a1d2e27 Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 21:46:43 -0400 Subject: [PATCH 04/11] feat(shacl): RDFS subproperty inference in paths and predicate targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always-on entailment for enforcement: a property step over p also traverses every q rdfs:subPropertyOf p. Applied everywhere SHACL reads data by predicate: path evaluation (forward/inverse steps and the simple-predicate fast paths in both the top-level and nested property loops), pair-constraint target-property loads, and predicate targeting (sh:targetSubjectsOf / sh:targetObjectsOf in focus discovery, the per-node applicability probes, and the literal-object collection). The hierarchy threads through ClassMembershipCtx (already flowing through the validation call tree) and — the wiring gap this exposed — through validate_view_with_shacl into the staged-validation engine: the tx path handed only the ShaclCache across the crate boundary and stage.rs rebuilt a hierarchy-less engine, so engine-level entailment silently vanished at transaction time. The staged path now receives the shared Arc plus the hierarchy (also dropping a per- transaction deep clone of the compiled shapes). W3C SHACL core unchanged at 82.7% (no suite test exercises subPropertyOf; ours pin the behavior). --- fluree-db-api/src/shacl_tests.rs | 141 +++++++++++++++++++ fluree-db-api/src/tx.rs | 6 +- fluree-db-shacl/src/path.rs | 114 ++++++++++------ fluree-db-shacl/src/validate.rs | 223 ++++++++++++++++++------------- fluree-db-transact/src/stage.rs | 25 +++- 5 files changed, 373 insertions(+), 136 deletions(-) diff --git a/fluree-db-api/src/shacl_tests.rs b/fluree-db-api/src/shacl_tests.rs index 57a4af985d..50e27df355 100644 --- a/fluree-db-api/src/shacl_tests.rs +++ b/fluree-db-api/src/shacl_tests.rs @@ -4532,3 +4532,144 @@ async fn shacl_compile_cache_invalidates_on_new_shapes() { .unwrap_err(); assert_shacl_violation(err, "at least 1"); } + +#[tokio::test] +async fn shacl_path_sees_subproperty_values() { + // RDFS entailment (always on): a constraint on schema:name also governs + // values asserted via ex:firstName when firstName ⊑ name. + let fluree = FlureeBuilder::memory().build_memory(); + let context = shacl_context(); + let ledger = fluree + .create_ledger("shacl/subprop-path:main") + .await + .unwrap(); + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@graph": [ + { + "@id": "ex:NameShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:User"}, + "sh:property": [{ + "@id": "ex:sp-name-ps", + "sh:path": {"@id": "schema:name"}, + "sh:maxLength": 4 + }] + }, + {"@id": "ex:firstName", "rdfs:subPropertyOf": {"@id": "schema:name"}} + ] + }), + ) + .await + .unwrap() + .ledger; + + // Direct schema:name within limit + firstName value over the limit: + // the subproperty value must violate maxLength on schema:name. + let err = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:al", + "@type": "ex:User", + "schema:name": "Al", + "ex:firstName": "Alexander" + }), + ) + .await + .unwrap_err(); + assert_shacl_violation(err, "exceeds maximum"); + + // Conforming subproperty value passes. + let ledger2 = fluree + .create_ledger("shacl/subprop-path-ok:main") + .await + .unwrap(); + let ledger2 = fluree + .upsert( + ledger2, + &json!({ + "@context": context.clone(), + "@graph": [ + { + "@id": "ex:NameShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:User"}, + "sh:property": [{ + "@id": "ex:sp2-name-ps", + "sh:path": {"@id": "schema:name"}, + "sh:maxLength": 4 + }] + }, + {"@id": "ex:firstName", "rdfs:subPropertyOf": {"@id": "schema:name"}} + ] + }), + ) + .await + .unwrap() + .ledger; + fluree + .upsert( + ledger2, + &json!({ + "@context": context.clone(), + "@id": "ex:bo", + "@type": "ex:User", + "ex:firstName": "Bo" + }), + ) + .await + .expect("conforming subproperty value must pass"); +} + +#[tokio::test] +async fn shacl_target_subjects_of_sees_subproperties() { + // sh:targetSubjectsOf(ex:phone) must also target subjects that only + // carry ex:homePhone ⊑ ex:phone. + let fluree = FlureeBuilder::memory().build_memory(); + let context = shacl_context(); + let ledger = fluree + .create_ledger("shacl/subprop-target:main") + .await + .unwrap(); + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@graph": [ + { + "@id": "ex:PhoneOwnerShape", + "@type": "sh:NodeShape", + "sh:targetSubjectsOf": {"@id": "ex:phone"}, + "sh:property": [{ + "@id": "ex:tso-name-ps", + "sh:path": {"@id": "schema:name"}, + "sh:minCount": 1 + }] + }, + {"@id": "ex:homePhone", "rdfs:subPropertyOf": {"@id": "ex:phone"}} + ] + }), + ) + .await + .unwrap() + .ledger; + + let err = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:nameless", + "ex:homePhone": "555-0100" + }), + ) + .await + .unwrap_err(); + assert_shacl_violation(err, "at least 1"); +} diff --git a/fluree-db-api/src/tx.rs b/fluree-db-api/src/tx.rs index 0b353d5a96..cec51b599a 100644 --- a/fluree-db-api/src/tx.rs +++ b/fluree-db-api/src/tx.rs @@ -607,6 +607,9 @@ pub(crate) async fn apply_shacl_policy_to_staged_view( ) .await .map_err(fluree_db_transact::TransactError::from)?; + // SchemaHierarchy clones are refcount bumps; keep one for the staged + // validation pass (the other moves into engine construction). + let hierarchy_for_validation = hierarchy.clone(); // Cross-transaction compile reuse: skip the ~40 predicate scans of // ShapeCompiler when nothing shape-affecting changed since the last // compile of the same shape-source graphs. @@ -651,7 +654,7 @@ pub(crate) async fn apply_shacl_policy_to_staged_view( engine } }; - let shacl_cache = engine.cache(); + let shacl_cache = engine.shared_cache(); // No config + no shapes → skip (backward compat: shapes-exist heuristic). if !has_config && shacl_cache.is_empty() { @@ -665,6 +668,7 @@ pub(crate) async fn apply_shacl_policy_to_staged_view( let outcome = validate_view_with_shacl( view, shacl_cache, + hierarchy_for_validation, ctx.graph_sids, ctx.tracker, per_graph_policy.as_ref(), diff --git a/fluree-db-shacl/src/path.rs b/fluree-db-shacl/src/path.rs index 7e0858d40c..b7147e43ce 100644 --- a/fluree-db-shacl/src/path.rs +++ b/fluree-db-shacl/src/path.rs @@ -17,7 +17,7 @@ use crate::error::{Result, ShaclError}; use crate::predicates; use fluree_db_core::{ - id_datatype_sid, FlakeValue, GraphDbRef, IndexType, RangeMatch, RangeTest, Sid, + id_datatype_sid, FlakeValue, GraphDbRef, IndexType, RangeMatch, RangeTest, SchemaHierarchy, Sid, }; use fluree_vocab::namespaces::{BLANK_NODE, RDF, SHACL}; use fluree_vocab::rdf_names; @@ -344,6 +344,17 @@ async fn ordered_objects( Ok((items.into_iter().map(|(_, _, v)| v).collect(), all_indexed)) } +/// The predicate plus its RDFS subproperties — the always-on entailment +/// expansion for enforcement: a step over `p` also traverses every +/// `q rdfs:subPropertyOf p`. +pub(crate) fn with_subproperties(p: &Sid, hierarchy: Option<&SchemaHierarchy>) -> Vec { + let mut preds = vec![p.clone()]; + if let Some(h) = hierarchy { + preds.extend(h.subproperties_of(p).iter().cloned()); + } + preds +} + /// Evaluate a property path from `focus`, returning the reached value nodes as /// `(value, datatype, language)` tuples — the direct analogue of the objects /// of a single `SPOT` scan for a simple predicate. @@ -351,28 +362,31 @@ pub fn eval_path<'a>( db: GraphDbRef<'a>, focus: &'a Sid, path: &'a PropertyPath, + hierarchy: Option<&'a SchemaHierarchy>, ) -> PathFuture<'a, Vec> { Box::pin(async move { match path { - PropertyPath::Predicate(p) => forward_step(db, focus, p).await, - PropertyPath::Inverse(p) => inverse_step(db, focus, p).await, - PropertyPath::Sequence(steps) => eval_sequence(db, focus, steps).await, + PropertyPath::Predicate(p) => forward_step(db, focus, p, hierarchy).await, + PropertyPath::Inverse(p) => inverse_step(db, focus, p, hierarchy).await, + PropertyPath::Sequence(steps) => eval_sequence(db, focus, steps, hierarchy).await, PropertyPath::Alternative(alts) => { let mut out = Vec::new(); for alt in alts { - out.extend(eval_path(db, focus, alt).await?); + out.extend(eval_path(db, focus, alt, hierarchy).await?); } Ok(dedup(out)) } PropertyPath::ZeroOrMore(inner) => { let mut out = vec![(FlakeValue::Ref(focus.clone()), id_datatype_sid(), None)]; - out.extend(closure(db, focus, inner).await?); + out.extend(closure(db, focus, inner, hierarchy).await?); Ok(dedup(out)) } - PropertyPath::OneOrMore(inner) => Ok(dedup(closure(db, focus, inner).await?)), + PropertyPath::OneOrMore(inner) => { + Ok(dedup(closure(db, focus, inner, hierarchy).await?)) + } PropertyPath::ZeroOrOne(inner) => { let mut out = vec![(FlakeValue::Ref(focus.clone()), id_datatype_sid(), None)]; - out.extend(eval_path(db, focus, inner).await?); + out.extend(eval_path(db, focus, inner, hierarchy).await?); Ok(dedup(out)) } // Never evaluated: validation surfaces a violation for the owning @@ -382,40 +396,58 @@ pub fn eval_path<'a>( }) } -/// Forward single-predicate step: objects of `(focus, p, ?)`. -async fn forward_step(db: GraphDbRef<'_>, focus: &Sid, p: &Sid) -> Result> { - let flakes = db - .range( - IndexType::Spot, - RangeTest::Eq, - RangeMatch::subject_predicate(focus.clone(), p.clone()), - ) - .await?; - Ok(flakes - .iter() - .map(|f| { +/// Forward single-predicate step: objects of `(focus, p, ?)`, unioned over +/// `p` and its RDFS subproperties. +async fn forward_step( + db: GraphDbRef<'_>, + focus: &Sid, + p: &Sid, + hierarchy: Option<&SchemaHierarchy>, +) -> Result> { + let mut out = Vec::new(); + for pred in with_subproperties(p, hierarchy) { + let flakes = db + .range( + IndexType::Spot, + RangeTest::Eq, + RangeMatch::subject_predicate(focus.clone(), pred), + ) + .await?; + out.extend(flakes.iter().map(|f| { ( f.o.clone(), f.dt.clone(), f.m.as_ref().and_then(|m| m.lang.clone()), ) - }) - .collect()) + })); + } + Ok(out) } -/// Inverse single-predicate step: subjects of `(?, p, focus)`. -async fn inverse_step(db: GraphDbRef<'_>, focus: &Sid, p: &Sid) -> Result> { - let flakes = db - .range( - IndexType::Opst, - RangeTest::Eq, - RangeMatch::predicate_object(p.clone(), FlakeValue::Ref(focus.clone())), - ) - .await?; - Ok(flakes - .iter() - .map(|f| (FlakeValue::Ref(f.s.clone()), id_datatype_sid(), None)) - .collect()) +/// Inverse single-predicate step: subjects of `(?, p, focus)`, unioned over +/// `p` and its RDFS subproperties. +async fn inverse_step( + db: GraphDbRef<'_>, + focus: &Sid, + p: &Sid, + hierarchy: Option<&SchemaHierarchy>, +) -> Result> { + let mut out = Vec::new(); + for pred in with_subproperties(p, hierarchy) { + let flakes = db + .range( + IndexType::Opst, + RangeTest::Eq, + RangeMatch::predicate_object(pred, FlakeValue::Ref(focus.clone())), + ) + .await?; + out.extend( + flakes + .iter() + .map(|f| (FlakeValue::Ref(f.s.clone()), id_datatype_sid(), None)), + ); + } + Ok(out) } /// Evaluate a sequence path: chain each step, carrying `(value, dt)` only for @@ -424,6 +456,7 @@ async fn eval_sequence( db: GraphDbRef<'_>, focus: &Sid, steps: &[PropertyPath], + hierarchy: Option<&SchemaHierarchy>, ) -> Result> { let mut frontier: Vec = vec![focus.clone()]; @@ -431,7 +464,7 @@ async fn eval_sequence( let is_last = i + 1 == steps.len(); let mut reached: Vec = Vec::new(); for node in &frontier { - reached.extend(eval_path(db, node, step).await?); + reached.extend(eval_path(db, node, step, hierarchy).await?); } reached = dedup(reached); @@ -456,14 +489,19 @@ async fn eval_sequence( /// Transitive closure of `inner` from `focus` (one or more steps), BFS over the /// reference nodes reached. Non-reference values are terminal value nodes. -async fn closure(db: GraphDbRef<'_>, focus: &Sid, inner: &PropertyPath) -> Result> { +async fn closure( + db: GraphDbRef<'_>, + focus: &Sid, + inner: &PropertyPath, + hierarchy: Option<&SchemaHierarchy>, +) -> Result> { let mut out: Vec = Vec::new(); // Seed `visited` with the focus so a cycle back to it isn't re-expanded. let mut visited: HashSet = HashSet::from([focus.clone()]); let mut queue: Vec = vec![focus.clone()]; while let Some(node) = queue.pop() { - for (value, dt, lang) in eval_path(db, &node, inner).await? { + for (value, dt, lang) in eval_path(db, &node, inner, hierarchy).await? { if let FlakeValue::Ref(sid) = &value { if visited.insert(sid.clone()) { queue.push(sid.clone()); diff --git a/fluree-db-shacl/src/validate.rs b/fluree-db-shacl/src/validate.rs index 16392cb8f5..edff7a79ac 100644 --- a/fluree-db-shacl/src/validate.rs +++ b/fluree-db-shacl/src/validate.rs @@ -86,6 +86,9 @@ struct ClassMembershipCtx<'a> { /// vocabulary), when `f:shapesSource` is cross-ledger. Consulted on demand /// for `sh:class` membership after the local lookup misses. cross_ledger: Option>, + /// Current RDFS hierarchy for always-on entailment: property paths and + /// pair constraints traverse `p` plus its subproperties. + hierarchy: Option<&'a SchemaHierarchy>, } /// SHACL validation engine @@ -288,33 +291,36 @@ impl ShaclEngine { // are actually used as `SubjectsOf` targets are probed, so this is // bounded by the shape-set size, not the data size. for predicate in self.cache.by_target_subjects_of.keys() { - let flakes = db - .range( - IndexType::Spot, - RangeTest::Eq, - RangeMatch::subject_predicate(focus_node.clone(), predicate.clone()), - ) - .await?; - if !flakes.is_empty() { - applicable_shapes.extend(self.cache.shapes_for_subjects_of(predicate)); + for probe in crate::path::with_subproperties(predicate, self.hierarchy.as_ref()) { + let flakes = db + .range( + IndexType::Spot, + RangeTest::Eq, + RangeMatch::subject_predicate(focus_node.clone(), probe), + ) + .await?; + if !flakes.is_empty() { + applicable_shapes.extend(self.cache.shapes_for_subjects_of(predicate)); + break; + } } } // By `sh:targetObjectsOf(p)`: focus must currently appear as the // object of `p` (OPST existence check). Same bounded-cost argument. for predicate in self.cache.by_target_objects_of.keys() { - let flakes = db - .range( - IndexType::Opst, - RangeTest::Eq, - RangeMatch::predicate_object( - predicate.clone(), - FlakeValue::Ref(focus_node.clone()), - ), - ) - .await?; - if !flakes.is_empty() { - applicable_shapes.extend(self.cache.shapes_for_objects_of(predicate)); + for probe in crate::path::with_subproperties(predicate, self.hierarchy.as_ref()) { + let flakes = db + .range( + IndexType::Opst, + RangeTest::Eq, + RangeMatch::predicate_object(probe, FlakeValue::Ref(focus_node.clone())), + ) + .await?; + if !flakes.is_empty() { + applicable_shapes.extend(self.cache.shapes_for_objects_of(predicate)); + break; + } } } @@ -332,6 +338,7 @@ impl ShaclEngine { membership_g_ids: &self.membership_g_ids, cache: &self.class_cache, cross_ledger, + hierarchy: self.hierarchy.as_ref(), }; let active = ActiveShapeChecks::default(); for shape in applicable_shapes { @@ -386,6 +393,7 @@ impl ShaclEngine { membership_g_ids: &self.membership_g_ids, cache: &self.class_cache, cross_ledger, + hierarchy: self.hierarchy.as_ref(), }; for shape in self.cache.all_shapes() { if shape.deactivated { @@ -424,24 +432,24 @@ impl ShaclEngine { } } crate::compile::TargetType::ObjectsOf(predicate) => { - let flakes = db - .range( - IndexType::Psot, - RangeTest::Eq, - RangeMatch::predicate(predicate.clone()), - ) - .await?; - for flake in &flakes { - if matches!(flake.o, FlakeValue::Ref(_)) { - continue; - } - let lit = crate::compile::LiteralTarget { - value: flake.o.clone(), - datatype: flake.dt.clone(), - lang: flake.m.as_ref().and_then(|m| m.lang.clone()), - }; - if !literal_targets.contains(&lit) { - literal_targets.push(lit); + for pred in + crate::path::with_subproperties(predicate, self.hierarchy.as_ref()) + { + let flakes = db + .range(IndexType::Psot, RangeTest::Eq, RangeMatch::predicate(pred)) + .await?; + for flake in &flakes { + if matches!(flake.o, FlakeValue::Ref(_)) { + continue; + } + let lit = crate::compile::LiteralTarget { + value: flake.o.clone(), + datatype: flake.dt.clone(), + lang: flake.m.as_ref().and_then(|m| m.lang.clone()), + }; + if !literal_targets.contains(&lit) { + literal_targets.push(lit); + } } } } @@ -687,32 +695,26 @@ async fn get_focus_nodes( // `validate_literal_focus` in `validate_all_with_membership`. TargetType::LiteralNode(_) => {} TargetType::SubjectsOf(predicate) => { - // Find all subjects that have this predicate - let flakes = db - .range( - IndexType::Psot, - RangeTest::Eq, - RangeMatch::predicate(predicate.clone()), - ) - .await?; - - for flake in flakes { - focus_nodes.push(flake.s.clone()); + // Subjects of the predicate — or of any RDFS subproperty. + for pred in crate::path::with_subproperties(predicate, hierarchy) { + let flakes = db + .range(IndexType::Psot, RangeTest::Eq, RangeMatch::predicate(pred)) + .await?; + for flake in flakes { + focus_nodes.push(flake.s.clone()); + } } } TargetType::ObjectsOf(predicate) => { - // Find all objects of triples with this predicate - let flakes = db - .range( - IndexType::Psot, - RangeTest::Eq, - RangeMatch::predicate(predicate.clone()), - ) - .await?; - - for flake in flakes { - if let FlakeValue::Ref(obj) = &flake.o { - focus_nodes.push(obj.clone()); + // Objects of the predicate — or of any RDFS subproperty. + for pred in crate::path::with_subproperties(predicate, hierarchy) { + let flakes = db + .range(IndexType::Psot, RangeTest::Eq, RangeMatch::predicate(pred)) + .await?; + for flake in flakes { + if let FlakeValue::Ref(obj) = &flake.o { + focus_nodes.push(obj.clone()); + } } } } @@ -1012,13 +1014,20 @@ async fn focus_value_violations<'a>( | Constraint::Disjoint(target_prop) | Constraint::LessThan(target_prop) | Constraint::LessThanOrEquals(target_prop) => { - let target_flakes = db - .range( - IndexType::Spot, - RangeTest::Eq, - RangeMatch::subject_predicate(focus_node.clone(), target_prop.clone()), - ) - .await?; + let mut target_flakes = Vec::new(); + for pred in crate::path::with_subproperties( + target_prop, + class_ctx.and_then(|c| c.hierarchy), + ) { + target_flakes.extend( + db.range( + IndexType::Spot, + RangeTest::Eq, + RangeMatch::subject_predicate(focus_node.clone(), pred), + ) + .await?, + ); + } let target_values: Vec = target_flakes.iter().map(|f| f.o.clone()).collect(); violations.extend(validate_pair_constraint( @@ -1466,13 +1475,19 @@ fn validate_nested_shape<'a>( // scan; complex path → evaluate the AST (same as top-level shapes). let (values, datatypes, langs): (Vec, Vec, Vec>) = if let Some(pred) = path.as_predicate() { - let flakes = db - .range( - IndexType::Spot, - RangeTest::Eq, - RangeMatch::subject_predicate(focus_node.clone(), pred.clone()), - ) - .await?; + let mut flakes = Vec::new(); + for probe in + crate::path::with_subproperties(pred, class_ctx.and_then(|c| c.hierarchy)) + { + flakes.extend( + db.range( + IndexType::Spot, + RangeTest::Eq, + RangeMatch::subject_predicate(focus_node.clone(), probe), + ) + .await?, + ); + } ( flakes.iter().map(|f| f.o.clone()).collect(), flakes.iter().map(|f| f.dt.clone()).collect(), @@ -1483,7 +1498,13 @@ fn validate_nested_shape<'a>( ) } else { crate::path::split_path_values( - crate::path::eval_path(db, focus_node, path).await?, + crate::path::eval_path( + db, + focus_node, + path, + class_ctx.and_then(|c| c.hierarchy), + ) + .await?, ) }; @@ -1747,13 +1768,18 @@ async fn validate_property_shape<'a>( // language column feeds sh:uniqueLang / sh:languageIn. let (values, datatypes, langs): (Vec, Vec, Vec>) = if let Some(pred) = prop_shape.path.as_predicate() { - let flakes = db - .range( - IndexType::Spot, - RangeTest::Eq, - RangeMatch::subject_predicate(focus_node.clone(), pred.clone()), - ) - .await?; + let mut flakes = Vec::new(); + for probe in crate::path::with_subproperties(pred, class_ctx.and_then(|c| c.hierarchy)) + { + flakes.extend( + db.range( + IndexType::Spot, + RangeTest::Eq, + RangeMatch::subject_predicate(focus_node.clone(), probe), + ) + .await?, + ); + } ( flakes.iter().map(|f| f.o.clone()).collect(), flakes.iter().map(|f| f.dt.clone()).collect(), @@ -1764,7 +1790,13 @@ async fn validate_property_shape<'a>( ) } else { crate::path::split_path_values( - crate::path::eval_path(db, focus_node, &prop_shape.path).await?, + crate::path::eval_path( + db, + focus_node, + &prop_shape.path, + class_ctx.and_then(|c| c.hierarchy), + ) + .await?, ) }; @@ -1777,13 +1809,20 @@ async fn validate_property_shape<'a>( | Constraint::Disjoint(target_prop) | Constraint::LessThan(target_prop) | Constraint::LessThanOrEquals(target_prop) => { - let target_flakes = db - .range( - IndexType::Spot, - RangeTest::Eq, - RangeMatch::subject_predicate(focus_node.clone(), target_prop.clone()), - ) - .await?; + let mut target_flakes = Vec::new(); + for pred in crate::path::with_subproperties( + target_prop, + class_ctx.and_then(|c| c.hierarchy), + ) { + target_flakes.extend( + db.range( + IndexType::Spot, + RangeTest::Eq, + RangeMatch::subject_predicate(focus_node.clone(), pred), + ) + .await?, + ); + } let target_values: Vec = target_flakes.iter().map(|f| f.o.clone()).collect(); diff --git a/fluree-db-transact/src/stage.rs b/fluree-db-transact/src/stage.rs index 75b5031111..ad0b3ad647 100644 --- a/fluree-db-transact/src/stage.rs +++ b/fluree-db-transact/src/stage.rs @@ -2158,8 +2158,21 @@ pub async fn stage_with_shacl( .map(|(&g_id, iri)| (g_id, ns_registry.sid_for_iri(iri))) .collect(); - // Create SHACL engine from cache - let engine = ShaclEngine::new(shacl_cache.clone()); + // Create SHACL engine from cache, with the current (novelty-aware) RDFS + // hierarchy so subproperty/subclass entailment applies on this legacy + // path too. + let base = view.base(); + let hierarchy = base + .schema_hierarchy_cache + .current( + &base.snapshot, + base.novelty.as_ref(), + base.t(), + base.novelty.schema_epoch, + ) + .await?; + let engine = + ShaclEngine::from_shared_cache(std::sync::Arc::new(shacl_cache.clone()), hierarchy); // Validate staged flakes against shapes (per graph). `None` for // `enabled_graphs` means "validate every graph with staged flakes" — @@ -2224,9 +2237,11 @@ impl ShaclValidationOutcome { /// Returns a [`ShaclValidationOutcome`] split into reject / warn buckets. /// The caller decides whether to propagate an error, log warnings, or both. #[cfg(feature = "shacl")] +#[allow(clippy::too_many_arguments)] pub async fn validate_view_with_shacl( view: &StagedLedger, - shacl_cache: &ShaclCache, + shacl_cache: std::sync::Arc, + hierarchy: Option, graph_sids: Option<&HashMap>, tracker: Option<&fluree_db_core::Tracker>, per_graph_policy: Option<&HashMap>, @@ -2244,8 +2259,8 @@ pub async fn validate_view_with_shacl( // `cross_ledger_db` is a live handle into a model ledger holding the // controlled vocabulary (cross-ledger `f:shapesSource`), consulted on // demand for `sh:class` membership. - let engine = - ShaclEngine::new(shacl_cache.clone()).with_membership_graphs(membership_g_ids.to_vec()); + let engine = ShaclEngine::from_shared_cache(shacl_cache, hierarchy) + .with_membership_graphs(membership_g_ids.to_vec()); let enabled_graphs: Option> = per_graph_policy.map(|m| m.keys().copied().collect()); let report = validate_staged_nodes( From 074a7f7c4b5b751806f2f0020d35cdcd1fd4e401 Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 21:47:26 -0400 Subject: [PATCH 05/11] docs: RDFS entailment in SHACL and policy enforcement --- docs/guides/cookbook-shacl.md | 24 ++++++++++++++++++++++++ docs/security/policy-model.md | 9 +++++++++ 2 files changed, 33 insertions(+) diff --git a/docs/guides/cookbook-shacl.md b/docs/guides/cookbook-shacl.md index eb2cd839a5..7a8af17ed9 100644 --- a/docs/guides/cookbook-shacl.md +++ b/docs/guides/cookbook-shacl.md @@ -375,6 +375,30 @@ ex:StrictPersonShape a sh:NodeShape ; A closed shape forbids any property not explicitly declared (or listed in `sh:ignoredProperties`). Per the SHACL spec, `rdf:type` is **not** implicitly ignored — a closed shape with `sh:targetClass` (whose instances necessarily carry `rdf:type`) must list it in `sh:ignoredProperties`, as above. +## RDFS entailment in enforcement + +SHACL enforcement applies RDFS subclass and subproperty inference +**always** — no configuration needed: + +- **Targets**: `sh:targetClass ex:Employee` also fires for instances of any + `rdfs:subClassOf* ex:Employee` class; `sh:targetSubjectsOf` / + `sh:targetObjectsOf ex:phone` also match subjects/objects of any + `rdfs:subPropertyOf* ex:phone` property. +- **Paths**: a constraint on `sh:path schema:name` also governs values + asserted via any subproperty of `schema:name` (including through + sequence/inverse/alternative path steps and pair constraints). + +The hierarchy used is the **committed** state, kept current automatically: +commits that assert or retract `rdfs:subClassOf` / `rdfs:subPropertyOf` +invalidate a shared cache that rebuilds lazily; all other commits pay +nothing. One consequence worth knowing: schema asserted in the *same* +transaction as data does not entail for that transaction — commit the +schema first, then the data (two transactions). + +Policy enforcement applies the same inference: `f:onClass` policies govern +subclass instances and `f:onProperty` policies govern subproperties — see +[the policy cookbook](cookbook-policies.md). + ## RDFS subclass reasoning for `sh:class` `sh:class` honors `rdfs:subClassOf`. Example: diff --git a/docs/security/policy-model.md b/docs/security/policy-model.md index 9f3d85e1d4..4fe9e406de 100644 --- a/docs/security/policy-model.md +++ b/docs/security/policy-model.md @@ -82,6 +82,15 @@ Example (typed-literal form, suitable for inline policies): > **Inline policies must use full IRIs.** Compact IRIs (`schema:ssn`) inside an inline policy passed through `opts.policy` are not expanded against the request `@context`. Use full IRIs (`http://schema.org/ssn`). +## RDFS entailment + +Policy targeting applies RDFS inference **always**: a policy with +`f:onClass ex:Employee` governs instances of every `rdfs:subClassOf* +ex:Employee` class, and a policy with `f:onProperty ex:phone` governs every +`rdfs:subPropertyOf* ex:phone` property. The hierarchy reflects committed +state (kept current automatically; no reindex needed). Cross-ledger policy +wires are not expanded (no hierarchy handle in that path yet). + ## Combining algorithm When more than one policy targets the same flake, the engine combines them as follows: From e269c82d6ea3ae133ce11c6ff45ee1eb4881767c Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 21:52:34 -0400 Subject: [PATCH 06/11] chore: update lockfiles for parking_lot additions --- Cargo.lock | 2 ++ testsuite-shacl/Cargo.lock | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 96e5d19970..74fff4ecf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2478,6 +2478,7 @@ dependencies = [ "mimalloc", "moka", "num-bigint", + "parking_lot", "percent-encoding", "rand 0.8.5", "reqwest", @@ -2814,6 +2815,7 @@ dependencies = [ "fluree-db-novelty", "fluree-vocab", "futures", + "parking_lot", "serde_json", "thiserror 1.0.69", "tokio", diff --git a/testsuite-shacl/Cargo.lock b/testsuite-shacl/Cargo.lock index 3ce140f1da..6b99645b14 100644 --- a/testsuite-shacl/Cargo.lock +++ b/testsuite-shacl/Cargo.lock @@ -704,6 +704,7 @@ dependencies = [ "indexmap", "itoa", "moka", + "parking_lot", "percent-encoding", "rustc-hash", "ryu", @@ -886,6 +887,7 @@ dependencies = [ "fluree-db-novelty", "fluree-vocab", "futures", + "parking_lot", "serde_json", "thiserror 1.0.69", "tracing", From 13a1a8a4942ecc4e87d525c3e14d7dc762c5e5ff Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 21:55:53 -0400 Subject: [PATCH 07/11] memory --- .fluree-memory/repo.ttl | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.fluree-memory/repo.ttl b/.fluree-memory/repo.ttl index 207e6231fa..89e2da3895 100644 --- a/.fluree-memory/repo.ttl +++ b/.fluree-memory/repo.ttl @@ -1447,6 +1447,36 @@ mem:fact-01kj8k5nzpe59p5vp7zrehbrr9 a mem:Fact ; mem:branch "feature/memory" ; mem:createdAt "2026-02-24T19:49:14.358637+00:00"^^xsd:dateTime . +mem:constraint-01kwjtjew74a727brs0q3jaf7e a mem:Constraint ; + mem:content "SHACL tx-path engine identity: fluree-db-api builds a ShaclEngine but hands only the ShaclCache across the crate boundary into fluree-db-transact's validate_view_with_shacl, which used to rebuild a HIERARCHY-LESS engine — engine-level state (hierarchy for subproperty/predicate-target entailment) silently vanished at transaction time while cache-baked state (subclass target index) survived. Fixed: validate_view_with_shacl takes Arc + Option via ShaclEngine::from_shared_cache. When adding engine-level fields, thread them through this boundary or they only apply to validate_all paths." ; + mem:tag "crate-boundary" ; + mem:tag "engine" ; + mem:tag "hierarchy" ; + mem:tag "shacl" ; + mem:tag "tx-path" ; + mem:scope mem:repo ; + mem:artifactRef "fluree-db-api/src/tx.rs" ; + mem:artifactRef "fluree-db-shacl/src/validate.rs" ; + mem:artifactRef "fluree-db-transact/src/stage.rs" ; + mem:branch "feature/rdfs-enforcement-entailment" ; + mem:createdAt "2026-07-03T01:47:55.655870+00:00"^^xsd:dateTime . + +mem:fact-01kwjtjbnvb36dtaf2zrq806cj a mem:Fact ; + mem:content "RDFS enforcement entailment (feature/rdfs-enforcement-entailment): always-on subclass+subproperty inference for SHACL and Policy. Epoch counters on Novelty bumped in apply_commit's routing loop (schema_epoch: subClassOf/subPropertyOf; shacl_epoch: sh:* predicates or rdf:type→SHACL-type/rdfs:Class/owl:Class) + two Arc caches on LedgerState carried across commits: SchemaHierarchyCache (core; keyed indexed-schema-t + schema_epoch) and a type-erased compiled-SHACL slot (+shacl_epoch +shapes_g_ids; data-only txns skip ShapeCompiler via ShaclEngine::from_shared_cache). RULE: enforcement uses COMMITTED hierarchy — same-txn schema does not entail (two-txn workflow, pinned by test)." ; + mem:tag "enforcement" ; + mem:tag "entailment" ; + mem:tag "epoch-cache" ; + mem:tag "policy" ; + mem:tag "rdfs" ; + mem:tag "shacl" ; + mem:scope mem:repo ; + mem:artifactRef "fluree-db-api/src/tx.rs" ; + mem:artifactRef "fluree-db-core/src/schema_hierarchy.rs" ; + mem:artifactRef "fluree-db-novelty/src/lib.rs" ; + mem:artifactRef "fluree-db-policy/src/index.rs" ; + mem:branch "feature/rdfs-enforcement-entailment" ; + mem:createdAt "2026-07-03T01:47:52.379794+00:00"^^xsd:dateTime . + mem:fact-01kvzn4mmmcvc7zz6bcswtw47c a mem:Fact ; mem:content "SPARQL property paths, branch feature/sparql-property-paths: closed ALL reported gaps PLUS inverse-in-composite (wired in BOTH sparql lower/path.rs AND query parse/lower.rs for JSON-LD parity). (1) ZeroOrOne p?: PathModifier::ZeroOrOne; BFS self+1hop. (2) Negated sets !: fresh ?__np{n} pred-var triple + FILTER(?p != IRI(...)); fwd/inv/mixed=UNION. (3) Nested modifiers (p*)*: collapse_modifiers (zero=any */?, unbounded=any +/*). (4) Composite-transitive (a/b)+ incl inverse steps (^a/b)+: PropertyPathPattern has predicates+first_inverse+sequence_steps:Vec; operator's read_step reads SPOT/POST by direction (opposite index when retreating); single_predicate() bails on composite. ONLY UNSUPPORTED (niche): nested transitive step in a composite unit (a+/b)+ — not exercised by any W3C test." ; mem:tag "negated-set" ; From 501afc676059e32e2ab7a064d90072d52e4e93e1 Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 22:23:05 -0400 Subject: [PATCH 08/11] feat(shacl,policy): cross-ledger ontology feeds enforcement entailment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last cell of the entailment matrix: when the class/property hierarchy lives in a model ledger (config f:reasoningDefaults / f:schemaSource with f:ledger), SHACL and policy enforcement now merge its subclass/subproperty edges — matching what query reasoning already did. A shared cross_ledger::resolve_schema_closure_bundle resolves the SchemaClosure wire (t-cached in GovernanceCache: an unchanged model head is an Arc clone, not a re-query) and translates it against the data ledger's snapshot. Consumers: - Transaction SHACL: the bundle threads through StagedShaclContext; the enforcement hierarchy computes over it composed on novelty (bypassing the local hierarchy cache and the compiled-shape reuse, whose keys have no model-t dimension). - Policy: build_policy_context composes the bundle into its hierarchy on the query path (both branches), the transact policy path, and the cross-ledger policy path; a from_opts_with_schema wrapper keeps the seven existing plain callers unchanged (local-only hierarchy). follow-owl-imports with a cross-ledger schema source fails closed, mirroring the query path. Tests: Manager-typed records governed by an Employee shape (tx rejection) and an f:onClass Employee policy (query visibility) purely via M's rdfs:subClassOf edge. --- fluree-db-api/src/commit_transfer.rs | 1 + fluree-db-api/src/cross_ledger/mod.rs | 52 +++++++++ fluree-db-api/src/policy_builder.rs | 51 ++++++++- fluree-db-api/src/policy_view.rs | 25 ++++- fluree-db-api/src/tx.rs | 103 ++++++++++++++++-- fluree-db-api/src/view/fluree_ext.rs | 56 +++++++++- fluree-db-api/tests/it_policy_allow.rs | 98 +++++++++++++++++ fluree-db-api/tests/it_shapes_cross_ledger.rs | 102 +++++++++++++++++ 8 files changed, 473 insertions(+), 15 deletions(-) diff --git a/fluree-db-api/src/commit_transfer.rs b/fluree-db-api/src/commit_transfer.rs index 888a553faf..8c6175e833 100644 --- a/fluree-db-api/src/commit_transfer.rs +++ b/fluree-db-api/src/commit_transfer.rs @@ -463,6 +463,7 @@ impl Fluree { // re-validate same-ledger only. cross_ledger_shapes: None, staged_ns: None, + cross_ledger_schema: None, // Inline shapes are an authoring-time // construct; commit replay carries no // `opts` payload. diff --git a/fluree-db-api/src/cross_ledger/mod.rs b/fluree-db-api/src/cross_ledger/mod.rs index e5919cf0c8..b330069b76 100644 --- a/fluree-db-api/src/cross_ledger/mod.rs +++ b/fluree-db-api/src/cross_ledger/mod.rs @@ -82,3 +82,55 @@ pub(crate) fn encode_system_iri( ), }) } + +/// Resolve `f:reasoningDefaults`' `f:schemaSource` when it points at a +/// model ledger, translating the ontology wire against the data ledger's +/// snapshot. Returns `None` when no cross-ledger schema source is +/// configured. Resolution is t-cached (GovernanceCache): an unchanged model +/// head is an Arc clone, not a re-query. +/// +/// Shared by SHACL enforcement (transaction path) and policy-context +/// construction so both merge the model ledger's subclass/subproperty edges +/// into their entailment hierarchy. +pub(crate) async fn resolve_schema_closure_bundle( + reasoning: &fluree_db_core::ledger_config::ReasoningDefaults, + snapshot: &fluree_db_core::LedgerSnapshot, + ctx: &mut ResolveCtx<'_>, +) -> Result< + Option>, + CrossLedgerError, +> { + let Some(schema_source) = reasoning.schema_source.as_ref() else { + return Ok(None); + }; + if schema_source.ledger.is_none() { + return Ok(None); + } + // Mirror the query path: the cross-ledger materializer resolves a single + // graph and does not walk `owl:imports` — fail closed rather than + // silently enforcing against a partial closure. + if reasoning.follow_owl_imports.unwrap_or(false) { + return Err(CrossLedgerError::TranslationFailed { + ledger_id: schema_source.ledger.clone().unwrap_or_default(), + graph_iri: schema_source.graph_selector.clone().unwrap_or_default(), + detail: "`f:followOwlImports` is not supported with a cross-ledger `f:schemaSource`" + .into(), + }); + } + let resolved = resolve_graph_ref(schema_source, ArtifactKind::SchemaClosure, ctx).await?; + let GovernanceArtifact::SchemaClosure(wire) = &resolved.artifact else { + return Err(CrossLedgerError::TranslationFailed { + ledger_id: resolved.model_ledger_id.clone(), + graph_iri: resolved.graph_iri.clone(), + detail: "resolver returned a non-SchemaClosure artifact".into(), + }); + }; + let bundle = wire + .translate_to_schema_bundle_flakes(snapshot) + .map_err(|e| CrossLedgerError::TranslationFailed { + ledger_id: resolved.model_ledger_id.clone(), + graph_iri: resolved.graph_iri.clone(), + detail: format!("schema wire translation failed: {e}"), + })?; + Ok(Some(bundle)) +} diff --git a/fluree-db-api/src/policy_builder.rs b/fluree-db-api/src/policy_builder.rs index e470ef0b91..29158b5761 100644 --- a/fluree-db-api/src/policy_builder.rs +++ b/fluree-db-api/src/policy_builder.rs @@ -125,6 +125,7 @@ pub async fn build_policy_context_from_opts( opts, policy_graphs, None, + None, ) .await } @@ -152,6 +153,34 @@ pub async fn build_policy_context_from_opts( /// subject-existence check because identity binding always resolves /// against the data ledger; cross-ledger never contributes identity /// records. +/// [`build_policy_context_from_opts`] plus a pre-resolved cross-ledger +/// ontology bundle (`f:reasoningDefaults` / `f:schemaSource` with +/// `f:ledger`): the model ledger's subclass/subproperty edges merge into the +/// policy entailment hierarchy. Policies themselves remain same-ledger. +#[allow(clippy::too_many_arguments)] +pub async fn build_policy_context_from_opts_with_schema( + snapshot: &LedgerSnapshot, + overlay: &dyn fluree_db_core::OverlayProvider, + novelty_for_stats: Option<&Novelty>, + to_t: i64, + opts: &GovernanceOptions, + policy_graphs: &[fluree_db_core::GraphId], + cross_ledger_schema: Option>, +) -> Result { + build_policy_context_from_opts_inner( + snapshot, + overlay, + novelty_for_stats, + to_t, + opts, + policy_graphs, + None, + cross_ledger_schema, + ) + .await +} + +#[allow(clippy::too_many_arguments)] pub async fn build_policy_context_from_opts_with_cross_ledger( snapshot: &LedgerSnapshot, overlay: &dyn fluree_db_core::OverlayProvider, @@ -160,6 +189,7 @@ pub async fn build_policy_context_from_opts_with_cross_ledger( opts: &GovernanceOptions, policy_graphs: &[fluree_db_core::GraphId], cross_ledger_restrictions: Vec, + cross_ledger_schema: Option>, ) -> Result { build_policy_context_from_opts_inner( snapshot, @@ -169,10 +199,12 @@ pub async fn build_policy_context_from_opts_with_cross_ledger( opts, policy_graphs, Some(cross_ledger_restrictions), + cross_ledger_schema, ) .await } +#[allow(clippy::too_many_arguments)] async fn build_policy_context_from_opts_inner( snapshot: &LedgerSnapshot, overlay: &dyn fluree_db_core::OverlayProvider, @@ -181,6 +213,7 @@ async fn build_policy_context_from_opts_inner( opts: &GovernanceOptions, policy_graphs: &[fluree_db_core::GraphId], cross_ledger_restrictions: Option>, + cross_ledger_schema: Option>, ) -> Result { struct PolicyStatsLookup<'a> { overlay: &'a dyn fluree_db_core::OverlayProvider, @@ -338,9 +371,21 @@ async fn build_policy_context_from_opts_inner( // policies govern subclass instances, property policies govern // subproperties. Two bound-predicate scans + closure — small next to the // stats assembly and policy parsing above. - let hierarchy = fluree_db_core::compute_schema_hierarchy_with_overlay(snapshot, overlay, to_t) - .await - .map_err(|e| ApiError::internal(format!("policy hierarchy computation failed: {e}")))?; + let hierarchy = match &cross_ledger_schema { + // Cross-ledger ontology: compose the model ledger's schema bundle + // over the local overlay so its subclass/subproperty edges merge in. + Some(bundle) => { + let composed = fluree_db_query::schema_bundle::SchemaBundleOverlay::new( + overlay, + std::sync::Arc::clone(bundle), + ); + fluree_db_core::compute_schema_hierarchy_with_overlay(snapshot, &composed, to_t).await + } + None => { + fluree_db_core::compute_schema_hierarchy_with_overlay(snapshot, overlay, to_t).await + } + } + .map_err(|e| ApiError::internal(format!("policy hierarchy computation failed: {e}")))?; let view_set = build_policy_set( restrictions.clone(), diff --git a/fluree-db-api/src/policy_view.rs b/fluree-db-api/src/policy_view.rs index 3379e5ef48..97d8d1bdc9 100644 --- a/fluree-db-api/src/policy_view.rs +++ b/fluree-db-api/src/policy_view.rs @@ -374,6 +374,27 @@ pub async fn build_transact_policy_context( .and_then(|r| r.policy.as_ref()) .and_then(|p| p.policy_source.as_ref()); + // Cross-ledger ontology (f:schemaSource with f:ledger): resolve once so + // f:onClass / f:onProperty expansion sees the model ledger's hierarchy. + let cross_ledger_schema = match resolved + .as_ref() + .and_then(|r| r.reasoning.as_ref()) + .filter(|r| r.schema_source.as_ref().is_some_and(|s| s.ledger.is_some())) + { + Some(reasoning) => { + let ledger_id: String = snapshot.ledger_id.to_string(); + let mut schema_ctx = crate::cross_ledger::ResolveCtx::new(&ledger_id, fluree); + crate::cross_ledger::resolve_schema_closure_bundle(reasoning, snapshot, &mut schema_ctx) + .await + .map_err(|e| { + crate::error::ApiError::config(format!( + "cross-ledger f:schemaSource resolution failed: {e}" + )) + })? + } + None => None, + }; + if let Some(source) = source.filter(|s| s.ledger.is_some()) { let ledger_id: String = snapshot.ledger_id.to_string(); let mut ctx = crate::cross_ledger::ResolveCtx::new(&ledger_id, fluree); @@ -397,6 +418,7 @@ pub async fn build_transact_policy_context( &effective_opts, &[0], // identity-mode uses [0]; unused under cross-ledger restrictions, + cross_ledger_schema.clone(), ) .await?; return Ok(Some(policy_ctx)); @@ -407,13 +429,14 @@ pub async fn build_transact_policy_context( } let policy_graphs = policy_builder::resolve_policy_source_g_ids(source, snapshot)?; - let policy_ctx = policy_builder::build_policy_context_from_opts( + let policy_ctx = policy_builder::build_policy_context_from_opts_with_schema( snapshot, overlay, novelty_for_stats, to_t, &effective_opts, &policy_graphs, + cross_ledger_schema, ) .await?; Ok(Some(policy_ctx)) diff --git a/fluree-db-api/src/tx.rs b/fluree-db-api/src/tx.rs index cec51b599a..ebb6f91e6f 100644 --- a/fluree-db-api/src/tx.rs +++ b/fluree-db-api/src/tx.rs @@ -277,6 +277,15 @@ pub(crate) struct StagedShaclContext<'a> { pub inline_shape_bundle: Option>, + /// Cross-ledger ontology bundle (`f:reasoningDefaults` / + /// `f:schemaSource` with `f:ledger`), pre-resolved at the API boundary + /// and translated against D's snapshot. When `Some`, the enforcement + /// hierarchy is computed over this bundle composed on novelty, so + /// subclass/subproperty edges living in the model ledger reach SHACL + /// targeting and path inference. + pub cross_ledger_schema: + Option>, + /// Live model-ledger membership source for cross-ledger `sh:class` /// value-sets. Present only when `f:shapesSource` is cross-ledger /// (`f:ledger` set). Carries a `GraphDbRef` into M's value-set graph at the @@ -353,6 +362,48 @@ async fn resolve_cross_ledger_shapes_for_tx( Ok(Some(resolved)) } +/// Inspect the data ledger's resolved config and, when +/// `f:reasoningDefaults`' `f:schemaSource` carries a cross-ledger +/// `f:ledger` reference, resolve the model ledger's ontology graph and +/// translate it against D's snapshot. The returned bundle merges into the +/// enforcement hierarchy so subclass/subproperty edges living in M govern +/// SHACL targeting on D. Resolution is t-cached (GovernanceCache): an +/// unchanged M head is an Arc clone, not a re-query. +#[cfg(feature = "shacl")] +async fn resolve_cross_ledger_schema_for_tx( + ledger: &LedgerState, + ctx: &mut crate::cross_ledger::ResolveCtx<'_>, +) -> std::result::Result< + Option>, + fluree_db_transact::TransactError, +> { + let config = match crate::config_resolver::resolve_ledger_config( + &ledger.snapshot, + ledger.novelty.as_ref(), + ledger.t(), + ) + .await + { + Ok(Some(c)) => c, + Ok(None) => return Ok(None), + Err(e) => { + return Err(fluree_db_transact::TransactError::Parse(format!( + "failed to load ledger config while resolving cross-ledger f:schemaSource: {e}" + ))); + } + }; + let Some(reasoning) = config.reasoning.as_ref() else { + return Ok(None); + }; + crate::cross_ledger::resolve_schema_closure_bundle(reasoning, &ledger.snapshot, ctx) + .await + .map_err(|e| { + fluree_db_transact::TransactError::Parse(format!( + "f:schemaSource cross-ledger resolution failed: {e}" + )) + }) +} + /// Resolve `f:shapesSource` from a loaded `LedgerConfig` into concrete graph /// IDs, against the current snapshot's graph registry. /// @@ -597,22 +648,47 @@ pub(crate) async fn apply_shacl_policy_to_staged_view( // Current (novelty-aware) RDFS hierarchy: subclass targeting must see // relations committed since the last index build. Cached on the ledger // state; rebuilt only when a commit touched subClassOf/subPropertyOf. - let hierarchy = base - .schema_hierarchy_cache - .current( - &base.snapshot, - base.novelty.as_ref(), - base.t(), - base.novelty.schema_epoch, - ) - .await - .map_err(fluree_db_transact::TransactError::from)?; + // When a cross-ledger f:schemaSource is configured, M's ontology bundle + // composes over novelty so its subclass/subproperty edges merge in — + // this path bypasses the local cache (keyed only by local epochs); the + // GovernanceCache already avoids re-reading M while its head t is + // unchanged. + let hierarchy = match &ctx.cross_ledger_schema { + Some(bundle) => { + let overlay = fluree_db_query::schema_bundle::SchemaBundleOverlay::new( + base.novelty.as_ref(), + std::sync::Arc::clone(bundle), + ); + fluree_db_core::compute_schema_hierarchy_with_overlay( + &base.snapshot, + &overlay, + base.t(), + ) + .await + .map_err(fluree_db_transact::TransactError::from)? + } + None => base + .schema_hierarchy_cache + .current( + &base.snapshot, + base.novelty.as_ref(), + base.t(), + base.novelty.schema_epoch, + ) + .await + .map_err(fluree_db_transact::TransactError::from)?, + }; // SchemaHierarchy clones are refcount bumps; keep one for the staged // validation pass (the other moves into engine construction). let hierarchy_for_validation = hierarchy.clone(); // Cross-transaction compile reuse: skip the ~40 predicate scans of // ShapeCompiler when nothing shape-affecting changed since the last // compile of the same shape-source graphs. + let cacheable_shape_g_ids = if ctx.cross_ledger_schema.is_some() { + None + } else { + cacheable_shape_g_ids + }; let reuse_key = cacheable_shape_g_ids.as_ref().map(|g_ids| { ( base.snapshot.t, @@ -761,6 +837,11 @@ async fn stage_with_config_shacl( // staging as an internal governance input and compiled // against the staged namespace registry at validation time. let cross_ledger_shapes = resolve_cross_ledger_shapes_for_tx(&ledger, resolve_ctx).await?; + // Same boundary for the cross-ledger ontology: when + // f:reasoningDefaults/f:schemaSource points at M, resolve the schema + // wire (t-cached) so the enforcement hierarchy can merge M's + // subclass/subproperty edges. + let cross_ledger_schema = resolve_cross_ledger_schema_for_tx(&ledger, resolve_ctx).await?; let (view, mut ns_registry) = stage_txn(ledger, txn, ns_registry, options).await?; @@ -858,6 +939,7 @@ async fn stage_with_config_shacl( }), staged_ns: cross_ledger_shapes.as_deref().map(|_| &ns_registry), inline_shape_bundle, + cross_ledger_schema, cross_ledger_membership, }, ) @@ -2450,6 +2532,7 @@ impl crate::Fluree { // the use case lands. cross_ledger_shapes: None, staged_ns: None, + cross_ledger_schema: None, // Turtle insert API has no `opts.shapes` surface // today — inline SHACL flows in over the JSON // transaction path. Wireable later if needed. diff --git a/fluree-db-api/src/view/fluree_ext.rs b/fluree-db-api/src/view/fluree_ext.rs index ec49999a9e..6c364ccd40 100644 --- a/fluree-db-api/src/view/fluree_ext.rs +++ b/fluree-db-api/src/view/fluree_ext.rs @@ -717,6 +717,27 @@ impl Fluree { ) .await?; + // Cross-ledger ontology (f:schemaSource with f:ledger) merges the + // model ledger's hierarchy into policy class/property expansion. + let cross_ledger_schema = match view + .resolved_config + .as_ref() + .and_then(|c| c.reasoning.as_ref()) + { + Some(reasoning) => crate::cross_ledger::resolve_schema_closure_bundle( + reasoning, + &view.snapshot, + &mut ctx, + ) + .await + .map_err(|e| { + crate::error::ApiError::config(format!( + "cross-ledger f:schemaSource resolution failed: {e}" + )) + })?, + None => None, + }; + let policy_ctx = crate::policy_builder::build_policy_context_from_opts_with_cross_ledger( &view.snapshot, @@ -726,6 +747,7 @@ impl Fluree { &effective_opts, &[0], // identity-mode uses [0]; unused under cross-ledger restrictions, + cross_ledger_schema, ) .await?; // Carry the per-ledger head-t captures forward onto @@ -747,13 +769,45 @@ impl Fluree { vec![0] }; - let policy_ctx = crate::policy_builder::build_policy_context_from_opts( + // Local policies may still entail through a cross-ledger ontology + // (f:schemaSource with f:ledger) — resolve it so f:onClass / + // f:onProperty expansion sees the model ledger's hierarchy. + let cross_ledger_schema = match view + .resolved_config + .as_ref() + .and_then(|c| c.reasoning.as_ref()) + .filter(|r| r.schema_source.as_ref().is_some_and(|s| s.ledger.is_some())) + { + Some(reasoning) => { + let ledger_id_owned: String = view.snapshot.ledger_id.to_string(); + let mut ctx = crate::cross_ledger::ResolveCtx::with_resolved_ts( + &ledger_id_owned, + self, + (**view.cross_ledger_resolved_ts()).clone(), + ); + crate::cross_ledger::resolve_schema_closure_bundle( + reasoning, + &view.snapshot, + &mut ctx, + ) + .await + .map_err(|e| { + crate::error::ApiError::config(format!( + "cross-ledger f:schemaSource resolution failed: {e}" + )) + })? + } + None => None, + }; + + let policy_ctx = crate::policy_builder::build_policy_context_from_opts_with_schema( &view.snapshot, view.overlay.as_ref(), view.novelty_for_stats(), view.t, &effective_opts, &policy_graphs, + cross_ledger_schema, ) .await?; Ok(view.with_policy(Arc::new(policy_ctx))) diff --git a/fluree-db-api/tests/it_policy_allow.rs b/fluree-db-api/tests/it_policy_allow.rs index 3686977406..61989856d7 100644 --- a/fluree-db-api/tests/it_policy_allow.rs +++ b/fluree-db-api/tests/it_policy_allow.rs @@ -968,3 +968,101 @@ async fn policy_onproperty_governs_subproperties() { "property policy on ex:phone must deny the ex:homePhone subproperty" ); } + +/// Cross-ledger RDFS entailment for policy: the class hierarchy lives in a +/// model ledger (config `f:reasoningDefaults` / `f:schemaSource` with +/// `f:ledger`); an `f:onClass ex:Employee` policy must govern Manager-typed +/// subjects because `Manager rdfs:subClassOf Employee` is declared in M. +#[tokio::test] +async fn policy_onclass_governs_subclass_via_cross_ledger_schema() { + assert_index_defaults(); + let fluree = FlureeBuilder::memory().build_memory(); + + // Model ledger M: the ontology graph. + let model_id = "policy/xl-schema-model:main"; + let model = genesis_ledger(&fluree, model_id); + let ontology_iri = "http://example.org/governance/ontology"; + fluree + .stage_owned(model) + .upsert_turtle(&format!( + r" + @prefix rdfs: . + @prefix ex: . + GRAPH <{ontology_iri}> {{ + ex:Manager rdfs:subClassOf ex:Employee . + }} + " + )) + .execute() + .await + .expect("seed M ontology"); + + // Data ledger D: config points the schema source at M; data has a + // Manager and an untyped-in-hierarchy Contractor. + let data_id = "policy/xl-schema-data:main"; + let data = genesis_ledger(&fluree, data_id); + let config_iri = format!("urn:fluree:{data_id}#config"); + let r1 = fluree + .stage_owned(data) + .upsert_turtle(&format!( + r" + @prefix f: . + @prefix rdf: . + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig ; + f:reasoningDefaults . + f:schemaSource . + rdf:type f:GraphRef ; + f:graphSource . + f:ledger <{model_id}> ; + f:graphSelector <{ontology_iri}> . + }} + " + )) + .execute() + .await + .expect("seed D config"); + let _ = fluree + .insert( + r1.ledger, + &json!({ + "@context": {"ex": "http://example.org/ns/", "schema": "http://schema.org/"}, + "@graph": [ + {"@id": "ex:pat", "@type": "ex:Manager", "schema:name": "Pat"}, + {"@id": "ex:sam", "@type": "ex:Contractor", "schema:name": "Sam"} + ] + }), + ) + .await + .expect("seed D data"); + + let policy = json!([ + { + "@id": "ex:employeePolicy", + "@type": "f:AccessPolicy", + "f:action": "f:view", + "f:onClass": [{"@id": "http://example.org/ns/Employee"}], + "f:allow": true + } + ]); + let query = json!({ + "@context": {"ex": "http://example.org/ns/", "schema": "http://schema.org/"}, + "from": data_id, + "opts": {"policy": policy, "default-allow": false}, + "select": "?name", + "where": {"@id": "?s", "schema:name": "?name"} + }); + + let result = fluree + .query_connection(&query) + .await + .expect("query_connection"); + let ledger = fluree.ledger(data_id).await.expect("ledger"); + let jsonld = result.to_jsonld(&ledger.snapshot).expect("to_jsonld"); + + assert_eq!( + normalize_rows(&jsonld), + normalize_rows(&json!(["Pat"])), + "onClass Employee must govern Manager via M's cross-ledger hierarchy" + ); +} diff --git a/fluree-db-api/tests/it_shapes_cross_ledger.rs b/fluree-db-api/tests/it_shapes_cross_ledger.rs index 222a6480ea..bcbb32dd1e 100644 --- a/fluree-db-api/tests/it_shapes_cross_ledger.rs +++ b/fluree-db-api/tests/it_shapes_cross_ledger.rs @@ -288,3 +288,105 @@ async fn cross_ledger_sh_class_value_set_resolved_against_model_ledger() { "expected ShaclViolation for non-member, got: {err:?}" ); } + +/// Cross-ledger RDFS entailment for enforcement: the class hierarchy lives +/// in model ledger M (`f:reasoningDefaults` / `f:schemaSource` with +/// `f:ledger`), while the shape lives locally in D. A Manager-typed record +/// must be governed by D's Employee-targeting shape because +/// `Manager rdfs:subClassOf Employee` is declared in M. +#[tokio::test] +async fn cross_ledger_schema_feeds_shacl_subclass_targeting() { + let fluree = FlureeBuilder::memory().build_memory(); + + let model_id = "test/cross-ledger-schema/model:main"; + let model = genesis_ledger(&fluree, model_id); + let ontology_graph_iri = "http://example.org/governance/ontology"; + let m_trig = format!( + r" + @prefix rdfs: . + @prefix ex: . + + GRAPH <{ontology_graph_iri}> {{ + ex:Manager rdfs:subClassOf ex:Employee . + }} + " + ); + fluree + .stage_owned(model) + .upsert_turtle(&m_trig) + .execute() + .await + .expect("seed M ontology"); + + let data_id = "test/cross-ledger-schema/data:main"; + let data = genesis_ledger(&fluree, data_id); + + // D: local Employee shape + config pointing the schema source at M. + let config_iri = config_graph_iri(data_id); + let r1 = fluree + .stage_owned(data) + .upsert_turtle(&format!( + r" + @prefix f: . + @prefix rdf: . + @prefix sh: . + @prefix ex: . + + ex:EmployeeShape rdf:type sh:NodeShape ; + sh:targetClass ex:Employee ; + sh:property ex:EmployeeShape-name . + ex:EmployeeShape-name sh:path ex:name ; + sh:minCount 1 . + + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig ; + f:shaclDefaults ; + f:reasoningDefaults . + f:shaclEnabled true . + f:schemaSource . + rdf:type f:GraphRef ; + f:graphSource . + f:ledger <{model_id}> ; + f:graphSelector <{ontology_graph_iri}> . + }} + " + )) + .execute() + .await + .expect("seed D shape + cross-ledger schema config"); + let data = r1.ledger; + + // Manager without ex:name → rejected via M's subclass edge. + let err = fluree + .insert( + data.clone(), + &json!({ + "@context": {"ex": "http://example.org/ns/"}, + "@id": "ex:grace", + "@type": "ex:Manager" + }), + ) + .await + .expect_err("Manager must be governed by the Employee shape via M's hierarchy"); + assert!( + matches!( + err, + fluree_db_api::ApiError::Transact(fluree_db_transact::TransactError::ShaclViolation(_)) + ), + "expected ShaclViolation, got: {err:?}" + ); + + // Conforming Manager passes. + fluree + .insert( + data, + &json!({ + "@context": {"ex": "http://example.org/ns/"}, + "@id": "ex:hana", + "@type": "ex:Manager", + "ex:name": "Hana" + }), + ) + .await + .expect("conforming Manager must pass"); +} From cc73f76db5bcdef847303960d6144e1d445de712 Mon Sep 17 00:00:00 2001 From: bplatz Date: Sat, 4 Jul 2026 10:07:16 -0400 Subject: [PATCH 09/11] fix(policy,shacl): don't reject writes on followOwlImports with cross-ledger schemaSource The enforcement-entailment resolver hard-failed on f:followOwlImports + cross-ledger f:schemaSource, mirroring the query path's fail-closed guard. But this resolver runs inside every transaction's enforcement setup, so once such a config committed, every subsequent write on the ledger was rejected -- including the config repair itself -- and the pre-existing it_schema_cross_ledger fails-closed tests (which pin that inserts succeed and the rejection surfaces at reasoning-query time) broke. Skip the cross-ledger bundle instead (warn + local-only hierarchy): enforcement falls back to exactly the pre-feature behavior for this unsupported combination, while the loud rejection stays on the reasoning-query path (resolve_configured_schema_bundle), where an incomplete import closure would actually change entailment results. --- fluree-db-api/src/cross_ledger/mod.rs | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/fluree-db-api/src/cross_ledger/mod.rs b/fluree-db-api/src/cross_ledger/mod.rs index b330069b76..32aaec7480 100644 --- a/fluree-db-api/src/cross_ledger/mod.rs +++ b/fluree-db-api/src/cross_ledger/mod.rs @@ -106,16 +106,25 @@ pub(crate) async fn resolve_schema_closure_bundle( if schema_source.ledger.is_none() { return Ok(None); } - // Mirror the query path: the cross-ledger materializer resolves a single - // graph and does not walk `owl:imports` — fail closed rather than - // silently enforcing against a partial closure. + // The cross-ledger materializer resolves a single graph and does not + // walk `owl:imports`, so `f:followOwlImports` cannot be honored here. + // Skip the bundle (local-only hierarchy) rather than erroring: this + // resolver runs inside every transaction's enforcement setup, so a hard + // failure would reject every subsequent write on the ledger — including + // the config repair itself. The loud fail-closed rejection lives on the + // reasoning-query path (`resolve_configured_schema_bundle`), which is + // where an incomplete closure would actually change entailment results; + // enforcement merely falls back to the pre-feature local hierarchy. if reasoning.follow_owl_imports.unwrap_or(false) { - return Err(CrossLedgerError::TranslationFailed { - ledger_id: schema_source.ledger.clone().unwrap_or_default(), - graph_iri: schema_source.graph_selector.clone().unwrap_or_default(), - detail: "`f:followOwlImports` is not supported with a cross-ledger `f:schemaSource`" - .into(), - }); + tracing::warn!( + model_ledger = schema_source.ledger.as_deref().unwrap_or_default(), + graph_selector = schema_source.graph_selector.as_deref().unwrap_or_default(), + "`f:followOwlImports` is not supported with a cross-ledger \ + `f:schemaSource`; skipping cross-ledger enforcement entailment \ + (local hierarchy only). Reasoning queries against this config \ + fail closed." + ); + return Ok(None); } let resolved = resolve_graph_ref(schema_source, ArtifactKind::SchemaClosure, ctx).await?; let GovernanceArtifact::SchemaClosure(wire) = &resolved.artifact else { From 64699d8949e8958f919217c7c64cad7af0feef3d Mon Sep 17 00:00:00 2001 From: bplatz Date: Sun, 5 Jul 2026 09:13:29 -0400 Subject: [PATCH 10/11] perf(policy): skip RDFS hierarchy computation when no restriction uses it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_policy_context_from_opts_inner computed the schema hierarchy on every governed query, uncached — an O(ontology-size) schema clone + sort plus two bound-predicate scans. Only OnClass (subclass-instance) and OnProperty (subproperty) restrictions consult it; identity-only, default-allow, and OnSubject policies don't. Guard the computation on a needs_hierarchy check so those paths pay nothing, and a class/property-governed ledger only pays when a restriction can actually use the result. Behavior unchanged: subclass/subproperty entailment still fires (covered by policy_onclass_governs_subclass_instances / _via_cross_ledger_schema and policy_onproperty_governs_subproperties). --- fluree-db-api/src/policy_builder.rs | 42 ++++++++++++++++++----------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/fluree-db-api/src/policy_builder.rs b/fluree-db-api/src/policy_builder.rs index 29158b5761..1c1a84541d 100644 --- a/fluree-db-api/src/policy_builder.rs +++ b/fluree-db-api/src/policy_builder.rs @@ -369,23 +369,33 @@ async fn build_policy_context_from_opts_inner( // Current RDFS hierarchy (always-on entailment for enforcement): class // policies govern subclass instances, property policies govern - // subproperties. Two bound-predicate scans + closure — small next to the - // stats assembly and policy parsing above. - let hierarchy = match &cross_ledger_schema { - // Cross-ledger ontology: compose the model ledger's schema bundle - // over the local overlay so its subclass/subproperty edges merge in. - Some(bundle) => { - let composed = fluree_db_query::schema_bundle::SchemaBundleOverlay::new( - overlay, - std::sync::Arc::clone(bundle), - ); - fluree_db_core::compute_schema_hierarchy_with_overlay(snapshot, &composed, to_t).await - } - None => { - fluree_db_core::compute_schema_hierarchy_with_overlay(snapshot, overlay, to_t).await + // subproperties. Only OnClass/OnProperty restrictions consult it — + // identity-only, default-allow, and OnSubject policies don't. Skip the + // O(ontology-size) schema clone + sort + scans entirely when nothing can + // use it, since this builder runs uncached on every governed query. + let needs_hierarchy = restrictions + .iter() + .any(|r| !r.for_classes.is_empty() || matches!(r.target_mode, TargetMode::OnProperty)); + let hierarchy = if !needs_hierarchy { + None + } else { + match &cross_ledger_schema { + // Cross-ledger ontology: compose the model ledger's schema bundle + // over the local overlay so its subclass/subproperty edges merge in. + Some(bundle) => { + let composed = fluree_db_query::schema_bundle::SchemaBundleOverlay::new( + overlay, + std::sync::Arc::clone(bundle), + ); + fluree_db_core::compute_schema_hierarchy_with_overlay(snapshot, &composed, to_t) + .await + } + None => { + fluree_db_core::compute_schema_hierarchy_with_overlay(snapshot, overlay, to_t).await + } } - } - .map_err(|e| ApiError::internal(format!("policy hierarchy computation failed: {e}")))?; + .map_err(|e| ApiError::internal(format!("policy hierarchy computation failed: {e}")))? + }; let view_set = build_policy_set( restrictions.clone(), From 17ae7ebf644c23ad487ebfca3a69537635723163 Mon Sep 17 00:00:00 2001 From: bplatz Date: Sun, 5 Jul 2026 09:18:28 -0400 Subject: [PATCH 11/11] perf(tx): resolve ledger config once for both cross-ledger resolvers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_cross_ledger_shapes_for_tx and resolve_cross_ledger_schema_for_tx each called resolve_ledger_config independently, so a config-bearing ledger ran the config-graph type scan + parse twice per transaction. Resolve it once in stage_with_config_shacl and pass Option<&LedgerConfig> into both. The shapes resolver no longer needs the LedgerState handle at all; the schema resolver keeps it for resolve_schema_closure_bundle. Plain (config-less) ledgers are unaffected — a None config short-circuits both to no dispatch. --- fluree-db-api/src/tx.rs | 75 ++++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 42 deletions(-) diff --git a/fluree-db-api/src/tx.rs b/fluree-db-api/src/tx.rs index ebb6f91e6f..f60d99eb79 100644 --- a/fluree-db-api/src/tx.rs +++ b/fluree-db-api/src/tx.rs @@ -316,30 +316,16 @@ pub(crate) struct StagedShaclContext<'a> { /// type would preserve the variant. #[cfg(feature = "shacl")] async fn resolve_cross_ledger_shapes_for_tx( - ledger: &LedgerState, + config: Option<&LedgerConfig>, ctx: &mut crate::cross_ledger::ResolveCtx<'_>, ) -> std::result::Result< Option>, fluree_db_transact::TransactError, > { - // Load the same config the same-ledger SHACL path loads (from - // pre-tx state). resolve_ledger_config returns None on a fresh - // ledger with no #config — in that case there's no cross-ledger - // to dispatch. - let config = match crate::config_resolver::resolve_ledger_config( - &ledger.snapshot, - ledger.novelty.as_ref(), - ledger.t(), - ) - .await - { - Ok(Some(c)) => c, - Ok(None) => return Ok(None), - Err(e) => { - return Err(fluree_db_transact::TransactError::Parse(format!( - "failed to load ledger config while resolving cross-ledger f:shapesSource: {e}" - ))); - } + // No #config → no cross-ledger to dispatch. Config is resolved once by the + // caller and shared with the schema resolver. + let Some(config) = config else { + return Ok(None); }; let shapes_source = config.shacl.as_ref().and_then(|s| s.shapes_source.as_ref()); let Some(source) = shapes_source else { @@ -372,25 +358,15 @@ async fn resolve_cross_ledger_shapes_for_tx( #[cfg(feature = "shacl")] async fn resolve_cross_ledger_schema_for_tx( ledger: &LedgerState, + config: Option<&LedgerConfig>, ctx: &mut crate::cross_ledger::ResolveCtx<'_>, ) -> std::result::Result< Option>, fluree_db_transact::TransactError, > { - let config = match crate::config_resolver::resolve_ledger_config( - &ledger.snapshot, - ledger.novelty.as_ref(), - ledger.t(), - ) - .await - { - Ok(Some(c)) => c, - Ok(None) => return Ok(None), - Err(e) => { - return Err(fluree_db_transact::TransactError::Parse(format!( - "failed to load ledger config while resolving cross-ledger f:schemaSource: {e}" - ))); - } + // No #config → nothing to dispatch. Config is resolved once by the caller. + let Some(config) = config else { + return Ok(None); }; let Some(reasoning) = config.reasoning.as_ref() else { return Ok(None); @@ -829,19 +805,34 @@ async fn stage_with_config_shacl( let inline_shapes_json = txn.opts.shapes.take(); let inline_shapes_ledger_id = ledger.snapshot.ledger_id.to_string(); - // Detect cross-ledger SHACL config at the API boundary BEFORE - // staging starts: read D's resolved config and, if - // f:shapesSource carries f:ledger, resolve the wire artifact - // from M now so the per-tx ResolveCtx benefits from memo + - // governance cache. The wire is then threaded through - // staging as an internal governance input and compiled - // against the staged namespace registry at validation time. - let cross_ledger_shapes = resolve_cross_ledger_shapes_for_tx(&ledger, resolve_ctx).await?; + // Detect cross-ledger governance at the API boundary BEFORE staging + // starts. Resolve D's config once from pre-tx state and share it across + // both resolvers below — each used to re-run the config-graph scan + parse + // independently. `None` (no #config) short-circuits both to no dispatch. + let config = crate::config_resolver::resolve_ledger_config( + &ledger.snapshot, + ledger.novelty.as_ref(), + ledger.t(), + ) + .await + .map_err(|e| { + fluree_db_transact::TransactError::Parse(format!( + "failed to load ledger config for cross-ledger governance resolution: {e}" + )) + })?; + + // When f:shapesSource carries f:ledger, resolve the wire artifact from M + // now so the per-tx ResolveCtx benefits from memo + governance cache. The + // wire is threaded through staging as an internal governance input and + // compiled against the staged namespace registry at validation time. + let cross_ledger_shapes = + resolve_cross_ledger_shapes_for_tx(config.as_ref(), resolve_ctx).await?; // Same boundary for the cross-ledger ontology: when // f:reasoningDefaults/f:schemaSource points at M, resolve the schema // wire (t-cached) so the enforcement hierarchy can merge M's // subclass/subproperty edges. - let cross_ledger_schema = resolve_cross_ledger_schema_for_tx(&ledger, resolve_ctx).await?; + let cross_ledger_schema = + resolve_cross_ledger_schema_for_tx(&ledger, config.as_ref(), resolve_ctx).await?; let (view, mut ns_registry) = stage_txn(ledger, txn, ns_registry, options).await?;