From 5196b8cd952aaf257b1117402da4ff91be50d875 Mon Sep 17 00:00:00 2001 From: bplatz Date: Wed, 1 Jul 2026 18:23:01 -0400 Subject: [PATCH 1/3] feat: resolve sh:class value-sets from the f:shapesSource graph sh:class value membership was looked up only in the focus node's own data graph, so an enumerated value-set (e.g. a controlled list of US states) stored in a separate shapes/vocabulary graph produced false violations. Membership now resolves against the union of the focus node's data graph and the f:shapesSource vocabulary graph(s): rdf:type is read across {data graph} + {membership graphs} and the subClassOf walk across {g_id=0} + {membership graphs} via a generalized rescope_to_graph. A per-transaction memo on ShaclEngine (keyed by value, class, focus graph) collapses repeated checks within one transaction; cache hits skip the range scan and its fuel charge. The membership context is threaded as an Option through validate_shape -> validate_property_shape -> validate_class_constraint; referenced/nested-shape paths pass None and keep the prior behavior. validate_view_with_shacl gains a membership_g_ids parameter fed from the resolved f:shapesSource graph ids. Same-ledger only; cross-ledger value-sets remain deferred. Adds shacl_class_value_set_in_shapes_graph (cross-graph positive, per-txn cache batch, negative) and updates the SHACL implementation guide and cookbook. --- Cargo.lock | 1 + docs/contributing/shacl-implementation.md | 24 +- docs/guides/cookbook-shacl.md | 29 ++ fluree-db-api/src/tx.rs | 10 + fluree-db-api/tests/it_config_graph.rs | 118 ++++++++ fluree-db-shacl/Cargo.toml | 1 + fluree-db-shacl/src/validate.rs | 318 ++++++++++++++++------ fluree-db-transact/src/stage.rs | 14 +- 8 files changed, 424 insertions(+), 91 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f571752126..96e5d19970 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3110,6 +3110,7 @@ dependencies = [ "fluree-db-core", "fluree-db-query", "fluree-vocab", + "parking_lot", "regex", "thiserror 1.0.69", "tokio", diff --git a/docs/contributing/shacl-implementation.md b/docs/contributing/shacl-implementation.md index 6aa22abc4e..34d0e2a565 100644 --- a/docs/contributing/shacl-implementation.md +++ b/docs/contributing/shacl-implementation.md @@ -167,14 +167,29 @@ Cost is bounded by the number of predicate-targeted shapes in the cache, not by - For each subject: fetch `rdf:type` flakes, then call `engine.validate_node(db, subject, &types)`. - Tag each returned `ValidationResult` with `graph_id = Some(g_id)` so the caller can partition reject vs warn. +### `sh:class` value membership and value-sets across graphs + +`sh:class` is validated by `validate_class_constraint` in `fluree-db-shacl/src/validate.rs`. There are **two distinct graph contexts** at play: + +- **Compilation graph** — where the shapes themselves are read from (`f:shapesSource`; see [Shape compilation from multiple graphs](#shape-compilation-from-multiple-graphs)). +- **Membership graph(s)** — where a value's `rdf:type` (and any `rdfs:subClassOf` hierarchy) is looked up to decide `sh:class` conformance. + +By default, membership is resolved against the **focus node's own data graph** (plus `g_id=0` for the `subClassOf` walk). That breaks the "shared value-set" pattern — e.g. a controlled list of US states (`ex:illinois a ex:USState`) referenced by records living in a different graph. To support it, `validate_view_with_shacl` receives the `f:shapesSource` graph ids as `membership_g_ids` and the engine unions them into the lookup: a value's `rdf:type` is read across `{focus data graph} ∪ {membership graphs}`, so the value-set vocabulary can live alongside the shapes. When `f:shapesSource` is unset (shapes in `g_id=0`), this degenerates to the historical behaviour. + +A **per-transaction memo** on the `ShaclEngine` (`class_cache`, keyed `(value, class, focus g_id)`) collapses repeated checks — inserting 100 records that all reference `ex:illinois` performs a single membership lookup. The engine is built fresh per transaction (`validate_view_with_shacl`) and shared across all focus nodes, so the memo is scoped to exactly one validation pass. Cache hits skip the range scan **and** its fuel charge, so per-transaction fuel depends on intra-transaction value repetition. + +Scope limits (as of this writing): +- **Same-ledger only.** Cross-ledger value-sets aren't wired — the cross-ledger shapes wire projects only SHACL predicates, not `rdf:type`/`subClassOf`. The unused `cross_ledger/schema_materializer.rs` (`ArtifactKind::Schema`) is the hook if that's ever needed. +- **Top-level property shapes only.** `sh:class` reached via a referenced/nested shape (`sh:and`/`or`/`xone`/`node` referencing a shape by id) passes `None` for the context and keeps the legacy data-graph lookup. + ### RDFS subclass fallback (`is_subclass_of`) -When the indexed `SchemaHierarchy` doesn't know about a `rdfs:subClassOf` edge (e.g. asserted in the same or a recent unindexed transaction), `validate_class_constraint` calls `is_subclass_of(db, start, target)` which walks `rdfs:subClassOf` upward via BFS. +When the indexed `SchemaHierarchy` doesn't know about a `rdfs:subClassOf` edge (e.g. asserted in the same or a recent unindexed transaction), `validate_class_constraint` (via `value_conforms_to_class`) calls `is_subclass_of(db, membership_g_ids, start, target)` which walks `rdfs:subClassOf` upward via BFS. Two invariants in that walk: -- **Always scope to `g_id=0`** via `rescope_to_schema_graph(db)` — schema lives in the default graph, matching how `SchemaHierarchy::from_db_root_schema` is built. Subject may be in graph G but the `subClassOf` edge must be looked up in the schema graph. -- **Preserve tracker + other `GraphDbRef` fields** — `rescope_to_schema_graph` uses `db` copy + `g_id = 0` mutation rather than `GraphDbRef::new(..)`, which would reset `tracker`, `runtime_small_dicts`, and `eager`. There's a unit test pinning this (`rescope_to_schema_graph_preserves_tracker_and_other_fields`). +- **Scope to `g_id=0` unioned with the membership graphs** via `rescope_to_graph(db, g)` — schema lives in the default graph (matching how `SchemaHierarchy::from_db_root_schema` is built), while a value-set vocabulary configured via `f:shapesSource` may declare a small class hierarchy in its own graph that must also be honoured. Subject may be in graph G but the `subClassOf` edge is looked up in the schema/vocabulary graphs. +- **Preserve tracker + other `GraphDbRef` fields** — `rescope_to_graph` uses `db` copy + `g_id` mutation rather than `GraphDbRef::new(..)`, which would reset `tracker`, `runtime_small_dicts`, and `eager`. There's a unit test pinning this (`rescope_to_graph_preserves_tracker_and_other_fields`). ## Adding a new constraint @@ -250,6 +265,7 @@ fluree.upsert(ledger, &valid_data).await.expect("must pass"); See `fluree-db-api/tests/it_config_graph.rs` for patterns that write config via TriG into the config graph, then stage transactions across multiple graphs. Examples: - `shacl_shapes_source_points_to_named_graph` — `f:shapesSource` wiring. +- `shacl_class_value_set_in_shapes_graph` — `sh:class` value-set defined in the `f:shapesSource` graph, referenced by data in another graph (cross-graph membership union + per-txn memo). - `shacl_per_graph_disable_honored` — per-graph `shaclEnabled: false`. - `shacl_per_graph_mode_warn_vs_reject` — mixed modes across graphs. - `shacl_target_subjects_of_fires_on_base_state_edge` — base-state predicate-target discovery. @@ -270,6 +286,8 @@ This is how we guard against tests that pass trivially but don't actually exerci - **`sh:uniqueLang`, `sh:languageIn`** — parsed but not evaluated. Needs language-tag metadata on flakes, which isn't yet threaded through the validation path. - **`sh:qualifiedValueShape` (+ `sh:qualifiedMinCount` / `sh:qualifiedMaxCount`)** — parsed but not evaluated. Needs recursive nested-shape counting. - **Cross-transaction shape cache** — every call to `from_dbs_with_overlay` recompiles from scratch. `ShaclCacheKey` has a `schema_epoch` field that's ready to drive a shared `Arc` cache on the connection, but nothing populates it yet. Low priority until perf regressions are observed. +- **Cross-ledger `sh:class` value-sets** — value membership is same-ledger only; a value-set defined in a different ledger isn't consulted (see the `sh:class` scope-limits note above). +- **`sh:class` in referenced/nested shapes** — value-membership context (vocabulary graphs + per-txn memo) isn't threaded through the `sh:and`/`or`/`xone`/`node` referenced-shape path; those keep the legacy data-graph lookup. ## Where to look in the code diff --git a/docs/guides/cookbook-shacl.md b/docs/guides/cookbook-shacl.md index 53e6cc8e5c..89526e6004 100644 --- a/docs/guides/cookbook-shacl.md +++ b/docs/guides/cookbook-shacl.md @@ -341,6 +341,35 @@ Semantics: - Use `f:graphSelector f:defaultGraph` to explicitly point at the default graph (same as omitting `f:shapesSource`). - `f:shapesSource` also supports **cross-ledger references** — set `f:ledger` on the inner `f:graphSource` to compile shapes from a different ledger at validation time. See [Cross-ledger governance — Cross-ledger SHACL shapes](../security/cross-ledger-policy.md#cross-ledger-shacl-shapes) for the end-to-end pattern. +## Shared value-sets with `sh:class` + +`sh:class` is the natural way to model a **controlled value-set** — e.g. a fixed list of US states — as an *extensible* enumeration: each allowed value is an instance of a class, and adding a new value means inserting one triple rather than editing the shape (contrast [`sh:in`](#enumerated-values), which bakes the list into the shape). Put the value-set vocabulary **in the same graph as the shapes** (`f:shapesSource`), and it is honoured even when the referencing records live in a different graph: + +```trig +@prefix f: . +@prefix sh: . +@prefix rdf: . +@prefix ex: . + +GRAPH { + ex:PersonShape a sh:NodeShape ; + sh:targetClass ex:Person ; + sh:property [ sh:path ex:homeState ; sh:class ex:USState ] . + + # The value-set vocabulary lives alongside the shapes. + ex:illinois a ex:USState . + ex:iowa a ex:USState . +} +``` + +With `f:shapesSource` pointing at ``, a record written to any graph passes validation when its `ex:homeState` is one of the declared states, and is rejected otherwise. Adding a state later (`ex:ohio a ex:USState`) requires no shape change. + +Semantics and limits: + +- **Membership graph = focus data graph ∪ the `f:shapesSource` graph.** A value is an instance of the class if it is typed so in either the record's own graph or the vocabulary graph. When shapes live in the default graph, this is just an ordinary local lookup. +- **Per-transaction caching.** Repeated references to the same value within one transaction are memoized — bulk-inserting many records that share a state pays the membership lookup once. +- **Same-ledger only.** The value-set must be in this ledger (any graph); cross-ledger value-sets are not yet supported. + ## Inline shapes per transaction In addition to shapes stored in a ledger, a transaction can supply diff --git a/fluree-db-api/src/tx.rs b/fluree-db-api/src/tx.rs index a7ff93e36e..32f0d187e8 100644 --- a/fluree-db-api/src/tx.rs +++ b/fluree-db-api/src/tx.rs @@ -509,6 +509,13 @@ pub(crate) async fn apply_shacl_policy_to_staged_view( let mut cl_overlay_holder = None; #[allow(unused_assignments)] let mut inline_overlay_holder = None; + // Graphs consulted for `sh:class` value membership at validation time. The + // focus node's own data graph is always consulted; these are the extra + // `f:shapesSource` vocabulary graph(s) unioned in, so a shared value-set + // (e.g. a list of US states) can live alongside the shapes rather than in + // 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; let mut shape_dbs: Vec> = if let (Some(wire), Some(staged_ns)) = (ctx.cross_ledger_shapes, ctx.staged_ns) { let bundle = wire @@ -522,6 +529,7 @@ pub(crate) async fn apply_shacl_policy_to_staged_view( base.novelty.as_ref(), bundle, )); + membership_g_ids = vec![0]; vec![fluree_db_core::GraphDbRef::new( &base.snapshot, 0u16, @@ -532,6 +540,7 @@ pub(crate) async fn apply_shacl_policy_to_staged_view( // 4b. Same-ledger path. Resolve `f:shapesSource` into // 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(); shapes_g_ids .iter() .map(|g_id| base.as_graph_db_ref(*g_id)) @@ -578,6 +587,7 @@ pub(crate) async fn apply_shacl_policy_to_staged_view( ctx.graph_sids, ctx.tracker, per_graph_policy.as_ref(), + &membership_g_ids, ) .await?; diff --git a/fluree-db-api/tests/it_config_graph.rs b/fluree-db-api/tests/it_config_graph.rs index fb54a98596..497ed513df 100644 --- a/fluree-db-api/tests/it_config_graph.rs +++ b/fluree-db-api/tests/it_config_graph.rs @@ -1201,6 +1201,124 @@ async fn shacl_shapes_source_excludes_default_graph() { ); } +/// `sh:class` value-set: the enumerated members of the target class live in +/// the **shapes-source graph**, while the referencing data lives in a different +/// (default) graph. Membership resolution unions the focus node's data graph +/// with the `f:shapesSource` vocabulary graph, so a shared value-set (here: US +/// states) is honoured cross-graph. +/// +/// Pre-fix, `validate_class_constraint` looked up each value's `rdf:type` only +/// in the focus node's own graph, so the vocabulary living in the shapes graph +/// was invisible and the valid insert would have been rejected as a false +/// violation. +#[cfg(feature = "shacl")] +#[tokio::test] +async fn shacl_class_value_set_in_shapes_graph() { + let fluree = FlureeBuilder::memory().build_memory(); + let ledger_id = "it/shacl-class-value-set:main"; + let ledger = genesis_ledger(&fluree, ledger_id); + + // Shapes graph carries BOTH the shape and the value-set vocabulary (the US + // states). Config points `f:shapesSource` at it. + let config_iri = config_graph_iri(ledger_id); + let trig = format!( + r" + @prefix f: . + @prefix rdf: . + @prefix sh: . + @prefix ex: . + + GRAPH {{ + ex:PersonShape rdf:type sh:NodeShape ; + sh:targetClass ex:Person ; + sh:property ex:pshape_state . + ex:pshape_state sh:path ex:homeState ; + sh:class ex:USState . + + ex:illinois rdf:type ex:USState . + ex:iowa rdf:type ex:USState . + }} + + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig . + f:shaclDefaults . + f:shaclEnabled true . + f:shapesSource . + rdf:type f:GraphRef ; + f:graphSource . + f:graphSelector . + }} + " + ); + + let result = fluree + .stage_owned(ledger) + .upsert_turtle(&trig) + .execute() + .await + .expect("config + shape + vocabulary write should succeed"); + let ledger = result.ledger; + + // A person in the DEFAULT graph referencing a state defined only in the + // shapes graph must PASS — membership unions the shapes-source graph. + let result = fluree + .insert( + ledger, + &json!({ + "@context": {"ex": "http://example.org/"}, + "@id": "ex:alice", + "@type": "ex:Person", + "ex:homeState": {"@id": "ex:illinois"} + }), + ) + .await + .expect( + "sh:class value-set member defined in the f:shapesSource graph must \ + satisfy the constraint even though the data is in another graph", + ); + let ledger = result.ledger; + + // Multiple subjects referencing the SAME value-set member in one + // transaction exercise the per-transaction membership memo — the second + // `(ex:illinois, ex:USState, g0)` check is a cache hit. + let result = fluree + .insert( + ledger, + &json!({ + "@context": {"ex": "http://example.org/"}, + "@graph": [ + {"@id": "ex:carol", "@type": "ex:Person", "ex:homeState": {"@id": "ex:illinois"}}, + {"@id": "ex:dave", "@type": "ex:Person", "ex:homeState": {"@id": "ex:illinois"}} + ] + }), + ) + .await + .expect("repeated value-set members in one transaction must pass (memoized)"); + let ledger = result.ledger; + + // A person referencing a non-member must be REJECTED — ex:atlantis is not + // typed ex:USState in any consulted graph. + let err = fluree + .insert( + ledger, + &json!({ + "@context": {"ex": "http://example.org/"}, + "@id": "ex:bob", + "@type": "ex:Person", + "ex:homeState": {"@id": "ex:atlantis"} + }), + ) + .await + .unwrap_err(); + assert!( + matches!( + err, + fluree_db_api::ApiError::Transact(fluree_db_transact::TransactError::ShaclViolation(_)) + ), + "value outside the sh:class value-set must be rejected: {err:?}" + ); +} + // ============================================================================= // Test 15d: Per-graph SHACL enable/disable // ============================================================================= diff --git a/fluree-db-shacl/Cargo.toml b/fluree-db-shacl/Cargo.toml index 116c3ea721..e5e7622363 100644 --- a/fluree-db-shacl/Cargo.toml +++ b/fluree-db-shacl/Cargo.toml @@ -13,6 +13,7 @@ fluree-vocab = { path = "../fluree-vocab" } async-trait = { workspace = true } thiserror = { workspace = true } regex = "1" +parking_lot = "0.12" [dev-dependencies] tokio = { workspace = true } diff --git a/fluree-db-shacl/src/validate.rs b/fluree-db-shacl/src/validate.rs index 65dd3aed4e..05b22bf311 100644 --- a/fluree-db-shacl/src/validate.rs +++ b/fluree-db-shacl/src/validate.rs @@ -20,7 +20,34 @@ use fluree_db_core::{ }; use fluree_vocab::namespaces::RDF; use fluree_vocab::rdf_names; -use std::collections::HashSet; +use parking_lot::Mutex; +use std::collections::{HashMap, HashSet}; + +/// Per-transaction memo for `sh:class` value-membership verdicts. +/// +/// Keyed by `(value node, expected class, focus data graph)`. The engine that +/// drives transaction validation is built fresh per transaction and shared +/// across every focus node, so this map is scoped to exactly one validation +/// pass and never leaks a verdict across transactions or staged states. The +/// focus data graph is part of the key because membership is resolved against +/// the *union* of the focus data graph and the vocabulary graphs — the same +/// value can legitimately resolve differently when referenced from a different +/// data graph. +type ClassMembershipCache = Mutex>; + +/// Threaded context for resolving `sh:class` value membership: the extra +/// vocabulary graphs to union into the `rdf:type` / `rdfs:subClassOf` lookup +/// (the `f:shapesSource` graph[s]) plus the per-transaction memo. `Copy` +/// because it is just two borrows passed down the validation call tree. +#[derive(Clone, Copy)] +struct ClassMembershipCtx<'a> { + /// Graphs beyond the focus node's own data graph to consult for membership. + /// Empty = legacy behaviour (focus data graph for `rdf:type`, schema graph + /// 0 for `subClassOf`). + membership_g_ids: &'a [GraphId], + /// Per-transaction memo shared across all focus nodes in one pass. + cache: &'a ClassMembershipCache, +} /// SHACL validation engine /// @@ -33,6 +60,13 @@ pub struct ShaclEngine { cache: ShaclCache, /// Schema hierarchy for RDFS reasoning (optional) hierarchy: Option, + /// Extra graphs consulted when resolving `sh:class` value membership — + /// typically the `f:shapesSource` graph(s), so a shared value-set + /// vocabulary can live alongside the shapes. The focus node's own data + /// graph is always consulted in addition to these. + membership_g_ids: Vec, + /// Per-transaction memo of resolved `sh:class` membership verdicts. + class_cache: ClassMembershipCache, } impl ShaclEngine { @@ -43,6 +77,8 @@ impl ShaclEngine { Self { cache, hierarchy: None, + membership_g_ids: Vec::new(), + class_cache: Mutex::new(HashMap::new()), } } @@ -54,6 +90,8 @@ impl ShaclEngine { Self { cache, hierarchy: Some(hierarchy), + membership_g_ids: Vec::new(), + class_cache: Mutex::new(HashMap::new()), } } @@ -96,7 +134,22 @@ impl ShaclEngine { let hierarchy = dbs.first().and_then(|d| d.snapshot.schema_hierarchy()); let cache = ShaclCache::new(key, shapes, hierarchy.as_ref()); - Ok(Self { cache, hierarchy }) + Ok(Self { + cache, + hierarchy, + membership_g_ids: Vec::new(), + class_cache: Mutex::new(HashMap::new()), + }) + } + + /// Set the additional graphs consulted when resolving `sh:class` value + /// membership (typically the `f:shapesSource` graph ids). The focus node's + /// own data graph is always consulted in addition to these, so a shared + /// value-set vocabulary can live in the shapes graph while the referencing + /// data lives in a different graph. + pub fn with_membership_graphs(mut self, g_ids: Vec) -> Self { + self.membership_g_ids = g_ids; + self } /// Build an engine by compiling shapes from a database (no overlay) @@ -195,13 +248,20 @@ impl ShaclEngine { // Collect all shapes for logical constraint resolution let all_shapes: Vec<&CompiledShape> = self.cache.all_shapes().iter().collect(); - // Validate against each shape + // Validate against each shape. `class_ctx` carries the `f:shapesSource` + // vocabulary graphs (for cross-graph `sh:class` value-sets) and the + // per-transaction membership memo down to `validate_class_constraint`. + let class_ctx = ClassMembershipCtx { + membership_g_ids: &self.membership_g_ids, + cache: &self.class_cache, + }; for shape in applicable_shapes { if shape.deactivated { continue; } - let shape_results = validate_shape(db, focus_node, shape, &all_shapes).await?; + let shape_results = + validate_shape(db, focus_node, shape, &all_shapes, Some(class_ctx)).await?; results.extend(shape_results); } @@ -229,6 +289,10 @@ impl ShaclEngine { // Collect all shapes for logical constraint resolution let all_shapes: Vec<&CompiledShape> = self.cache.all_shapes().iter().collect(); + let class_ctx = ClassMembershipCtx { + membership_g_ids: &self.membership_g_ids, + cache: &self.class_cache, + }; for shape in self.cache.all_shapes() { if shape.deactivated { continue; @@ -238,7 +302,8 @@ impl ShaclEngine { let focus_nodes = get_focus_nodes(db, shape, self.hierarchy.as_ref()).await?; for focus_node in focus_nodes { - let results = validate_shape(db, &focus_node, shape, &all_shapes).await?; + let results = + validate_shape(db, &focus_node, shape, &all_shapes, Some(class_ctx)).await?; all_results.extend(results); } } @@ -494,6 +559,7 @@ fn validate_shape<'a>( focus_node: &'a Sid, shape: &'a CompiledShape, all_shapes: &'a [&'a CompiledShape], + class_ctx: Option>, ) -> std::pin::Pin>> + Send + 'a>> { Box::pin(async move { @@ -502,7 +568,8 @@ fn validate_shape<'a>( // Validate property shapes for prop_shape in &shape.property_shapes { let prop_results = - validate_property_shape(db, focus_node, prop_shape, shape, all_shapes).await?; + validate_property_shape(db, focus_node, prop_shape, shape, all_shapes, class_ctx) + .await?; results.extend(prop_results); } @@ -769,7 +836,11 @@ fn validate_nested_shape<'a>( && nested.value_constraints.is_empty() { if let Some(ref_shape) = all_shapes.iter().find(|s| s.id == nested.id) { - return validate_shape(db, focus_node, ref_shape, all_shapes).await; + // `sh:class` reached via a referenced/nested shape keeps the + // legacy data-graph lookup (no `f:shapesSource` vocabulary union + // and no shared memo) — the value-set feature targets top-level + // property shapes. + return validate_shape(db, focus_node, ref_shape, all_shapes, None).await; } // Shape not found and no inline constraints — treat as unresolved. // Return a violation to prevent sh:or from being trivially true. @@ -880,6 +951,7 @@ async fn validate_property_shape<'a>( prop_shape: &PropertyShape, parent_shape: &'a CompiledShape, all_shapes: &'a [&'a CompiledShape], + class_ctx: Option>, ) -> Result> { let mut results = Vec::new(); @@ -935,7 +1007,7 @@ async fn validate_property_shape<'a>( } Constraint::Class(expected_class) => { let class_violations = - validate_class_constraint(db, &values, expected_class).await?; + validate_class_constraint(db, &values, expected_class, class_ctx).await?; for violation in class_violations { results.push(ValidationResult { focus_node: focus_node.clone(), @@ -1428,25 +1500,35 @@ fn validate_pair_constraint( /// Validate `sh:class` for a set of property values. /// /// For each value (which must be a `Ref` — a literal can never be an instance -/// of a class), look up `rdf:type` flakes and check conformance via: -/// 1. Direct match: value's type == `expected_class` -/// 2. Indexed-schema hierarchy: type is a descendant of `expected_class` per -/// the `SchemaHierarchy` cached on the snapshot (fast, but only reflects -/// already-indexed subclass relations) -/// 3. Live subclass walk: BFS upward over `rdfs:subClassOf` via `db.range()`, -/// which sees novelty-added relations that aren't yet in the hierarchy +/// of a class), resolve whether it is (transitively) an instance of +/// `expected_class`. Membership is looked up across the **union** of the focus +/// node's own data graph and any `f:shapesSource` vocabulary graphs threaded in +/// via `class_ctx`, so a shared value-set (e.g. a list of US states defined in +/// the shapes graph) is discoverable even when the referencing records live in +/// a different graph. Resolution uses, in order: +/// 1. Direct / indexed-hierarchy match against the `SchemaHierarchy`. +/// 2. Live `rdfs:subClassOf` walk (schema graph 0 unioned with the vocabulary +/// graphs) for novelty-added or vocabulary-local relations. /// -/// A value with no conforming `rdf:type` is a violation. +/// When `class_ctx` is present its per-transaction memo collapses repeated +/// `(value, class, focus graph)` checks to a single lookup. A value with no +/// conforming `rdf:type` is a violation. async fn validate_class_constraint( db: GraphDbRef<'_>, values: &[FlakeValue], expected_class: &Sid, + class_ctx: Option>, ) -> Result> { let mut out = Vec::new(); if values.is_empty() { return Ok(out); } + // Extra vocabulary graphs (f:shapesSource) unioned into the membership + // lookup. Empty when no context is threaded (e.g. `sh:class` reached via a + // referenced shape), which preserves the historical data-graph-only lookup. + let membership_g_ids: &[GraphId] = class_ctx.map(|c| c.membership_g_ids).unwrap_or(&[]); + // Fast-path acceptable set: expected_class + its descendants per the // indexed-schema hierarchy. Misses novelty-added subclass relations; // we fall through to `is_subclass_of` (a db walk) for those. @@ -1459,7 +1541,6 @@ async fn validate_class_constraint( } } - let rdf_type = Sid::new(RDF, rdf_names::TYPE); for value in values { let value_ref = match value { FlakeValue::Ref(r) => r, @@ -1476,35 +1557,33 @@ async fn validate_class_constraint( } }; - let type_flakes = db - .range( - IndexType::Spot, - RangeTest::Eq, - RangeMatch::subject_predicate(value_ref.clone(), rdf_type.clone()), - ) - .await?; - - let value_types: Vec = type_flakes - .iter() - .filter_map(|f| match &f.o { - FlakeValue::Ref(t) => Some(t.clone()), - _ => None, - }) - .collect(); - - // Fast path: any indexed-hierarchy match. - let mut conforms = value_types.iter().any(|t| hierarchy_accepted.contains(t)); + // Per-transaction memo keyed on (value, class, focus data graph). The + // guard is dropped before any `.await` so the validation future stays + // `Send`. Cache hits also skip the range scan (and its fuel charge), so + // per-transaction fuel depends on intra-transaction value repetition. + let cache_key = (value_ref.clone(), expected_class.clone(), db.g_id); + let cached: Option = class_ctx.and_then(|c| { + let guard = c.cache.lock(); + guard.get(&cache_key).copied() + }); - // Slow path: walk the live `rdfs:subClassOf` graph (covers novelty-added - // subclass relations that haven't made it into the hierarchy yet). - if !conforms { - for t in &value_types { - if is_subclass_of(db, t, expected_class).await? { - conforms = true; - break; + let conforms = match cached { + Some(hit) => hit, + None => { + let computed = value_conforms_to_class( + db, + membership_g_ids, + value_ref, + &hierarchy_accepted, + expected_class, + ) + .await?; + if let Some(c) = class_ctx { + c.cache.lock().insert(cache_key, computed); } + computed } - } + }; if !conforms { out.push(ConstraintViolation { @@ -1521,47 +1600,113 @@ async fn validate_class_constraint( Ok(out) } -/// Rescope a `GraphDbRef` to the default (schema) graph while preserving -/// every other field — tracker, runtime_small_dicts, eager, overlay, -/// snapshot, and t. +/// Resolve whether `value_ref` is (transitively) an instance of +/// `expected_class`, consulting the focus node's data graph unioned with +/// `membership_g_ids` (the `f:shapesSource` vocabulary graph[s]). +async fn value_conforms_to_class( + db: GraphDbRef<'_>, + membership_g_ids: &[GraphId], + value_ref: &Sid, + hierarchy_accepted: &HashSet, + expected_class: &Sid, +) -> Result { + let rdf_type = Sid::new(RDF, rdf_names::TYPE); + + // Graphs to consult for the value's `rdf:type`: the focus data graph plus + // the configured vocabulary graphs, de-duplicated. + let mut lookup_g_ids: Vec = vec![db.g_id]; + for &g in membership_g_ids { + if !lookup_g_ids.contains(&g) { + lookup_g_ids.push(g); + } + } + + let mut value_types: Vec = Vec::new(); + for &g in &lookup_g_ids { + let gdb = rescope_to_graph(db, g); + let type_flakes = gdb + .range( + IndexType::Spot, + RangeTest::Eq, + RangeMatch::subject_predicate(value_ref.clone(), rdf_type.clone()), + ) + .await?; + for f in &type_flakes { + if let FlakeValue::Ref(t) = &f.o { + if !value_types.contains(t) { + value_types.push(t.clone()); + } + } + } + } + + // Fast path: any indexed-hierarchy match. + if value_types.iter().any(|t| hierarchy_accepted.contains(t)) { + return Ok(true); + } + + // Slow path: walk the live `rdfs:subClassOf` graph (schema graph 0 unioned + // with the vocabulary graphs) for novelty-added / vocabulary-local relations. + for t in &value_types { + if is_subclass_of(db, membership_g_ids, t, expected_class).await? { + return Ok(true); + } + } + + Ok(false) +} + +/// Rescope a `GraphDbRef` to a specific graph while preserving every other +/// field — tracker, runtime_small_dicts, eager, overlay, snapshot, and t. /// -/// **Do not replace this with `GraphDbRef::new(db.snapshot, 0, db.overlay, db.t)`.** -/// That constructor resets `tracker` (and `runtime_small_dicts`, `eager`) to -/// their defaults, which silently disables fuel accounting on any schema -/// walks a tracked validation is running. The copy-and-mutate-`g_id` pattern -/// below leans on `GraphDbRef: Copy` to carry every field through unchanged. -fn rescope_to_schema_graph(db: GraphDbRef<'_>) -> GraphDbRef<'_> { - let mut schema_db = db; - schema_db.g_id = 0; - schema_db +/// **Do not replace this with `GraphDbRef::new(..)`.** That constructor resets +/// `tracker` (and `runtime_small_dicts`, `eager`) to their defaults, silently +/// disabling fuel accounting on any walk a tracked validation is running. The +/// copy-and-mutate-`g_id` pattern leans on `GraphDbRef: Copy` to carry every +/// field through unchanged. +fn rescope_to_graph(db: GraphDbRef<'_>, g_id: GraphId) -> GraphDbRef<'_> { + let mut scoped = db; + scoped.g_id = g_id; + scoped } /// BFS upward from `start` over `rdfs:subClassOf`, returning true if `target` /// is reachable. /// -/// The walk is scoped to the **default graph** (`g_id = 0`), not the caller's -/// graph. Rationale: `rdfs:subClassOf` is schema-level data — the indexed -/// `SchemaHierarchy` is built exclusively from the default graph, and this -/// fallback walk must match that semantic. Otherwise a subject being validated -/// in graph `G` would not see a subclass edge asserted in the schema graph. +/// The walk consults the **schema graph** (`g_id = 0`) unioned with any +/// `membership_g_ids` (the `f:shapesSource` vocabulary graph[s]). Rationale: +/// `rdfs:subClassOf` is schema-level data — the indexed `SchemaHierarchy` is +/// built from the default graph, and this fallback must match that semantic, +/// while a value-set vocabulary configured via `f:shapesSource` may define a +/// small class hierarchy in its own graph that must also be honoured. /// -/// Uses `db.range()` via a rebuilt `GraphDbRef` so novelty-added subclass +/// Uses `db.range()` via rescoped `GraphDbRef`s so novelty-added subclass /// relations are visible — the indexed `SchemaHierarchy` can lag behind. /// /// Returns `Ok(true)` immediately when `start == target` (every class is a /// subclass of itself for the purposes of `sh:class`). Cycle-guarded via a /// `visited` set, since `rdfs:subClassOf` graphs in user data can be malformed. -async fn is_subclass_of(db: GraphDbRef<'_>, start: &Sid, target: &Sid) -> Result { +async fn is_subclass_of( + db: GraphDbRef<'_>, + membership_g_ids: &[GraphId], + start: &Sid, + target: &Sid, +) -> Result { use std::collections::VecDeque; if start == target { return Ok(true); } - // Schema relations live in g_id=0. Use `rescope_to_schema_graph` so the - // caller's tracker and other per-validation context survive — see the - // function's docstring for why `GraphDbRef::new(..)` must NOT be used. - let schema_db = rescope_to_schema_graph(db); + // Graphs holding subClassOf edges: the schema graph plus the vocabulary + // graphs, de-duplicated. Rescoping preserves the caller's tracker — see + // `rescope_to_graph` for why `GraphDbRef::new(..)` must NOT be used. + let mut walk_g_ids: Vec = vec![0]; + for &g in membership_g_ids { + if !walk_g_ids.contains(&g) { + walk_g_ids.push(g); + } + } let sub_class_of = Sid::new(fluree_vocab::namespaces::RDFS, "subClassOf"); let mut visited: HashSet = HashSet::new(); @@ -1570,20 +1715,23 @@ async fn is_subclass_of(db: GraphDbRef<'_>, start: &Sid, target: &Sid) -> Result queue.push_back(start.clone()); while let Some(current) = queue.pop_front() { - let flakes = schema_db - .range( - IndexType::Spot, - RangeTest::Eq, - RangeMatch::subject_predicate(current, sub_class_of.clone()), - ) - .await?; - for f in flakes { - if let FlakeValue::Ref(parent) = &f.o { - if parent == target { - return Ok(true); - } - if visited.insert(parent.clone()) { - queue.push_back(parent.clone()); + for &g in &walk_g_ids { + let scoped = rescope_to_graph(db, g); + let flakes = scoped + .range( + IndexType::Spot, + RangeTest::Eq, + RangeMatch::subject_predicate(current.clone(), sub_class_of.clone()), + ) + .await?; + for f in flakes { + if let FlakeValue::Ref(parent) = &f.o { + if parent == target { + return Ok(true); + } + if visited.insert(parent.clone()) { + queue.push_back(parent.clone()); + } } } } @@ -1656,13 +1804,13 @@ mod tests { use crate::cache::ShaclCacheKey; use fluree_db_core::GraphDbRef; - /// Regression: `rescope_to_schema_graph` — used by the `sh:class` fallback - /// subclass walk — must preserve the caller's tracker (and other - /// per-validation context). A naive rebuild via `GraphDbRef::new(..)` + /// Regression: `rescope_to_graph` — used by the `sh:class` value-membership + /// and fallback subclass walks — must preserve the caller's tracker (and + /// other per-validation context). A naive rebuild via `GraphDbRef::new(..)` /// would silently drop `tracker`, disabling fuel accounting on tracked /// validations. This pins the invariant. #[test] - fn rescope_to_schema_graph_preserves_tracker_and_other_fields() { + fn rescope_to_graph_preserves_tracker_and_other_fields() { use fluree_db_core::tracking::TrackingOptions; use fluree_db_core::{LedgerSnapshot, NoOverlay, Tracker}; @@ -1685,7 +1833,7 @@ mod tests { ); assert!(db.eager, "precondition: caller's db is eager"); - let schema_db = super::rescope_to_schema_graph(db); + let schema_db = super::rescope_to_graph(db, 0); assert_eq!(schema_db.g_id, 0, "schema walk must run in default graph"); assert!( diff --git a/fluree-db-transact/src/stage.rs b/fluree-db-transact/src/stage.rs index 5fd424803f..fdba2d77ab 100644 --- a/fluree-db-transact/src/stage.rs +++ b/fluree-db-transact/src/stage.rs @@ -2227,13 +2227,18 @@ pub async fn validate_view_with_shacl( graph_sids: Option<&HashMap>, tracker: Option<&fluree_db_core::Tracker>, per_graph_policy: Option<&HashMap>, + membership_g_ids: &[GraphId], ) -> Result { // Fast path: if there are no SHACL shapes, elide validation entirely. if shacl_cache.is_empty() { return Ok(ShaclValidationOutcome::default()); } - let engine = ShaclEngine::new(shacl_cache.clone()); + // `membership_g_ids` (the `f:shapesSource` graph[s]) are unioned into + // `sh:class` value-membership resolution so a shared value-set vocabulary + // can live alongside the shapes rather than in each data graph. + let engine = + ShaclEngine::new(shacl_cache.clone()).with_membership_graphs(membership_g_ids.to_vec()); let enabled_graphs: Option> = per_graph_policy.map(|m| m.keys().copied().collect()); let report = @@ -2269,8 +2274,11 @@ pub async fn validate_view_with_shacl( /// Validate staged nodes against SHACL shapes, per graph. /// /// Groups staged subjects by their graph and validates each group with a -/// `GraphDbRef` targeting the correct `g_id`. Shape compilation stays at -/// g_id=0 (shapes are schema-level definitions in the default graph). +/// `GraphDbRef` targeting the correct `g_id`. Shape *compilation* graph is +/// chosen upstream by `f:shapesSource` (see `apply_shacl_policy_to_staged_view`) +/// — this loop only drives per-graph *validation*. `sh:class` value membership +/// additionally consults the engine's `membership_g_ids` (the `f:shapesSource` +/// vocabulary graph[s]) unioned with each focus node's own data graph. /// /// When `graph_sids` is `None` (e.g., commit-transfer path where the txn /// context is unavailable), falls back to validating all subjects against From 79fcd34c8675d42974f0640c80fdfc4f064227cb Mon Sep 17 00:00:00 2001 From: bplatz Date: Wed, 1 Jul 2026 19:55:01 -0400 Subject: [PATCH 2/3] feat: resolve cross-ledger sh:class value-sets against the model ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends sh:class value-set membership to the cross-ledger case: when f:shapesSource points at a model ledger M (via f:ledger), the controlled vocabulary lives in M alongside the shapes. M's instance typings (ex:illinois a ex:USState) are ABox and are not carried in the shapes wire, so membership is resolved by querying M live. stage_with_config_shacl opens M at the resolved t (latest committed at transaction time) and threads a CrossLedgerMembership { model_db, data_ns_map } down to validate_class_constraint. On a local miss, value_conforms_cross_ledger decodes the value/class Sids to IRIs via D's staged namespace map — the base snapshot alone can't decode namespaces staged this transaction — then re-encodes against M (whose split mode may differ) and does the rdf:type + subClassOf lookup in M's term space. Well-known predicate codes are global, so only user IRIs are translated. The per-transaction memo covers cross-ledger verdicts too. Same-ledger behavior is unchanged; referenced/nested-shape sh:class keeps the legacy local lookup. Adds an end-to-end two-ledger test and updates the SHACL implementation guide and cookbook. --- docs/contributing/shacl-implementation.md | 9 +- docs/guides/cookbook-shacl.md | 2 +- fluree-db-api/src/commit_transfer.rs | 1 + fluree-db-api/src/tx.rs | 70 +++++++++++ fluree-db-api/tests/it_shapes_cross_ledger.rs | 105 ++++++++++++++++ fluree-db-shacl/src/lib.rs | 2 +- fluree-db-shacl/src/validate.rs | 119 +++++++++++++++++- fluree-db-transact/src/stage.rs | 23 +++- 8 files changed, 318 insertions(+), 13 deletions(-) diff --git a/docs/contributing/shacl-implementation.md b/docs/contributing/shacl-implementation.md index 34d0e2a565..cbc5b8b4bc 100644 --- a/docs/contributing/shacl-implementation.md +++ b/docs/contributing/shacl-implementation.md @@ -178,9 +178,11 @@ By default, membership is resolved against the **focus node's own data graph** ( A **per-transaction memo** on the `ShaclEngine` (`class_cache`, keyed `(value, class, focus g_id)`) collapses repeated checks — inserting 100 records that all reference `ex:illinois` performs a single membership lookup. The engine is built fresh per transaction (`validate_view_with_shacl`) and shared across all focus nodes, so the memo is scoped to exactly one validation pass. Cache hits skip the range scan **and** its fuel charge, so per-transaction fuel depends on intra-transaction value repetition. +**Cross-ledger value-sets.** When `f:shapesSource` is cross-ledger (`f:ledger`), the controlled vocabulary lives in the *model ledger* M alongside the shapes. Because M's ABox (`ex:illinois a ex:USState`) is *not* carried in the shapes wire (which projects only SHACL predicates + `rdf:list` internals), membership is resolved by **querying M live**: `stage_with_config_shacl` opens M at the resolved `t` (`load_graph_db_at_t`) and threads a `CrossLedgerMembership { model_db, data_ns_map }` down to `validate_class_constraint`. On a local miss, `value_conforms_cross_ledger` decodes the value/class Sids to IRIs via D's staged namespace map (`data_ns_map` — the base snapshot alone can't decode namespaces staged this txn), re-encodes them against M (whose split mode may differ), then does the `rdf:type` + `subClassOf` lookup in M's term space. Well-known predicates (`rdf:type`, `rdfs:subClassOf`) share global namespace codes, so only the user IRIs are translated. The per-txn memo covers cross-ledger verdicts too. M is pinned at the resolved `t` (latest at tx time), consistent with cross-ledger shapes. + Scope limits (as of this writing): -- **Same-ledger only.** Cross-ledger value-sets aren't wired — the cross-ledger shapes wire projects only SHACL predicates, not `rdf:type`/`subClassOf`. The unused `cross_ledger/schema_materializer.rs` (`ArtifactKind::Schema`) is the hook if that's ever needed. -- **Top-level property shapes only.** `sh:class` reached via a referenced/nested shape (`sh:and`/`or`/`xone`/`node` referencing a shape by id) passes `None` for the context and keeps the legacy data-graph lookup. +- **Top-level property shapes only.** `sh:class` reached via a referenced/nested shape (`sh:and`/`or`/`xone`/`node` referencing a shape by id) passes `None` for the context and keeps the legacy data-graph lookup (no vocabulary union, no cross-ledger, no memo). +- **`f:atT` / trust dimensions** on the source are still rejected globally, so a cross-ledger value-set tracks M's latest committed state. ### RDFS subclass fallback (`is_subclass_of`) @@ -286,8 +288,7 @@ This is how we guard against tests that pass trivially but don't actually exerci - **`sh:uniqueLang`, `sh:languageIn`** — parsed but not evaluated. Needs language-tag metadata on flakes, which isn't yet threaded through the validation path. - **`sh:qualifiedValueShape` (+ `sh:qualifiedMinCount` / `sh:qualifiedMaxCount`)** — parsed but not evaluated. Needs recursive nested-shape counting. - **Cross-transaction shape cache** — every call to `from_dbs_with_overlay` recompiles from scratch. `ShaclCacheKey` has a `schema_epoch` field that's ready to drive a shared `Arc` cache on the connection, but nothing populates it yet. Low priority until perf regressions are observed. -- **Cross-ledger `sh:class` value-sets** — value membership is same-ledger only; a value-set defined in a different ledger isn't consulted (see the `sh:class` scope-limits note above). -- **`sh:class` in referenced/nested shapes** — value-membership context (vocabulary graphs + per-txn memo) isn't threaded through the `sh:and`/`or`/`xone`/`node` referenced-shape path; those keep the legacy data-graph lookup. +- **`sh:class` in referenced/nested shapes** — value-membership context (vocabulary graphs, cross-ledger model, per-txn memo) isn't threaded through the `sh:and`/`or`/`xone`/`node` referenced-shape path; those keep the legacy data-graph lookup. ## Where to look in the code diff --git a/docs/guides/cookbook-shacl.md b/docs/guides/cookbook-shacl.md index 89526e6004..7fe09b18b5 100644 --- a/docs/guides/cookbook-shacl.md +++ b/docs/guides/cookbook-shacl.md @@ -368,7 +368,7 @@ Semantics and limits: - **Membership graph = focus data graph ∪ the `f:shapesSource` graph.** A value is an instance of the class if it is typed so in either the record's own graph or the vocabulary graph. When shapes live in the default graph, this is just an ordinary local lookup. - **Per-transaction caching.** Repeated references to the same value within one transaction are memoized — bulk-inserting many records that share a state pays the membership lookup once. -- **Same-ledger only.** The value-set must be in this ledger (any graph); cross-ledger value-sets are not yet supported. +- **Cross-ledger too.** When `f:shapesSource` is cross-ledger (`f:ledger`), the controlled vocabulary lives in the model ledger alongside the shapes, and membership is resolved by querying that model ledger live (pinned at its latest committed state at transaction time). So a shared governance model can hold shapes *and* the value-sets they reference, and many data ledgers can point at it. ## Inline shapes per transaction diff --git a/fluree-db-api/src/commit_transfer.rs b/fluree-db-api/src/commit_transfer.rs index 5ec696c67d..4f89070337 100644 --- a/fluree-db-api/src/commit_transfer.rs +++ b/fluree-db-api/src/commit_transfer.rs @@ -467,6 +467,7 @@ impl Fluree { // construct; commit replay carries no // `opts` payload. inline_shape_bundle: None, + cross_ledger_membership: None, }, ) .await diff --git a/fluree-db-api/src/tx.rs b/fluree-db-api/src/tx.rs index 32f0d187e8..a2594df086 100644 --- a/fluree-db-api/src/tx.rs +++ b/fluree-db-api/src/tx.rs @@ -276,6 +276,14 @@ pub(crate) struct StagedShaclContext<'a> { /// additively. Inline shapes do not persist into the ledger. pub inline_shape_bundle: 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 + /// resolved `t` plus D's staged namespace map (needed to translate D-term + /// Sids into M's term space); `validate_class_constraint` consults it on + /// demand after a local miss. + pub cross_ledger_membership: Option>, } /// Inspect the data ledger's resolved config and, when @@ -588,6 +596,7 @@ pub(crate) async fn apply_shacl_policy_to_staged_view( ctx.tracker, per_graph_policy.as_ref(), &membership_g_ids, + ctx.cross_ledger_membership, ) .await?; @@ -702,6 +711,65 @@ async fn stage_with_config_shacl( .map(|(&g_id, iri)| (g_id, ns_registry.sid_for_iri(iri))) .collect(); + // Cross-ledger `sh:class` value-sets: when f:shapesSource is cross-ledger, + // open the model ledger M live at the resolved t and expose a GraphDbRef + // into its value-set graph (the shapes-source graph, where the controlled + // vocabulary lives alongside the shapes). `validate_class_constraint` + // consults it on demand — memoized — after a local membership miss. The + // owned handle and D's namespace map (for term translation) are held across + // the validation await below. + let cross_ledger_model_db = match cross_ledger_shapes.as_deref() { + Some(resolved) => Some( + resolve_ctx + .fluree + .load_graph_db_at_t(&resolved.model_ledger_id, resolved.resolved_t) + .await + .map_err(|e| { + fluree_db_transact::TransactError::Parse(format!( + "failed to open cross-ledger value-set model {} at t={}: {e}", + resolved.model_ledger_id, resolved.resolved_t + )) + })?, + ), + None => None, + }; + // D's namespace codes → IRI prefixes (base + this transaction's staged + // allocations). Needed to decode a staged value Sid to its IRI before + // re-encoding against M — the staged base snapshot alone can't decode a + // namespace introduced this transaction. + let cross_ledger_data_ns_map: Option> = + cross_ledger_model_db.as_ref().map(|_| { + ns_registry + .all_codes() + .into_iter() + .filter_map(|code| ns_registry.get_prefix(code).map(|p| (code, p.to_string()))) + .collect() + }); + let cross_ledger_membership = match ( + &cross_ledger_model_db, + &cross_ledger_data_ns_map, + cross_ledger_shapes.as_deref(), + ) { + (Some(m_db), Some(ns_map), Some(resolved)) => { + crate::cross_ledger::resolve_selector_g_id(&m_db.snapshot, &resolved.graph_iri) + .map_err(|e| { + fluree_db_transact::TransactError::Parse(format!( + "cross-ledger value-set graph resolution failed: {e}" + )) + })? + .map(|g_id| fluree_db_shacl::CrossLedgerMembership { + model_db: fluree_db_core::GraphDbRef::new( + &m_db.snapshot, + g_id, + m_db.overlay.as_ref(), + m_db.t, + ), + data_ns_map: ns_map, + }) + } + _ => None, + }; + apply_shacl_policy_to_staged_view( &view, StagedShaclContext { @@ -716,6 +784,7 @@ async fn stage_with_config_shacl( }), staged_ns: cross_ledger_shapes.as_deref().map(|_| &ns_registry), inline_shape_bundle, + cross_ledger_membership, }, ) .await?; @@ -2311,6 +2380,7 @@ impl crate::Fluree { // today — inline SHACL flows in over the JSON // transaction path. Wireable later if needed. inline_shape_bundle: None, + cross_ledger_membership: None, }, ) .await diff --git a/fluree-db-api/tests/it_shapes_cross_ledger.rs b/fluree-db-api/tests/it_shapes_cross_ledger.rs index 6290682a8a..222a6480ea 100644 --- a/fluree-db-api/tests/it_shapes_cross_ledger.rs +++ b/fluree-db-api/tests/it_shapes_cross_ledger.rs @@ -183,3 +183,108 @@ async fn data_ledger_tx_passes_when_cross_ledger_shape_satisfied() { .await .expect("valid Person under cross-ledger shape must be accepted"); } + +/// Cross-ledger `sh:class` value-set: model ledger M holds both the shape +/// (`sh:class ex:USState`) and the controlled vocabulary (`ex:illinois a +/// ex:USState`). Data ledger D references those value-set members. The shape is +/// enforced cross-ledger (via the wire); membership is resolved by querying M +/// live — the vocabulary is ABox and is NOT carried in the shapes wire. +#[tokio::test] +async fn cross_ledger_sh_class_value_set_resolved_against_model_ledger() { + let fluree = FlureeBuilder::memory().build_memory(); + + let model_id = "test/cross-ledger-shapes/vocab-model:main"; + let model = genesis_ledger(&fluree, model_id); + + let shapes_graph_iri = "http://example.org/governance/shapes"; + fluree + .stage_owned(model) + .upsert_turtle(&format!( + r" + @prefix sh: . + @prefix rdf: . + @prefix ex: . + + GRAPH <{shapes_graph_iri}> {{ + ex:PersonShape + rdf:type sh:NodeShape ; + sh:targetClass ex:Person ; + sh:property ex:pshape_state . + ex:pshape_state + sh:path ex:homeState ; + sh:class ex:USState . + + ex:illinois rdf:type ex:USState . + ex:iowa rdf:type ex:USState . + }} + " + )) + .execute() + .await + .expect("seed M shapes + controlled vocabulary"); + + let data_id = "test/cross-ledger-shapes/vocab-data:main"; + let data = genesis_ledger(&fluree, data_id); + + let config_iri = config_graph_iri(data_id); + let r1 = fluree + .stage_owned(data) + .upsert_turtle(&format!( + r" + @prefix f: . + @prefix rdf: . + + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig . + f:shaclDefaults . + f:shaclEnabled true . + f:shapesSource . + rdf:type f:GraphRef ; + f:graphSource . + f:ledger <{model_id}> ; + f:graphSelector <{shapes_graph_iri}> . + }} + " + )) + .execute() + .await + .expect("seed D cross-ledger SHACL config"); + let data = r1.ledger; + + // ex:alice references ex:illinois — a value-set member defined ONLY in M. + // Membership must resolve against M's live vocabulary. + let ok = fluree + .insert( + data, + &json!({ + "@context": {"ex": "http://example.org/ns/"}, + "@id": "ex:alice", + "@type": "ex:Person", + "ex:homeState": {"@id": "ex:illinois"} + }), + ) + .await + .expect("value-set member defined in the model ledger must satisfy sh:class"); + let data = ok.ledger; + + // ex:bob references ex:atlantis — not a member of the value-set in M. + let err = fluree + .insert( + data, + &json!({ + "@context": {"ex": "http://example.org/ns/"}, + "@id": "ex:bob", + "@type": "ex:Person", + "ex:homeState": {"@id": "ex:atlantis"} + }), + ) + .await + .expect_err("non-member value must be rejected by cross-ledger sh:class"); + assert!( + matches!( + err, + fluree_db_api::ApiError::Transact(fluree_db_transact::TransactError::ShaclViolation(_)) + ), + "expected ShaclViolation for non-member, got: {err:?}" + ); +} diff --git a/fluree-db-shacl/src/lib.rs b/fluree-db-shacl/src/lib.rs index b1c299e740..f16f7dfe51 100644 --- a/fluree-db-shacl/src/lib.rs +++ b/fluree-db-shacl/src/lib.rs @@ -77,7 +77,7 @@ pub use cache::{ShaclCache, ShaclCacheKey}; pub use compile::{CompiledShape, PropertyShape, Severity, ShapeId, TargetType}; pub use constraints::Constraint; pub use error::{Result, ShaclError}; -pub use validate::{ShaclEngine, ValidationReport, ValidationResult}; +pub use validate::{CrossLedgerMembership, ShaclEngine, ValidationReport, ValidationResult}; /// SHACL namespace code (re-exported from fluree-vocab) pub use fluree_vocab::namespaces::SHACL; diff --git a/fluree-db-shacl/src/validate.rs b/fluree-db-shacl/src/validate.rs index 05b22bf311..b5013666a9 100644 --- a/fluree-db-shacl/src/validate.rs +++ b/fluree-db-shacl/src/validate.rs @@ -39,6 +39,21 @@ type ClassMembershipCache = Mutex>; /// vocabulary graphs to union into the `rdf:type` / `rdfs:subClassOf` lookup /// (the `f:shapesSource` graph[s]) plus the per-transaction memo. `Copy` /// because it is just two borrows passed down the validation call tree. +/// A live cross-ledger membership source for `sh:class`: a handle into the +/// model ledger M holding the controlled vocabulary, plus the data ledger's +/// namespace map (code → IRI prefix, including this transaction's staged +/// allocations) needed to translate D-term Sids into M's term space. +#[derive(Clone, Copy)] +pub struct CrossLedgerMembership<'a> { + /// `GraphDbRef` into M's value-set graph at the resolved `t`. + pub model_db: GraphDbRef<'a>, + /// D's namespace codes → IRI prefixes (base + this transaction's staged + /// allocations). Used to decode a D-term Sid to its full IRI before + /// re-encoding it against M (whose split mode may differ), because the + /// staged base snapshot alone can't decode namespaces introduced this txn. + pub data_ns_map: &'a HashMap, +} + #[derive(Clone, Copy)] struct ClassMembershipCtx<'a> { /// Graphs beyond the focus node's own data graph to consult for membership. @@ -47,6 +62,10 @@ struct ClassMembershipCtx<'a> { membership_g_ids: &'a [GraphId], /// Per-transaction memo shared across all focus nodes in one pass. cache: &'a ClassMembershipCache, + /// Cross-ledger value-set source (model ledger M holding the controlled + /// vocabulary), when `f:shapesSource` is cross-ledger. Consulted on demand + /// for `sh:class` membership after the local lookup misses. + cross_ledger: Option>, } /// SHACL validation engine @@ -192,6 +211,7 @@ impl ShaclEngine { db: GraphDbRef<'_>, focus_node: &Sid, node_types: &[Sid], + cross_ledger: Option>, ) -> Result { let mut results = Vec::new(); @@ -254,6 +274,7 @@ impl ShaclEngine { let class_ctx = ClassMembershipCtx { membership_g_ids: &self.membership_g_ids, cache: &self.class_cache, + cross_ledger, }; for shape in applicable_shapes { if shape.deactivated { @@ -279,7 +300,7 @@ impl ShaclEngine { node_types: &[Sid], ) -> Result { let db = GraphDbRef::new(snapshot, g_id, &NoOverlay, snapshot.t); - self.validate_node(db, focus_node, node_types).await + self.validate_node(db, focus_node, node_types, None).await } /// Validate all focus nodes targeted by shapes @@ -292,6 +313,9 @@ impl ShaclEngine { let class_ctx = ClassMembershipCtx { membership_g_ids: &self.membership_g_ids, cache: &self.class_cache, + // Full-db validation (`validate_all`) has no cross-ledger model + // context; `sh:class` uses the local lookup only. + cross_ledger: None, }; for shape in self.cache.all_shapes() { if shape.deactivated { @@ -416,7 +440,7 @@ impl ShaclEngine { .collect(); // Validate this node against applicable shapes - let report = self.validate_node(db, subject, &node_types).await?; + let report = self.validate_node(db, subject, &node_types, None).await?; all_results.extend(report.results); } @@ -1528,6 +1552,8 @@ async fn validate_class_constraint( // lookup. Empty when no context is threaded (e.g. `sh:class` reached via a // referenced shape), which preserves the historical data-graph-only lookup. let membership_g_ids: &[GraphId] = class_ctx.map(|c| c.membership_g_ids).unwrap_or(&[]); + // Cross-ledger value-set source, if any. + let cross_ledger: Option> = class_ctx.and_then(|c| c.cross_ledger); // Fast-path acceptable set: expected_class + its descendants per the // indexed-schema hierarchy. Misses novelty-added subclass relations; @@ -1573,6 +1599,7 @@ async fn validate_class_constraint( let computed = value_conforms_to_class( db, membership_g_ids, + cross_ledger, value_ref, &hierarchy_accepted, expected_class, @@ -1602,10 +1629,14 @@ async fn validate_class_constraint( /// Resolve whether `value_ref` is (transitively) an instance of /// `expected_class`, consulting the focus node's data graph unioned with -/// `membership_g_ids` (the `f:shapesSource` vocabulary graph[s]). +/// `membership_g_ids` (the `f:shapesSource` vocabulary graph[s]). When a +/// cross-ledger model handle is present, it is consulted on demand only after +/// the local lookup misses (so locally-typed values never touch the model +/// ledger). async fn value_conforms_to_class( db: GraphDbRef<'_>, membership_g_ids: &[GraphId], + cross_ledger: Option>, value_ref: &Sid, hierarchy_accepted: &HashSet, expected_class: &Sid, @@ -1653,6 +1684,88 @@ async fn value_conforms_to_class( } } + // Cross-ledger fallback: the controlled vocabulary lives in a model ledger. + // Only reached when the value isn't typed locally. + if let Some(cl) = cross_ledger { + return value_conforms_cross_ledger(cl, value_ref, expected_class).await; + } + + Ok(false) +} + +/// Decode a data-ledger Sid to its IRI using an explicit namespace-code map. +/// +/// Mirrors `LedgerSnapshot::decode_sid` but reads from a supplied map so that +/// namespaces this transaction *staged* (absent from the base snapshot) still +/// decode. `EMPTY` / `OVERFLOW` codes carry the full IRI as the name. +fn decode_sid_with_ns_map(ns_map: &HashMap, sid: &Sid) -> Option { + use fluree_vocab::namespaces::{EMPTY, OVERFLOW}; + if sid.namespace_code == EMPTY || sid.namespace_code == OVERFLOW { + return Some(sid.name.to_string()); + } + ns_map + .get(&sid.namespace_code) + .map(|prefix| format!("{}{}", prefix, sid.name)) +} + +/// Resolve `sh:class` membership against a cross-ledger model ledger `M`. +/// +/// `value_ref` / `expected_class` are Sids in the data ledger D's term space, +/// so they are decoded to IRIs against D's (staged) namespace map and +/// re-encoded against M — which re-splits with its own mode, so differing +/// namespace-split modes between the ledgers are handled correctly. Well-known +/// vocab predicates (`rdf:type`, `rdfs:subClassOf`) share global namespace +/// codes across ledgers, so only the user IRIs need translation. If M has never +/// seen the value or class IRI, it cannot be a member there. +async fn value_conforms_cross_ledger( + cl: CrossLedgerMembership<'_>, + value_ref: &Sid, + expected_class: &Sid, +) -> Result { + let m_db = cl.model_db; + // D term -> IRI (via D's staged ns map) -> M term. A missing decode/encode + // means the value/class is simply not known to M -> not a member there. + let (Some(value_iri), Some(class_iri)) = ( + decode_sid_with_ns_map(cl.data_ns_map, value_ref), + decode_sid_with_ns_map(cl.data_ns_map, expected_class), + ) else { + return Ok(false); + }; + let (Some(m_value), Some(m_class)) = ( + m_db.snapshot.encode_iri_strict(&value_iri), + m_db.snapshot.encode_iri_strict(&class_iri), + ) else { + return Ok(false); + }; + + let rdf_type = Sid::new(RDF, rdf_names::TYPE); + let type_flakes = m_db + .range( + IndexType::Spot, + RangeTest::Eq, + RangeMatch::subject_predicate(m_value, rdf_type), + ) + .await?; + let m_types: Vec = type_flakes + .iter() + .filter_map(|f| match &f.o { + FlakeValue::Ref(t) => Some(t.clone()), + _ => None, + }) + .collect(); + + if m_types.contains(&m_class) { + return Ok(true); + } + + // Subclass reasoning within M: walk `subClassOf` over M's value-set graph + // unioned with M's schema graph (g_id=0). + for t in &m_types { + if is_subclass_of(m_db, &[m_db.g_id], t, &m_class).await? { + return Ok(true); + } + } + Ok(false) } diff --git a/fluree-db-transact/src/stage.rs b/fluree-db-transact/src/stage.rs index fdba2d77ab..dd57d83407 100644 --- a/fluree-db-transact/src/stage.rs +++ b/fluree-db-transact/src/stage.rs @@ -2164,7 +2164,8 @@ pub async fn stage_with_shacl( // Validate staged flakes against shapes (per graph). `None` for // `enabled_graphs` means "validate every graph with staged flakes" — // this legacy path doesn't consult per-graph config. - let report = validate_staged_nodes(&view, &engine, Some(&graph_sids), tracker, None).await?; + let report = + validate_staged_nodes(&view, &engine, Some(&graph_sids), tracker, None, None).await?; if !report.conforms { return Err(TransactError::ShaclViolation(format_shacl_report(&report))); @@ -2228,6 +2229,7 @@ pub async fn validate_view_with_shacl( tracker: Option<&fluree_db_core::Tracker>, per_graph_policy: Option<&HashMap>, membership_g_ids: &[GraphId], + cross_ledger: Option>, ) -> Result { // Fast path: if there are no SHACL shapes, elide validation entirely. if shacl_cache.is_empty() { @@ -2237,12 +2239,22 @@ pub async fn validate_view_with_shacl( // `membership_g_ids` (the `f:shapesSource` graph[s]) are unioned into // `sh:class` value-membership resolution so a shared value-set vocabulary // can live alongside the shapes rather than in each data graph. + // `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 enabled_graphs: Option> = per_graph_policy.map(|m| m.keys().copied().collect()); - let report = - validate_staged_nodes(view, &engine, graph_sids, tracker, enabled_graphs.as_ref()).await?; + let report = validate_staged_nodes( + view, + &engine, + graph_sids, + tracker, + enabled_graphs.as_ref(), + cross_ledger, + ) + .await?; // Split violations by the graph's configured mode. `graph_id` on each // result was tagged during the per-graph loop in validate_staged_nodes. @@ -2290,6 +2302,7 @@ async fn validate_staged_nodes( graph_sids: Option<&HashMap>, tracker: Option<&fluree_db_core::Tracker>, enabled_graphs: Option<&HashSet>, + cross_ledger: Option>, ) -> Result { use fluree_vocab::namespaces::RDF; use fluree_vocab::rdf_names; @@ -2397,7 +2410,9 @@ async fn validate_staged_nodes( // inside `validate_node` via post-state range queries — see the // SubjectsOf/ObjectsOf handling there for why hints can't be // reliably built from staged flakes alone. - let report = engine.validate_node(db, subject, &node_types).await?; + let report = engine + .validate_node(db, subject, &node_types, cross_ledger) + .await?; // Tag each result with the graph it was validated under so the // caller can route warn vs reject per-graph (see // `ShaclValidationOutcome`). From a0e11093537d5ceac5ca677ccae82b1ea7635ad1 Mon Sep 17 00:00:00 2001 From: bplatz Date: Sun, 5 Jul 2026 08:10:37 -0400 Subject: [PATCH 3/3] fix(shacl): error on missing cross-ledger value-set graph instead of silent drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When resolving the model ledger's value-set graph for cross-ledger sh:class membership, a `resolve_selector_g_id` result of `Ok(None)` previously mapped to `None` membership, silently. With no membership handle, every M-only value falls through to "not a member" (value_conforms_to_class → Ok(false)) and the write is rejected with spurious sh:class violations. The vocabulary lives in the same M graph the shapes were compiled from, so this miss should be unreachable — surface it as an explicit error (in all build profiles, unlike a debug_assert) so any future divergence between shape-graph and value-set-graph resolution fails loudly rather than as confusing false rejects. Happy path unchanged. --- fluree-db-api/src/tx.rs | 43 +++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/fluree-db-api/src/tx.rs b/fluree-db-api/src/tx.rs index a2594df086..fed55e624e 100644 --- a/fluree-db-api/src/tx.rs +++ b/fluree-db-api/src/tx.rs @@ -751,21 +751,34 @@ async fn stage_with_config_shacl( cross_ledger_shapes.as_deref(), ) { (Some(m_db), Some(ns_map), Some(resolved)) => { - crate::cross_ledger::resolve_selector_g_id(&m_db.snapshot, &resolved.graph_iri) - .map_err(|e| { - fluree_db_transact::TransactError::Parse(format!( - "cross-ledger value-set graph resolution failed: {e}" - )) - })? - .map(|g_id| fluree_db_shacl::CrossLedgerMembership { - model_db: fluree_db_core::GraphDbRef::new( - &m_db.snapshot, - g_id, - m_db.overlay.as_ref(), - m_db.t, - ), - data_ns_map: ns_map, - }) + // The value-set vocabulary lives in the same M graph the shapes were + // compiled from, so a miss here should be unreachable. Error loudly + // rather than silently dropping membership — a silent `None` would + // make every M-only value fall through to "not a member" and reject + // the write with spurious `sh:class` violations. + let g_id = + crate::cross_ledger::resolve_selector_g_id(&m_db.snapshot, &resolved.graph_iri) + .map_err(|e| { + fluree_db_transact::TransactError::Parse(format!( + "cross-ledger value-set graph resolution failed: {e}" + )) + })? + .ok_or_else(|| { + fluree_db_transact::TransactError::Parse(format!( + "cross-ledger value-set graph {} not present in model ledger {} at t={} \ + (shapes resolved but vocabulary graph missing)", + resolved.graph_iri, resolved.model_ledger_id, resolved.resolved_t + )) + })?; + Some(fluree_db_shacl::CrossLedgerMembership { + model_db: fluree_db_core::GraphDbRef::new( + &m_db.snapshot, + g_id, + m_db.overlay.as_ref(), + m_db.t, + ), + data_ns_map: ns_map, + }) } _ => None, };