Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6fc5dab
fix(policy): enforce f:policySource and config policy defaults on writes
bplatz Jul 2, 2026
cd2426c
fix(policy): identity binds ?$identity under cross-ledger f:policySou…
bplatz Jul 2, 2026
4c3fde2
fix(reasoning): fail closed on followOwlImports with cross-ledger sch…
bplatz Jul 2, 2026
a5c2974
feat(reasoning): dataset-path reasoning parity + permissive reasoning…
bplatz Jul 2, 2026
8a1b7da
feat(shacl): compile and evaluate sh:path property path expressions
bplatz Jul 2, 2026
b2ac314
docs(shacl): document property paths in the cookbook; drop contributo…
bplatz Jul 2, 2026
ac4afaf
feat(shacl): surface sh:message as the violation message
bplatz Jul 2, 2026
728e4c8
fix(shacl): evaluate complex paths in nested shapes; scope unresolvab…
bplatz Jul 2, 2026
ae14bd4
feat(shacl): close the silently-broken constraint gaps
bplatz Jul 2, 2026
8e7890e
fix(shacl): honor sh:severity on node-level structural constraints
bplatz Jul 2, 2026
96103df
fix(shacl): match sh:pattern against the lexical form of literals
bplatz Jul 2, 2026
37200fe
fix(shacl): evaluate sh:class and qualified shapes inside nested members
bplatz Jul 2, 2026
dcb6e6f
feat(shacl): enforce sh:uniqueLang and sh:languageIn
bplatz Jul 2, 2026
b259515
fix(shacl): compare numerics by value in sh:in and sh:hasValue
bplatz Jul 2, 2026
fc30374
fix(shacl): apply string facets to IRIs via their full decoded IRI
bplatz Jul 2, 2026
8f9f90b
feat(shacl): surface sh:message on anonymous nested member shapes
bplatz Jul 2, 2026
7be42af
feat(shacl): enforce sh:qualifiedValueShapesDisjoint
bplatz Jul 2, 2026
a46380e
docs(shacl): document that bulk import intentionally bypasses validation
bplatz Jul 2, 2026
51f28eb
chore(shacl): drop needless raw-string hashes in Turtle test fixture
bplatz Jul 2, 2026
0c0f46d
fix(shacl): evaluate value-only nested members; unify focus/value str…
bplatz Jul 2, 2026
ed1e7cc
feat(shacl): support inverse over any property path; strict path parsing
bplatz Jul 2, 2026
2aa1755
fix(shacl): exclude own qualified shape by value from disjoint siblings
bplatz Jul 2, 2026
2063e08
perf(shacl): skip empty path/class probes in per-transaction compile
bplatz Jul 5, 2026
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
1 change: 0 additions & 1 deletion docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,5 @@
- [Dev setup](contributing/dev-setup.md)
- [Tests](contributing/tests.md)
- [W3C SPARQL compliance suite](contributing/sparql-compliance.md)
- [SHACL implementation](contributing/shacl-implementation.md)
- [Adding tracing spans](contributing/tracing-guide.md)
- [Releasing](contributing/releasing.md)
13 changes: 0 additions & 13 deletions docs/contributing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,6 @@ Guide to the manifest-driven W3C compliance test suite:
- Using Claude Code for compliance work
- Architecture overview

### [SHACL Implementation](shacl-implementation.md)

How SHACL validation is wired into Fluree, for contributors adding
constraints or fixing bugs:
- Pipeline: compile → cache → validate
- Crate layout (`fluree-db-shacl` / `-transact` / `-api`)
- Shared post-stage helper and its call sites
- Per-graph config, `f:shapesSource`, target-type resolution
- Adding a new constraint (walkthrough)
- Testing patterns (unit + integration + temp-revert regression trick)
- Known gaps (`sh:uniqueLang`, `sh:qualifiedValueShape`, cross-txn cache)


## How to Contribute

### Ways to Contribute
Expand Down
319 changes: 0 additions & 319 deletions docs/contributing/shacl-implementation.md

This file was deleted.

