Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .fluree-memory/repo.ttl
Original file line number Diff line number Diff line change
Expand Up @@ -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<ShaclCache> + Option<SchemaHierarchy> 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" ;
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions docs/guides/cookbook-shacl.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions docs/security/policy-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions fluree-db-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions fluree-db-api/src/commit_transfer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
61 changes: 61 additions & 0 deletions fluree-db-api/src/cross_ledger/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::sync::Arc<fluree_db_query::schema_bundle::SchemaBundleFlakes>>,
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))
}
10 changes: 10 additions & 0 deletions fluree-db-api/src/ledger_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ pub struct LedgerView {
pub novelty: Arc<Novelty>,
/// Dictionary novelty layer (subjects and strings since last index build)
pub dict_novelty: Arc<fluree_db_core::DictNovelty>,
/// Shared cache of the current RDFS schema hierarchy (see
/// `LedgerState::schema_hierarchy_cache`).
pub schema_hierarchy_cache: Arc<fluree_db_core::SchemaHierarchyCache>,
/// Cross-transaction compiled-SHACL cache slot (see
/// `LedgerState::shacl_compile_cache`).
pub shacl_compile_cache: Arc<parking_lot::RwLock<Option<Arc<dyn std::any::Any + Send + Sync>>>>,
/// Ledger-scoped runtime IDs for predicates and datatypes.
pub runtime_small_dicts: Arc<fluree_db_core::RuntimeSmallDicts>,
/// Current transaction t value
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down
77 changes: 75 additions & 2 deletions fluree-db-api/src/policy_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ pub async fn build_policy_context_from_opts(
opts,
policy_graphs,
None,
None,
)
.await
}
Expand Down Expand Up @@ -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<std::sync::Arc<fluree_db_query::schema_bundle::SchemaBundleFlakes>>,
) -> Result<PolicyContext> {
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,
Expand All @@ -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<PolicyRestriction>,
cross_ledger_schema: Option<std::sync::Arc<fluree_db_query::schema_bundle::SchemaBundleFlakes>>,
) -> Result<PolicyContext> {
build_policy_context_from_opts_inner(
snapshot,
Expand All @@ -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,
Expand All @@ -181,6 +213,7 @@ async fn build_policy_context_from_opts_inner(
opts: &GovernanceOptions,
policy_graphs: &[fluree_db_core::GraphId],
cross_ledger_restrictions: Option<Vec<PolicyRestriction>>,
cross_ledger_schema: Option<std::sync::Arc<fluree_db_query::schema_bundle::SchemaBundleFlakes>>,
) -> Result<PolicyContext> {
// A cross-ledger `f:policySource` is only ever passed here when configured,
// so its presence — even with an empty restriction set — means policy
Expand Down Expand Up @@ -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).
//
Expand Down
25 changes: 24 additions & 1 deletion fluree-db-api/src/policy_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand All @@ -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))
Expand Down
Loading
Loading