diff --git a/.fluree-memory/repo.ttl b/.fluree-memory/repo.ttl index 77a939205d..105ffd13f3 100644 --- a/.fluree-memory/repo.ttl +++ b/.fluree-memory/repo.ttl @@ -1447,6 +1447,35 @@ 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-01kws806qkz7h5w609y1b0r25n a mem:Fact ; mem:content "Reasoning is rejected (400) on history/changes dataset queries (from–to time range). Root cause of the silent bug: the derived-facts overlay is computed at primary.t (latest) and spliced onto GraphRefs keyed (ledger,g_id,to_t) where as_runtime_dataset sets to_t=view.t (latest) but execution runs at hist_to, so in history mode the key never matched and derived facts were silently dropped (status 200, derived_facts 0). Guard reject_reasoning_in_history_mode() runs at BOTH dataset execute paths, mirroring prepare's executable.reasoning.modes.has_any_enabled() gate (so \"reasoning\":\"none\" is allowed). Config reasoning defaults were already skipped in history mode (dataset_builder history branch omits apply_config_defaults)." ; mem:tag "dataset" ; diff --git a/Cargo.lock b/Cargo.lock index be825af374..763dd66624 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2488,6 +2488,7 @@ dependencies = [ "mimalloc", "moka", "num-bigint", + "parking_lot", "percent-encoding", "rand 0.8.5", "reqwest", @@ -2826,6 +2827,7 @@ dependencies = [ "fluree-db-novelty", "fluree-vocab", "futures", + "parking_lot", "serde_json", "thiserror 1.0.69", "tokio", 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: 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/commit_transfer.rs b/fluree-db-api/src/commit_transfer.rs index b54c9e35de..4516b176dc 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..32aaec7480 100644 --- a/fluree-db-api/src/cross_ledger/mod.rs +++ b/fluree-db-api/src/cross_ledger/mod.rs @@ -82,3 +82,64 @@ 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); + } + // 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) { + 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 { + 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/ledger_view.rs b/fluree-db-api/src/ledger_view.rs index 5c9f843626..e0961142da 100644 --- a/fluree-db-api/src/ledger_view.rs +++ b/fluree-db-api/src/ledger_view.rs @@ -78,6 +78,12 @@ 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, + /// 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 @@ -108,6 +114,8 @@ 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), + 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(), @@ -164,6 +172,8 @@ impl LedgerView { snapshot: self.snapshot, 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/policy_builder.rs b/fluree-db-api/src/policy_builder.rs index 2616b661f5..e3d695481f 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 { // A cross-ledger `f:policySource` is only ever passed here when configured, // so its presence — even with an empty restriction set — means policy @@ -341,8 +374,48 @@ 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. 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}")))? + }; + + 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/src/policy_view.rs b/fluree-db-api/src/policy_view.rs index 9deb82b950..be4903a4a0 100644 --- a/fluree-db-api/src/policy_view.rs +++ b/fluree-db-api/src/policy_view.rs @@ -370,6 +370,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( // in D's default graph — the probe searches [0] only. &[0], restrictions, + cross_ledger_schema.clone(), ) .await?; return Ok(Some(policy_ctx)); @@ -413,13 +435,14 @@ pub async fn build_transact_policy_context( return Ok(None); } - 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/shacl_tests.rs b/fluree-db-api/src/shacl_tests.rs index 9f7a676074..50e27df355 100644 --- a/fluree-db-api/src/shacl_tests.rs +++ b/fluree-db-api/src/shacl_tests.rs @@ -4337,3 +4337,339 @@ 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"); +} + +#[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"); +} + +#[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 2609f63166..e9b5b861e8 100644 --- a/fluree-db-api/src/tx.rs +++ b/fluree-db-api/src/tx.rs @@ -373,6 +373,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 @@ -409,9 +418,8 @@ async fn resolve_cross_ledger_shapes_for_tx( Option>, fluree_db_transact::TransactError, > { - // Config is resolved once by the caller against pre-tx state and shared - // with the same-ledger SHACL policy pass. `None` means no #config on a - // fresh ledger — nothing cross-ledger to dispatch. + // 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); }; @@ -436,6 +444,38 @@ 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, + config: Option<&LedgerConfig>, + ctx: &mut crate::cross_ledger::ResolveCtx<'_>, +) -> std::result::Result< + Option>, + fluree_db_transact::TransactError, +> { + // 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); + }; + 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. /// @@ -449,6 +489,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, @@ -613,6 +667,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 @@ -638,6 +695,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)) @@ -652,6 +710,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, @@ -664,10 +723,92 @@ pub(crate) async fn apply_shacl_policy_to_staged_view( )); } - let engine = ShaclEngine::from_dbs_with_overlay(&shape_dbs, base.ledger_id()) - .await - .map_err(fluree_db_transact::TransactError::from)?; - let shacl_cache = engine.cache(); + // 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. + // 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, + 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.shared_cache(); // No config + no shapes → skip (backward compat: shapes-exist heuristic). if !has_config && shacl_cache.is_empty() { @@ -681,6 +822,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(), @@ -765,22 +907,38 @@ 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(); - // Resolve the ledger #config ONCE against pre-tx state and share it across - // both SHACL passes below (cross-ledger shapes dispatch + the per-graph - // policy in apply_shacl_policy_to_staged_view). Both read the same pre-tx - // LedgerState, so a single resolution is equivalent and avoids a second - // config-graph scan per transaction. - let tx_config = load_transaction_config(&ledger).await; - - // 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. + // 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 cross-ledger resolvers below AND the per-graph SHACL policy pass in + // apply_shacl_policy_to_staged_view — each of which would otherwise re-run + // the config-graph scan + parse independently. `None` (no #config) + // short-circuits them all to no dispatch. Fail loudly on a read error so a + // broken config can't silently skip cross-ledger governance. + 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}" + )) + })?; + let tx_config = config.clone().map(std::sync::Arc::new); + + // 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(tx_config.as_deref(), resolve_ctx).await?; + 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, config.as_ref(), resolve_ctx).await?; let (view, mut ns_registry) = stage_txn(ledger, txn, ns_registry, options).await?; @@ -891,6 +1049,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, }, tx_config, @@ -2491,6 +2650,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/validate.rs b/fluree-db-api/src/validate.rs index f65eb919f5..e87ae3f54e 100644 --- a/fluree-db-api/src/validate.rs +++ b/fluree-db-api/src/validate.rs @@ -447,9 +447,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-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 a87dc338b2..61989856d7 100644 --- a/fluree-db-api/tests/it_policy_allow.rs +++ b/fluree-db-api/tests/it_policy_allow.rs @@ -831,3 +831,238 @@ 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" + ); +} + +/// 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"); +} 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..b9866b63fa 100644 --- a/fluree-db-core/src/namespaces.rs +++ b/fluree-db-core/src/namespaces.rs @@ -252,6 +252,42 @@ 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. +/// 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 + && 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/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 dadf946049..5c759d1cce 100644 --- a/fluree-db-ledger/src/lib.rs +++ b/fluree-db-ledger/src/lib.rs @@ -94,6 +94,17 @@ 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, + /// 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 @@ -226,6 +237,10 @@ 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(), + ), + 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, @@ -243,6 +258,8 @@ 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()), + shacl_compile_cache: Arc::new(parking_lot::RwLock::new(None)), runtime_small_dicts: Arc::new(RuntimeSmallDicts::new()), head_commit_id, head_index_id, @@ -371,6 +388,8 @@ 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()), + 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 717892c6b0..bcaf87cc74 100644 --- a/fluree-db-novelty/src/lib.rs +++ b/fluree-db-novelty/src/lib.rs @@ -475,6 +475,17 @@ 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, + /// 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, /// Highest `commit_t` at which the ledger config graph (`CONFIG_GRAPH_ID`) /// received a write. Monotonic within a novelty window; resets to 0 when @@ -506,6 +517,8 @@ impl Novelty { flake_count: 0, t, epoch: 0, + schema_epoch: 0, + shacl_epoch: 0, config_write_t: 0, attachments: AttachmentNovelty::new(), fact_state: NoveltyFactState::new(), @@ -830,6 +843,8 @@ 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; + let mut shacl_touched = false; for (flake, g_id) in routed { if flake.op && self.fact_state.is_asserted(g_id, &flake) { deduped += 1; @@ -838,8 +853,19 @@ 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. 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 +952,12 @@ 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; + } + 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-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 { diff --git a/fluree-db-shacl/src/path.rs b/fluree-db-shacl/src/path.rs index 5b1704a1e4..ad728f9223 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; @@ -352,6 +352,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. @@ -359,28 +370,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 @@ -390,40 +404,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 @@ -432,6 +464,7 @@ async fn eval_sequence( db: GraphDbRef<'_>, focus: &Sid, steps: &[PropertyPath], + hierarchy: Option<&SchemaHierarchy>, ) -> Result> { let mut frontier: Vec = vec![focus.clone()]; @@ -439,7 +472,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); @@ -464,14 +497,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 e6b27d5f0f..72e93270a6 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. /// @@ -85,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 @@ -94,8 +98,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 +119,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 +132,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`]). /// @@ -159,6 +184,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,12 +211,10 @@ 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 { - cache, + cache: Arc::new(cache), hierarchy, membership_g_ids: Vec::new(), class_cache: Mutex::new(HashMap::new()), @@ -250,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; + } } } @@ -294,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 { @@ -348,6 +393,7 @@ impl ShaclEngine { membership_g_ids: &self.membership_g_ids, cache: &self.class_cache, cross_ledger, + hierarchy: self.hierarchy.as_ref(), }; // Class-target focus nodes are constant across the shape loop (same // `db`, same hierarchy), so memoize them per class: several shapes @@ -392,24 +438,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); + } } } } @@ -664,32 +710,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()); + } } } } @@ -989,13 +1029,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( @@ -1443,13 +1490,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(), @@ -1460,7 +1513,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?, ) }; @@ -1724,13 +1783,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(), @@ -1741,7 +1805,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?, ) }; @@ -1754,13 +1824,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/commit.rs b/fluree-db-transact/src/commit.rs index da7b5a8525..54e82f378f 100644 --- a/fluree-db-transact/src/commit.rs +++ b/fluree-db-transact/src/commit.rs @@ -905,6 +905,10 @@ 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, + shacl_compile_cache: base.shacl_compile_cache, runtime_small_dicts, head_commit_id: Some(commit_cid.clone()), head_index_id: base.head_index_id, diff --git a/fluree-db-transact/src/stage.rs b/fluree-db-transact/src/stage.rs index f7be8639bf..a7bec3d2e3 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( 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",