27 changes: 21 additions & 6 deletions docs/design/cross-ledger-model-enforcement.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,14 @@ mode can be added without rewriting the failure taxonomy.
- `f:policySource` cross-ledger via `resolve_graph_ref`. The
policy IR carries definitional/contextual term references
separately so the model ledger contributes rules while the
data ledger contributes identity binding.
data ledger contributes identity binding. Enforced on both
the read path (`wrap_policy`) and the write path
(`build_transact_policy_context`, used by the consensus
transact pipeline, push replication, credentialed
transactions, and the CLI); the two share one restriction
resolver (`resolve_cross_ledger_policy_restrictions`) so the
identity-mode rejection and `f:policyClass` filter cannot
drift.
- `f:constraintsSource` cross-ledger via the same shared
resolver. M's `f:enforceUnique true` annotations on
properties apply to D's transactions; a tx that would
Expand All @@ -468,7 +475,10 @@ mode can be added without rewriting the failure taxonomy.
imports, and rdf:type for the schema-class set) are projected
into a `SchemaBundleFlakes` against D's snapshot and feed D's
reasoner. Single-graph only today; transitive `owl:imports`
recursion across multiple model ledgers is reserved.
recursion across multiple model ledgers is reserved, and
`f:followOwlImports true` combined with a cross-ledger
`f:schemaSource` fails closed (`ApiError::OntologyImport`)
rather than silently reasoning over the starting graph alone.
- Per-request memo + per-instance governance cache, both keyed
on `(ArtifactKind, canonical_model_ledger_id, graph_iri,
resolved_t)`.
Expand All @@ -477,10 +487,15 @@ mode can be added without rewriting the failure taxonomy.
- Reserved-feature rejection: `f:atT`, `f:trustPolicy`, and
`f:rollbackGuard` are surfaced as `UnsupportedFeature` rather
than silently ignored.
- Identity-mode + cross-ledger policy combination fails closed
with a config error — the design's "M contributes rules, D
contributes identity" boundary is enforced at the request
surface.
- Identity binding under cross-ledger policy: a request identity
is bind-only — it resolves against D to populate `?$identity`
for M's `f:query` rules and never selects rules (the design's
"M contributes rules, D contributes identity binding"
boundary). Rule selection is exclusively the policy-class
filter chain (request → config → `{f:AccessPolicy}` for
anonymous requests); an identity-carrying request with no
policy class anywhere fails closed with a config error rather
than silently defaulting.

### Reserved

Expand Down
51 changes: 36 additions & 15 deletions docs/design/ontology-imports.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,27 @@ An `OntologyImportBinding` has two fields:
- `f:ontologyIri` — the IRI that appears in `owl:imports` statements.
- `f:graphRef` — a nested `f:GraphRef` identifying the local graph.

The `GraphRef` shape supported for `f:schemaSource` and
`f:ontologyImportMap.graphRef` is the same-ledger shape:
`f:graphSelector` naming a local named graph, `f:defaultGraph`, or a
registered graph IRI. References are resolved at the query's effective
`to_t` — every named graph in a Fluree ledger shares the ledger's
monotonic `t`, so the entire closure is consistent at a single point in
time without per-import bookkeeping.
The `GraphRef` shape supported for `f:ontologyImportMap.graphRef` is the
same-ledger shape: `f:graphSelector` naming a local named graph,
`f:defaultGraph`, or a registered graph IRI. References are resolved at
the query's effective `to_t` — every named graph in a Fluree ledger
shares the ledger's monotonic `t`, so the entire closure is consistent
at a single point in time without per-import bookkeeping.

`f:schemaSource` additionally accepts the cross-ledger shape (`f:ledger`
naming a model ledger plus an explicit `f:graphSelector`). Cross-ledger
refs bypass this module entirely: `view/query.rs::
resolve_configured_schema_bundle` dispatches them through the shared
cross-ledger resolver (`ArtifactKind::SchemaClosure`), which projects
the model ledger's whitelisted axioms onto the data ledger's snapshot
at the model ledger's **current head** (as-of-now, matching
`f:policySource` / `f:shapesSource` semantics — not the query's `to_t`).
Both the single-ledger view path and the multi-ledger dataset path
(where reasoning is governed by the dataset's primary view) share this
dispatch via `apply_reasoning_to_executable`. The cross-ledger
materializer is single-graph: combining it with `f:followOwlImports
true` fails closed with `ApiError::OntologyImport` rather than silently
dropping the import closure. See `cross-ledger-model-enforcement.md`.

## Resolution algorithm

Expand Down Expand Up @@ -197,17 +211,24 @@ so broken ontology references surface early. Sources of this error:
- A resolution that would land on a reserved system graph (config or
txn-meta), whether via direct graph-IRI match, mapping table, or
`f:schemaSource` selector.
- A `GraphRef` that targets a different ledger, uses `f:atT`, or carries a
`f:trustPolicy` / `f:rollbackGuard`. The bundle is resolved at the
query's single `to_t`, same-ledger scope only, and accepting these
fields silently would create a gap between declared intent and actual
behavior.
- An `f:ontologyImportMap.graphRef` that targets a different ledger, or
any `GraphRef` that uses `f:atT` or carries a `f:trustPolicy` /
`f:rollbackGuard`. The local bundle is resolved at the query's single
`to_t`, and accepting these fields silently would create a gap between
declared intent and actual behavior. (A cross-ledger `f:schemaSource`
is legal on both the single-ledger and dataset query paths but never
reaches this module — see above.)
- `f:followOwlImports true` combined with a cross-ledger
`f:schemaSource` (raised by `view/query.rs` before dispatch — the
cross-ledger materializer does not walk `owl:imports`).

## Wiring at query time

`Fluree::query(&db, ...)` (and the dataset-query counterpart) call
`build_executable_for_view` → `attach_schema_bundle` on every query. The
attach step:
`Fluree::query(&db, ...)` calls `build_executable_for_view`, and the
dataset counterpart calls `build_executable_for_dataset` with the
dataset's primary view; both route through the shared
`apply_reasoning_to_executable` → `attach_schema_bundle` on every query.
The attach step:

1. Reads `db.resolved_config().reasoning`. If there is no `f:schemaSource`,
returns immediately — the legacy default-graph path applies unchanged.
Expand Down
152 changes: 146 additions & 6 deletions docs/guides/cookbook-shacl.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ Fluree decides whether to run SHACL validation on each transaction using this or

This means you can start using SHACL **without writing any config** — just transact shapes and they're enforced.

**Bulk import is deliberately exempt.** The bulk-import pipeline never runs
SHACL — it is a trusted, high-throughput load path. If your source data must
conform, validate it *before* importing (e.g. run a SHACL report over the
source with your shapes) so the ledger starts clean; transaction-time
validation keeps it clean from there.

The `shacl` feature must be enabled at build time (it's on by default for the server and CLI binaries). See [Standards and feature flags](../reference/compatibility.md).

## Enabling SHACL via the config graph
Expand Down Expand Up @@ -91,9 +97,66 @@ ex:PersonShape a sh:NodeShape ;
| `sh:targetNode <N>` | The specific subject `<N>` |
| `sh:targetSubjectsOf <P>` | Every subject that currently has predicate `<P>` |
| `sh:targetObjectsOf <P>` | Every node that currently appears as the object of `<P>` |
| implicit (shape `a rdfs:Class`) | A shape that is also a class targets its own instances — no explicit target needed |

See [Predicate-target shapes](#predicate-target-shapes) for notes on how the staged-path validator discovers focus nodes for `sh:targetSubjectsOf` / `sh:targetObjectsOf`.

## Property paths

`sh:path` is usually a single predicate, but it can also be a **property path expression**. The path is evaluated against the focus node to produce the set of *value nodes* the constraints then apply to — so `sh:minCount`, `sh:datatype`, `sh:class`, etc. all work over a path exactly as they do over a plain predicate.

| Path form | Turtle syntax | Reaches |
|-----------|---------------|---------|
| Predicate | `sh:path ex:knows` | objects of `ex:knows` |
| Inverse | `sh:path [ sh:inversePath ex:parent ]` | subjects that point at the focus via `ex:parent` (works over any path: `[ sh:inversePath ( ex:a ex:b ) ]` reaches nodes two hops upstream) |
| Sequence | `sh:path ( ex:knows schema:name )` | names of the people the focus knows |
| Alternative | `sh:path [ sh:alternativePath ( ex:email ex:altEmail ) ]` | values via **either** predicate |
| Zero-or-more | `sh:path [ sh:zeroOrMorePath ex:parent ]` | the focus **and** all transitive `ex:parent` ancestors |
| One-or-more | `sh:path [ sh:oneOrMorePath ex:parent ]` | all transitive `ex:parent` ancestors (excludes the focus) |
| Zero-or-one | `sh:path [ sh:zeroOrOnePath ex:parent ]` | the focus and its direct parents |

These nest: `sh:path ( [ sh:inversePath ex:parent ] schema:name )` reaches the names of the focus's children.

```turtle
# Every Parent must have at least one child (something points at it via ex:parent),
# and each place a Person knows-of must be named.
ex:ParentShape a sh:NodeShape ;
sh:targetClass ex:Parent ;
sh:property [
sh:path [ sh:inversePath ex:parent ] ;
sh:minCount 1 ;
sh:message "A Parent must have at least one child"
] .

ex:SocialiteShape a sh:NodeShape ;
sh:targetClass ex:Socialite ;
sh:property [
sh:path ( ex:knows schema:name ) ;
sh:datatype xsd:string ;
sh:minCount 1
] .
```

In **JSON-LD**, a sequence path is written with `@list`, and the blank-node forms are written as nested objects:

```json
{
"@id": "ex:SocialiteShape",
"@type": "sh:NodeShape",
"sh:targetClass": { "@id": "ex:Socialite" },
"sh:property": [{
"sh:path": { "@list": [ { "@id": "ex:knows" }, { "@id": "schema:name" } ] },
"sh:datatype": { "@id": "xsd:string" },
"sh:minCount": 1
}, {
"sh:path": { "sh:inversePath": { "@id": "ex:parent" } },
"sh:minCount": 1
}]
}
```

**Not supported:** the inverse of a composite path (e.g. `[ sh:inversePath ( ex:a ex:b ) ]`). `sh:inversePath` may only wrap a single predicate. An unsupported or unresolvable path doesn't silently pass — it produces a violation whenever the owning shape validates a focus node, so any transaction touching data that shape targets is rejected with a clear message. A broken path only affects the shape's own targets, not unrelated writes.

## Constraint patterns

### Cardinality — required and multi-valued
Expand Down Expand Up @@ -145,6 +208,23 @@ ex:UserShape a sh:NodeShape ;

`sh:pattern` accepts an optional `sh:flags` string (e.g. `"i"` for case-insensitive).

### Language constraints

`sh:languageIn` restricts values to language-tagged literals whose tag matches
one of the given basic language ranges (`"en"` also matches `"en-US"`, per
SPARQL `langMatches`); untagged values violate. `sh:uniqueLang true` forbids
two values of the property from sharing a language tag.

```turtle
ex:LabelShape a sh:NodeShape ;
sh:targetClass ex:Labeled ;
sh:property [
sh:path ex:label ;
sh:languageIn ( "en" "fr" ) ;
sh:uniqueLang true
] .
```

### Node kind

```turtle
Expand Down Expand Up @@ -218,6 +298,69 @@ ex:ContactShape a sh:NodeShape ;

Available: `sh:not`, `sh:and`, `sh:or`, `sh:xone`.

### Shape-based constraints (`sh:node`)

`sh:node` validates a node against another node shape. On a property shape it
applies to each value; directly on a node shape it applies to the focus node
itself. The referenced shape is usually targetless — it fires only where it is
referenced.

```turtle
ex:AddressShape a sh:NodeShape ;
sh:property [ sh:path ex:postalCode ; sh:minCount 1 ] .

ex:PersonShape a sh:NodeShape ;
sh:targetClass ex:Person ;
sh:property [
sh:path ex:address ;
sh:node ex:AddressShape
] .
```

Recursive references are safe: a shape may reference itself (directly or via a
chain), and validation over cyclic data (e.g. a mutual `ex:knows` graph)
terminates — a node already being validated against a shape higher in the
evaluation is assumed conforming, matching common SHACL engine behavior.

### Qualified value shapes

`sh:qualifiedValueShape` counts how many values conform to a shape and checks
the count against `sh:qualifiedMinCount` / `sh:qualifiedMaxCount` — unlike
`sh:node`, values that don't conform are fine as long as enough do.

```turtle
ex:TeamShape a sh:NodeShape ;
sh:targetClass ex:Team ;
sh:property [
sh:path ex:member ;
sh:qualifiedValueShape ex:BadgedMemberShape ;
sh:qualifiedMinCount 1
] .
```

`sh:qualifiedValueShapesDisjoint true` additionally excludes values that
conform to a *sibling* qualified shape — e.g. a crew needing one pilot and one
navigator as distinct members rejects a single member holding both roles.

### Constraints on the node itself

Value constraints declared directly on a node shape (without `sh:path`) apply
to the focus node. Combined with a predicate target this restricts which nodes
may appear in a position:

```turtle
# Only ex:active / ex:inactive may be used as an ex:status value.
ex:StatusShape a sh:NodeShape ;
sh:targetObjectsOf ex:status ;
sh:in ( ex:active ex:inactive ) .
```

### Deactivating a shape

`sh:deactivated true` turns a shape off without deleting it — it stops firing
for its targets and is treated as conforming when referenced via `sh:node` or
logical constraints.

### Closed shapes

```turtle
Expand Down Expand Up @@ -452,12 +595,10 @@ All three routes go through the same post-stage helper, so the ledger's configur

## Not yet supported

The following SHACL constructs are parsed/compiled but currently **no-ops** at validation time. Shapes using them load without error but don't constrain data:

- `sh:uniqueLang`, `sh:languageIn` — require language-tag metadata on flakes, which isn't yet threaded through the validation path.
- `sh:qualifiedValueShape` (+ `sh:qualifiedMinCount` / `sh:qualifiedMaxCount`) — requires recursive nested-shape counting.
- `sh:targetNode` with a literal value — only IRI/blank-node targets are compiled.
- `sh:sparql` (SPARQL-based constraints).

These are tracked in the SHACL compliance effort. Contributors: see [Contributing / SHACL implementation](../contributing/shacl-implementation.md).
These are tracked in the SHACL compliance effort.

## Shapes are data

Expand All @@ -481,4 +622,3 @@ Because shapes live as regular RDF in your ledger:
- [Setting Groups — SHACL](../ledger-config/setting-groups.md#shacl-defaults) — Configuration reference for `f:shaclDefaults`
- [Override Control](../ledger-config/override-control.md) — Per-graph / query-time override rules
- [Writing Config Data](../ledger-config/writing-config.md) — How to transact into the config graph
- [Contributing / SHACL implementation](../contributing/shacl-implementation.md) — How the pipeline works internally (for contributors)
4 changes: 3 additions & 1 deletion docs/ledger-config/setting-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ Controls default policy enforcement behavior.

When `f:policySource` is set, the policy loader scans the specified graph for policy rules instead of the default graph. This keeps policy rules separate from end-user data. If `f:policySource` is not set, policies are loaded from the default graph (backward compatible).

`f:policySource` and the policy defaults are honored on **both reads and writes**: queries load view rules from the configured graph, and transactions load `f:modify` rules from the same graph before staging. Config-declared `f:policyClass` / `f:defaultAllow` defaults apply to transactions even when the request itself carries no policy inputs — an operator who relocates policy into a named graph (or a model ledger) gets the same enforcement on writes as on reads.

**Cross-ledger references are supported on `f:policySource`.** The graph source can name another ledger via `f:ledger`, so a single model ledger can hold policy rules that govern many data ledgers. See [Cross-ledger policy](../security/cross-ledger-policy.md) for the configuration pattern and the contract on `f:policyClass` filtering, baseline `f:AccessPolicy` semantics, and the failure modes.

**Not yet honored on `f:policySource`** (parsed by the config layer but rejected at request time with a clear error): `f:atT` temporal pinning, `f:trustPolicy` verification, `f:rollbackGuard` freshness constraints. Cross-ledger references are also supported on `f:constraintsSource`, `f:schemaSource` (single graph only — transitive `owl:imports` recursion across ledgers is not yet supported), `f:shapesSource`, and `f:rulesSource`. See [Cross-ledger policy](../security/cross-ledger-policy.md) for the end-to-end configuration patterns and failure modes shared by all five subsystems.
Expand Down Expand Up @@ -125,7 +127,7 @@ Controls OWL/RDFS reasoning applied at query time.

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `f:reasoningModes` | IRI or list | (none) | Reasoning modes: `f:RDFS`, `f:OWL2QL`, `f:OWL2RL`, `f:Datalog` |
| `f:reasoningModes` | IRI, string, or list | (none) | Reasoning modes: `f:RDFS`, `f:OWL2QL`, `f:OWL2RL`, `f:Datalog`. Accepts repeated IRI objects (`f:reasoningModes f:rdfs, f:datalog`), string literals (`"rdfs"`), or an RDF collection of either (`( "rdfs" "datalog" )`); mode names are case-insensitive |
| `f:schemaSource` | `f:GraphRef` | (none) | Graph containing schema triples (`rdfs:subClassOf`, etc.) |
| `f:reasoningMaxFacts` | integer | 1,000,000 | OWL2-RL materialization budget: max derived facts before the closure is capped |
| `f:reasoningMaxSeconds` | integer | 30 | OWL2-RL materialization budget: max wall-clock seconds before the closure is capped |
Expand Down
Loading