Skip to content

fix: JSON-LD graph scoping, config integrity and explicit-only SHACL - #2009

Open
aaj3f wants to merge 27 commits into
mainfrom
fix/jsonld-graph-scope-config-integrity
Open

aaj3f wants to merge 27 commits into
mainfrom
fix/jsonld-graph-scope-config-integrity

Conversation

@aaj3f

@aaj3f aaj3f commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

This is deliberately more than #1979 needs. Which graph a write lands in and which checks run on it were decided per JSON-LD keyword and per write lane, and the answers drifted: nested nodes lost their graph (#1979), named-graph content was silently dropped, config groups split across graphs, and a heuristic decided whether SHACL ran. So rather than patch one surface, each answer now has one home, which is easier to reason about and tune: one rule for a node's graph, one check path shared by the write lanes instead of three drifted copies, and SHACL exactly where config turns it on. Fwiw, it's one of five PRs taking this approach, with #2006, #2007, #2008 and #2010.

Partially addresses #1979: items 1 (nested nodes inherit the enclosing @graph) and 2 (the config docs). Item 3, naming blank nodes in TriG GRAPH-block parse errors, remains for a separate change (#2008).

A JSON-LD config written the documented way (docs/ledger-config/writing-config.md) silently did not work: the config node landed in #config, but its nested f:shaclDefaults group landed in the default graph, so the reader saw an empty group and SHACL stayed off — while a ledger with shapes and no config enforced them anyway. This PR fixes both halves and closes the class around them: B1 makes a JSON-LD template's graph its innermost enclosing lexical scope, in every reader, and B2 checks config integrity and makes SHACL explicit-only.

Heads-up for reviewers: B2 is a breaking change (ledgers that hold shapes without f:shaclEnabled true stop enforcing on upgrade), so the behavior-change list and the enable recipe below are the parts I'd most like eyes on.

What changed

B1: JSON-LD graph scoping. A template's graph is its innermost enclosing lexical scope, in every JSON-LD reader:

  • Nested nodes (and @list items, anonymous nodes, @reverse / @included / @nest content) are written to their enclosing node's graph. A nested node's own @graph wins for its subtree; "default" names the default graph.
  • JSON-LD 1.1 named graph objects ({"@id": G, "@graph": [...]}) are supported. Named-graph object content that was silently dropped is now stored.
  • One @graph classifier and one graph-name resolution order (classify_written_graph_name), shared by templates and where: a variable, a fromNamed alias, the keywords "default", "config" (the ledger's own config graph) and "txn-meta", then expansion against the @context (absolute and compact IRIs). Relative graph names are refused. A ?var graph name in an update template writes to the graph the WHERE binds (SPARQL GRAPH ?g parity); elsewhere it is refused.
  • Blank node labels are scoped to the whole document (one issuer across ["graph", …] items; user labels never collide with minted ones).
  • Only envelope documents carry transaction metadata; a single object with a string @graph selector does not. At the top level, a document without @id whose @graph is a single node object is read exactly like the one-element array: envelope content, a bare reference {"@id": X} included. (Solo MCP's single-node envelope {"@graph": {…}, "f:message": …} keeps its metadata.)
  • Edge annotations follow their edge into its graph, with one f:reifiesGraph emitter (GraphScope::emit) and a fail-closed check that every reifier bundle sits beside its edge. An annotated edge under a top-level graph naming the ledger's own address (fix: unresolvable graph references fail closed, and TriG directives apply in document order #1997) lands with its annotation in the default graph. An annotation under a variable graph name is refused while parsing.
  • A node-level @graph in a JSON-LD where is a GRAPH pattern, and its name resolves in the order above, so an update whose where and delete both say "@graph": "ex:g" address one graph. In an update, "default" there names the ledger's default graph, as in the templates, also when a top-level graph or from gives the where another default graph, so moving data from the default graph into a named graph reads what it deletes. A nested node's own @graph scopes its patterns; "default" inside an enclosing graph pattern is refused (a where pattern cannot leave it).
  • @reverse, @included and @nest are implemented; @index is ignored; other keyword-form (^@[A-Za-z]+$) keys on a node are refused. Non-keyword @ keys such as @odata.etag behave exactly as before.
  • Bulk JSON-LD import refuses @graph scoping instead of folding it into the default graph.

B2: config integrity and explicit-only SHACL.

  • SHACL is enforced only where the ledger config enables it (f:shaclEnabled true, ledger-wide or per graph). The shapes-exist heuristic is gone; posture is a pure function of the resolved config, on every lane (JSON-LD/SPARQL, Turtle insert, branch merge/rebase/revert, push).
  • One post-staging check tail for every write lane (check_staged_write): one config read, then SHACL, uniqueness and the staged-config guard (every check on authoring lanes; the config-group re-check on branch operations). It replaces three copies that had drifted: the plain Turtle insert lane now enforces uniqueness like the other lanes, and merge, rebase and revert (and their previews) enforce uniqueness as well as SHACL. Push is the one exception: it validates pushed commits through its own SHACL call, with replay semantics (origin_validated_replay; a violation is a 422), and runs neither uniqueness nor the config-group checks. Routing it through the tail would change its statuses and add those checks to replication, so it is listed as a follow-up.
  • Fast-forward merges into a governed target are validated. A fast-forward adopted the source's commits unvalidated, as "validated when they were authored". Nothing guarantees that commits authored on another branch satisfy the target's configuration, so a fast-forward into a target whose config enables SHACL or uniqueness (ledger-wide or for any graph) now stages the adopted change on the target and runs the transaction checks under the target's config as it was before the merge, before any commit is copied; the merge preview reports the outcome. A fast-forward whose adopted commits carry config settings is checked the same way (next bullet). Any other fast-forward is unchanged.
  • Branch operations re-check the target's config groups. Merges (fast-forwards included), rebases and reverts run the staged-config guard on what they stage. Config typed outside the config graph is refused when the data is written; branch operations re-check the integrity of the target's config groups (no group split across graphs, within the change and across transactions, and no second value for a single-valued setting), so a merge cannot combine a group link and the group's fields outside the config graph that were each accepted where they were written, and a revert cannot undo a repair. The refusal is the same Parse error a transaction gets, naming the target's config group and pointing at the repair recipe; previews report it.
  • Inline request shapes (opts.shapes) validate on their own pass, gated by the SHACL group's f:overrideControl rather than f:shaclEnabled; where override control refuses them the transaction fails with a 400 (RequestOverrideRefused). They are not supported in a policy-scoped request (refused with a 400); support for inline shapes in policy-scoped requests is a follow-up. opts.shapes and opts.uniqueProperties in a transaction body are read on every surface (HTTP, the embedded API and the CLI's local mode; only the HTTP route read them), malformed values are refused, and a build without SHACL support refuses inline shapes.
  • Validation artifacts (shapes, schema and constraints sources, including cross-ledger model ledgers) are resolved only for graphs that participate. The system graphs never participate in SHACL or uniqueness, so a config-only write is never blocked by an unresolvable source and the owner can always repair the config. Policy sources are unchanged: they still fail every write closed, config writes included.
  • Config reads on the transaction path fail closed uniformly (uniqueness used to read an unreadable config as "no config"); a config-only write reads none.
  • The config reader treats an empty group as absent and resolves a doubled group pointer deterministically. Ledger info reports degenerate config as configDiagnostics (shacl-not-configured, empty-group, stranded-fields, ambiguous-pointer, multiple-ledger-configs, duplicate-graph-override), and fluree info prints them.
  • A staged-config guard refuses (as Parse error, HTTP 400) a setting group split across graphs, a config typed outside the ledger's own config graph, and a second value for a single-valued setting or a second f:LedgerConfig subject. The split check holds across transactions too, in either order: config fields (the predicates the config reader reads) written outside the config graph for a group the config graph already points at, and a config edge to a node whose config fields sit in another graph. Other f: data in a user graph (an edge annotation's f:reifies*, a property's f:enforceUnique, a policy document's f:allow) is not a config field, and writing it reads no config. Moving the fields in with the edge in one transaction (the repair) is accepted.

Behavior changes (release note)

Suggested label: breaking-change. The full text is in docs/reference/compatibility.md#behavior-changes.

  • Breaking: SHACL is enforced only where the ledger config enables it. Ledgers that hold shapes without f:shaclEnabled true (including shapes published through GraphQL or fluree model entity define) stop enforcing on upgrade; merge, rebase, revert and push stop validating on them too. Configs without a SHACL group were already unenforced and do not change. Ledger info reports affected ledgers as shapes present; SHACL enforcement not configured. To keep enforcing, attach SHACL to the existing config subject (recipe below).
  • Fast-forward merges into a target whose config enables SHACL or uniqueness are validated against the target's config as it was before the merge (a merge whose commits also change that config is judged by the config it merges into). A violating fast-forward is refused like a general merge, and fluree branch diff / GET /merge-preview report its validation. Fast-forwards into other targets are still not validated against shapes or uniqueness.
  • Merge, rebase and revert enforce uniqueness constraints as well as SHACL, and the merge and revert previews report a violation. A plain Turtle insert enforces uniqueness like the other lanes.
  • Merges (fast-forwards included), rebases and reverts refuse a result that would leave one of the target's setting groups split across graphs or a single-valued setting with two values. A branch that carries a split config from before this release is refused until the config is repaired (recipe below).
  • JSON-LD nodes nested under a node with an @graph selector are written to that graph (they used to land in the default graph). A client that relied on the old placement should give the nested node "@graph": "default".
  • Named-graph object content that was silently dropped is now stored.
  • Only an envelope's top-level keys are transaction metadata: a single node with an @graph selector, and a named-graph object's own keys, are data only (they were written as data and as metadata). A top-level {"@graph": {…}} without @id stays an envelope, as before.
  • A node-level @graph in a JSON-LD where is a GRAPH pattern, with the graph name resolved as in a template (it was read as a predicate named @graph and matched nothing). In an update, "default" there names the ledger's default graph, as in the templates, even under a top-level graph or from; in a query it names the query's default graph.
  • Relative graph names are refused; "config" names the ledger's config graph.
  • Inline request shapes follow override control (they now apply even under f:shaclEnabled false, and are refused under f:OverrideNone); they are refused in policy-scoped requests. opts.shapes and opts.uniqueProperties in a transaction body are honored by the embedded API and the CLI's local mode (they were ignored there), and malformed values are refused.
  • Config writes are checked (the guard above), including a setting group split across two transactions. A config-only write is never validated against shapes or uniqueness constraints (shapes targeting config nodes are no longer checked).
  • Unknown keyword-form @ keys on a node are refused; @nest is honored; @index is ignored. Edge annotations under a variable graph name are refused while parsing (they failed at commit with an unhelpful message).
  • Bulk JSON-LD import refuses @graph scoping; use fluree insert, or TriG / N-Quads.

Enable SHACL (ledger with a config subject; docs/ledger-config/writing-config.md#enabling-shacl has the variant for a ledger without config):

PREFIX f: <https://ns.flur.ee/db#>
INSERT {
  GRAPH <urn:fluree:mydb:main#config> {
    ?c f:shaclDefaults <urn:fluree:mydb:main:config:shacl> .
    <urn:fluree:mydb:main:config:shacl> f:shaclEnabled true .
  }
}
WHERE { GRAPH <urn:fluree:mydb:main#config> { ?c a f:LedgerConfig } }

Repair a config split by #1979 (policy and SHACL groups it affected: a split group's settings, f:defaultAllow false included, aren't in effect until it's repaired; merges, rebases and reverts that carry a split config are refused until it is repaired). Run with SELECT ?n ?p ?o first:

PREFIX f: <https://ns.flur.ee/db#>
DELETE { ?n ?p ?o }
INSERT { GRAPH <urn:fluree:mydb:main#config> { ?n ?p ?o } }
WHERE {
  GRAPH <urn:fluree:mydb:main#config> { ?parent ?edge ?root }
  VALUES ?edge { f:policyDefaults f:shaclDefaults f:reasoningDefaults f:datalogDefaults
                 f:transactDefaults f:fullTextDefaults f:servingDefaults f:graphOverrides }
  ?root (f:overrideControl|f:shapesSource|f:policySource|f:schemaSource|f:rulesSource|
         f:constraintsSource|f:graphSource|f:trustPolicy|f:rollbackGuard|f:ontologyImportMap|
         f:graphRef|f:property|f:shaclDefaults|f:policyDefaults|f:reasoningDefaults|
         f:datalogDefaults|f:transactDefaults|f:fullTextDefaults)* ?n .
  ?n ?p ?o .
}

Both recipes are exercised by tests (enable_recipe_enables_shacl_with_or_without_an_existing_config, repair_recipe_restores_a_split_config).

Solo lockstep

From an audit of fluree/solo's write paths, updated for the changes from Claude's review:

  • Branch merges (agent writes through review). With agents writing to <dataset>:<branch> and a person merging, a merge into an idle main is a fast-forward. When main's config enables SHACL or uniqueness, that fast-forward is now validated against main's config and refused on a violation, with the errors a general merge gives; the merge preview reports it as validation (mergeable false). Solo's review UI should show a fast-forward preview's validation too. Merge, rebase and revert also refuse uniqueness violations now, and a result that would split one of the target's config groups (a Parse error naming the group); a fast-forward whose commits carry config settings is checked that way even into an ungoverned main.
  • SHACL posture UI. Solo's readPosture renders a ledger with no stored f:validationMode as "enforce" (graphs.ts:254-287, QualityTab.tsx:151). For ungoverned ledgers that hold their own shapes and no config (user imports, ledgers unbound with governanceTx(…, null)), that label becomes false: return a third "not enforced" state when no enabled SHACL group exists, or write an explicit posture at import/unbind. The MCP tools' validationMode: "reject" does not turn SHACL on for such a ledger either (the heuristic did). Governed ledgers already set f:shaclEnabled true and are unaffected.
  • Config guard. Every current solo config writer passes. Latent refusal: a re-run of the model lambda's ensure-fulltext-config would add a second f:fullTextDefaults group (make it idempotent). Optional: make postureTx delete the three predicates it asserts.
  • JSON-LD writers. Named-graph objects from the spaCy extractor (…:knowledge) and semantic-transform model_jsonld (urn:fluree:workflow:vocabulary:<id>) now store their content in that named graph instead of dropping it; solo's default-graph readers and cleanup do not see it, so emit an envelope or strip the wrapper @id as pack-ner and apply-schema do. MCP stamp_provenance on an agent document with a top-level @id and an @graph (a named graph object, or a node with a graph selector) makes the stamped keys data of that node rather than transaction metadata. On a context-free bare reference ({"@id": …}, wrapped as {"@graph": {"@id": …}, …}) it is refused, as before. MCP jsonld_update: a where node's "@graph" now selects that graph, with compact names expanded against the document's @context and "default" naming the ledger's default graph (it matched nothing before). Bulk import of JSON-LD with @graph scoping now fails with the engine message instead of folding the data. @odata.* keys behave as before; custom webhooks sending @nest now merge it.
  • Errors. Every new refusal on a transaction renders as Parse error: , JSON-LD error: or Unsupported feature: (all mapped to 400 by solo's router), except RequestOverrideRefused for inline shapes, which solo does not send, and a named-graph object whose @id is the ledger's #txn-meta graph: staging's existing reserved-graph check refuses it as transaction targets reserved system graph …, which solo's router maps to 502 (map it to 400).

Performance

Across two criterion rounds on a shared box, the JSON-LD parse and insert benches move a few percent in both directions (the untouched Turtle bulk-import path moved −10% to +18%, which is the noise floor here), and jsonld_parse's update_sugar case is 22% faster. The one bench that went over the 5% budget, jsonld_parse annotations at +9.9% / +7.8%, was the reifier cross-check allocating a key per template; it now borrows its keys and runs only once a template names a graph, which brings it to +2.2%. These criterion numbers predate commits 17–27 and use default features, so they don't exercise the SHACL transaction path this PR restructures; the quiet box and the per-commit counts below do.

Quiet box. EC2 c7i.4xlarge, the fat-LTO bench profile with shacl, base and head in interleaved rounds; a change counts as a win or a loss only when the base and head ranges don't overlap and the median delta exceeds the bench's budget (5% at small). No loss by the rule. jsonld_parse wins on update_sugar (−14.7%) and flat_envelope (−15.3%; the base there was bimodal, 148 or 170 ms, but the head was at or below it in every round), and annotations is +4.4%, slower in every round but under the budget. insert_formats (JSON-LD and Turtle) is stable. transact_commit was inconclusive on populated_ledger in the first session (+23.9%, with the ranges overlapping) and is stable over the second session's 7 rounds, below.

Commit cost with shacl. On EC2 (c7i.4xlarge, the bench profile with shacl, 7 alternating rounds against main at 61b836e9a), transact_commit is stable by the rule: fresh_ledger −9.4% and populated_ledger +1.7% (the head slower in 6 of 7 rounds). Three of the rounds put both builds into a slower mode, in which the head was slower (populated_ledger +9% to +25%, fresh_ledger +5% to +8%); in the other four it was at parity on populated_ledger (−0.4% to +1.7%) and 9-10% faster on fresh_ledger. To see whether the head does more work per commit, a harness (not committed; dev-fast profile, shacl) ran the two scenarios as the bench does (memory storage, a current-thread runtime, one 10-node Turtle commit on a fresh ledger or on one holding 1000 nodes) and counted per commit: instructions and cycles retired (process-wide), allocator calls and bytes (a counting global allocator), page faults and context switches, and storage calls (a counting wrapper of the memory storage). Base 61b836e9a against this head, 4 interleaved rounds of 290 fresh and 50 populated commits, medians:

Per commit fresh: base → head populated: base → head
instructions 1,774,416 → 1,576,952 (−11.1%) 1,799,812 → 1,769,874 (−1.7%)
cycles 393,332 → 364,470 (−7.3%) 509,230 → 505,758 (−0.7%)
allocations 1,150 → 828 (−28%) 874 → 842 (−3.7%)
bytes allocated 384,851 → 287,954 (−25%) 372,465 → 281,306 (−24.5%)
frees 870 → 555 638 → 608
storage calls 1 write (781 bytes); no reads, lists, deletes or syncs, either build 1 write (795 bytes); no reads, lists, deletes or syncs, either build
page faults, context switches at most 1, none, either build none, none, either build
  • The head does less per commit, on both scenarios. The saving is in the SHACL posture: on a ledger without config, base prepared shape validation on every commit (the shapes-exist heuristic), and the head returns before it. Both builds read the config once per commit, and the head's config-guard pass is inside these numbers.
  • The bench's measured routine also drops the ledger and the Fluree handle. That costs about 110,000 instructions and 230 frees on a fresh ledger, and 2.25 million instructions and 9,700 frees on the populated one (more than the commit itself), the same in both builds (−0.1%).
  • The bench uses memory storage, so a commit does no file I/O and no fsync in either build. The slower mode on EC2 is therefore not extra work, allocation or I/O in the head's commit path; a per-iteration perf stat on that box would separate instructions from cycles there.

The fast-forward validation cost is below in full, since it's the one new cost on a user-visible operation:

Fast-forward merge (commit 20). A timing harness (not committed; file storage, dev-fast profile) fast-forwarded a branch of 3 commits (20 nodes each) into a target of 100 or 1000 commits (10 nodes each), 15 merges per cell per round, this branch against itself without commit 20, before its rebase onto 61b836e9a, the two interleaved over 2-4 rounds (load 9-19). "Cached": the target is in the ledger cache, as on a server that has read it. "Fresh": a new instance, as a CLI run or a server whose cache no longer holds the target. Medians:

Target Cached: before → after Fresh: before → after
ungoverned, indexed, 100 commits 15.2 → 15.7 ms 15.8 → 17.7 ms
ungoverned, indexed, 1000 commits 16.8 → 19.2 ms 17.8 → 23.0 ms
ungoverned, unindexed, 100 commits 15.6 → 15.6 ms 16.9 → 36.5 ms
ungoverned, unindexed, 1000 commits 21.0 → 21.6 ms 20.4 → 150.2 ms
SHACL enabled, indexed, 100 commits 16.2 → 17.2 ms 17.0 → 20.4 ms
SHACL enabled, indexed, 1000 commits 17.5 → 19.6 ms 19.0 → 23.8 ms
SHACL enabled, unindexed, 100 commits 15.8 → 16.0 ms 16.7 → 34.5 ms
SHACL enabled, unindexed, 1000 commits 22.0 → 22.5 ms 22.1 → 165.0 ms
  • With the target cached, the merge reads the config from the cached state: +0 to +0.6 ms on the unindexed targets and +0.5 to +2.5 ms on the indexed ones (whose config read goes to the index), governed or not. Validating the three adopted commits is within that.
  • A fresh instance has to load the target to read its config, which costs what a first query on the target costs: measured alone, about 2 ms for these indexed targets, and a replay of the unindexed novelty otherwise (19.6 ms for 100 commits, 126 ms for 1000). The CLI in local mode runs without an indexer or a ledger cache, so a fluree branch merge into a target with a long unindexed history pays that replay, as every CLI command that reads the target does.
  • Merges are not a hot path: a cached target pays +0 to +2.5 ms, and the replay a fresh CLI instance pays is the same replay any CLI command pays on unindexed history. Scanning only the config graph during that replay is listed as a follow-up.
  • Every fast-forward, governed or not, builds the target's pre-merge state (fast_forward_base: the cached state, or a full load when the cache does not hold the target) and reads its config. The ungoverned rows above measure that cost on its own; it is consistent with the merge cost accepted in the previous bullet.
  • Commit 25 adds, for a fast-forward into a target that governs nothing, a read of the adopted commits to see whether they carry config settings: +0.2 to +0.6 ms on these targets (this head with and without commit 25, 100 commits, indexed and unindexed, 3 interleaved rounds). For a governed target, its extra pass over the staged flakes is within noise (-0.4 to +0.9 ms).
The criterion table, the annotation fix, and the per-commit costs of commits 17–27

Criterion, FLUREE_BENCH_SCALE=small, bench profile, default features, the previous base e4793617f against this branch as of commit 16, before its rebase onto bf523e24e. Each bench ran back to back (before, then after) in two rounds on a shared 16-core macOS machine (load 9-22). The untouched Turtle import_bulk path moved between -10% and +18% across the rounds, which is the noise floor here. The "after" build predates two late changes to annotation lowering (the cross-check fix below and the invalid-@graph scan), neither of which runs on these documents, and commits 17-27 (see below). Medians:

Bench Round 1 before → after Round 2 before → after
jsonld_parse flat_envelope (20k nodes) 85.70 → 87.56 ms (+2.2%) 93.14 → 88.45 ms (-5.0%)
jsonld_parse nested_selector (20k nodes, depth 3) 60.28 → 58.65 ms (-2.7%) 61.02 → 59.47 ms (-2.5%)
jsonld_parse update_sugar (1k items) 2.72 → 2.11 ms (-22.4%) 2.74 → 2.14 ms (-22.0%)
insert_formats jsonld 10 txn × 100 nodes 11.74 → 12.45 ms (+6.0%) 12.08 → 12.27 ms (+1.6%)
insert_formats jsonld 10 × 10 1.27 → 1.29 ms (+1.6%) 1.27 → 1.32 ms (+3.4%)
insert_formats trig 10 × 100 14.29 → 14.90 ms (+4.3%) 14.40 → 14.85 ms (+3.1%)
insert_formats trig 10 × 10 1.54 → 1.56 ms (+1.5%) 1.57 → 1.57 ms (-0.3%)
insert_formats turtle 10 × 100 7.74 → 8.01 ms (+3.4%) 7.78 → 7.93 ms (+2.0%)
insert_formats turtle 10 × 10 875 → 873 µs (-0.2%) 876 → 870 µs (-0.7%)
transact_commit fresh ledger 84.3 → 89.5 µs (+6.2%) 86.2 → 86.1 µs (-0.1%)
transact_commit populated ledger 275.1 → 281.1 µs (+2.2%) 266.0 → 239.6 µs (-10.0%)
import_bulk default threads 228.6 → 252.9 ms (+10.6%) 216.9 → 207.1 ms (-4.5%)
import_bulk single thread 222.2 → 262.1 ms (+17.9%) 234.5 → 212.1 ms (-9.6%)

annotation_hydration (8 cases, read path unchanged) swung both ways in both rounds (-6% to +48%, including its non-annotation baseline at +28% / -6%), with no consistent direction.

jsonld_parse annotations (2k annotated edges) first measured +9.9% / +7.8%, over the 5% budget. The cause was the reifier cross-check allocating a key per template; it now borrows its keys and runs only once a template names a graph (commit 8). Alternating runs, four rounds each: base 19.21 ms, before the fix 21.13 ms (+10.0%), final 19.63 ms (+2.2%).

regression-budget.json gets a jsonld_parse entry (10% tiny, 5% small), like insert_formats.

Commits 17-27 on the transaction path, not re-benchmarked with criterion locally (EC2 and in-process counts for transact_commit are under Commit cost with shacl above):

  • Commit 19 moves the post-staging checks into one function; a transaction runs the same checks in the same order. The config read's "writes a user graph?" decision exits early instead of collecting and sorting a graph id per staged flake (the only change of work on the JSON-LD and SPARQL path), and the Turtle insert lane gains the uniqueness check: nothing on a ledger without config, one pass over the staged flakes' graphs on a ledger with one, and the lookups a JSON-LD insert makes where uniqueness is enabled.
  • Commit 21 adds one comparison per staged flake to the guard's existing pass. Its reads run only for a transaction that writes the config graph, or a config field outside the system graphs: the config graph once, plus a new config edge's target in the user graphs. As first written, any f: predicate outside the system graphs triggered them, including every edge annotation's f:reifies*; commit 26 restricts the trigger to the config reader's vocabulary.
  • Annotation writes (commit 26). A timing harness (not committed; memory ledger with a small config, dev-fast profile) ran 60 inserts of 200 annotated edges each and timed the last 50, for base 61b836e9a and this branch before and after commit 26, interleaved over 5 rounds (load 9-10). Over the 250 timed inserts, median (mean) per insert: base 6.90 ms (6.94), before 7.09 ms (+2.7%; 7.04), after 6.96 ms (+0.8%; 6.95). Per-round means: base 6.91-6.96 ms, before 7.01-7.08 ms, after 6.93-6.99 ms.
  • Commit 27 adds, to a JSON-LD update whose where reads the ledger's default graph by name (a "@graph": "default" while a graph or from names another graph), one entry to its WHERE dataset and one pass over the dataset's names; other updates compare each where graph name and VALUES value with the reserved name, and do no other extra work.

Before ready

  • Benchmark the final head on a quiet machine against 61b836e9a: done on EC2 with shacl (insert_formats, jsonld_parse and transact_commit; no loss by the rule) and counted in process for transact_commit (see Commit cost with shacl). Not run: the default-feature build.

Follow-ups (not filed yet)

  • Support for inline shapes in policy-scoped requests.
  • Bulk JSON-LD import of graph-scoped data (quads).
  • Governed defaults (a separate design); the per-transaction resolver count stays with it.
  • An IRI produced by BIND cannot be an INSERT template subject ("Raw IRI from graph source cannot be used as subject for flake generation"). This predates the PR and is why the enable recipe comes in two forms.
  • The ["graph", <name>, …] array form in a where takes its name as written (an alias or a full IRI), as before; resolving it like the node-level form would change existing queries.
  • Graph names resolve against the document's top-level @context, not a node-local or scoped one (as before).
  • Bulk import and insert differ on keyword-form keys: import drops @foo where insert refuses it, and drops @odata.etag where insert stores it (the latter predates this PR).
  • Datalog rule heads read @graph on their own rather than through the shared classifier.
  • The Turtle lexer (TriG, N-Quads) decodes a \u escape inside an IRI to a character an IRI cannot contain (turtle-eval-bad-01 to -03 are registered), so a graph can be registered under the name an update's WHERE reserves for the ledger's default graph; such an update is refused (see Deviations). Rejecting those escapes, or checking a graph IRI when a graph is registered, would make the name unreachable.
  • A fast-forward on a fresh instance replays the target's unindexed history to read its config; scanning only the config graph during that replay would make it cheaper.
  • Push through the shared check tail, keeping its replay semantics and statuses (uniqueness and the config-group checks on pushed commits).

Deviations from the design

The main departures: the config is read once, after staging, so the inline-shapes policy-scope refusal comes after staging; one more cross-ledger brick is fixed outside the listed sites (build_transact_policy_context); participation is per written graph; a governed fast-forward is validated in place rather than through the general merge; and uniqueness comes to branch operations with the shared check tail. Each of them, and the smaller ones, in full:

Every deviation from the design
  • Commit plan. The scope core, nested recursion and variable graph names are one commit (3), as are named-graph objects and the txn-meta envelope rule (5); the end-to-end scope tests are their own commit (4) so the SPARQL twins read on their own.
  • Config read once, after staging. SHACL and uniqueness share one pre-transaction config read, made after staging: the graphs a transaction writes decide whether any enforcement applies, and a config-only write reads none. The inline-shapes policy-scope refusal therefore happens after staging rather than before it (only a refused request pays for that).
  • One more brick, outside the listed sites. build_transact_policy_context resolved a cross-ledger f:schemaSource before its no-policy-inputs shortcut, so on the server and CLI paths an unreachable ontology model ledger refused every write, config repairs included. It now resolves the schema only when it builds a policy context. Policy sources are unchanged and still fail closed (test and mutation included).
  • Participation is per written graph. The default graph participates in stored-shape validation only when the transaction writes it (it used to be included whenever SHACL was enabled ledger-wide, which triggered source resolution for writes that touched no validated graph). What gets validated is unchanged: only graphs with staged flakes ever were.
  • Inline-shapes membership. sh:class membership in the inline pass reads the default graph plus the value-set facts in the request's own bundle (as fluree validate does for inline shapes), not the configured f:shapesSource graph.
  • Policy-scoped requests. Checked at two points: staging (a non-root policy context, or any policy group in the ledger config, ledger-wide or for a graph, other than one that only sets f:defaultAllow true) and the server route (any request policy input: identity, bearer identity, policy class, inline policy, policy values). The refusal is UnsupportedFeature (Unsupported feature: inline request shapes are not supported in a policy-scoped request), a 400 here and in solo's router.
  • Turtle insert. It now resolves a cross-ledger f:schemaSource for SHACL targeting like the other lanes (it passed none), and runs the whole check tail (the config guard, and uniqueness since commit 19).
  • Fast-forward validation in place. A governed fast-forward is validated where it runs rather than routed through the general merge, which would write a merge commit where a fast-forward adopts the source's commits as they are. The source line's net change since the target's head is staged on the target's pre-merge state (from the ledger cache when it holds the target at the merge's head, else loaded) and checked by the shared tail; the commits are then copied as before, and the ref update expects the validated head. Whether a target is governed is a coarse test (SHACL or uniqueness enabled anywhere in its config); the checks then apply each written graph's own settings. Into a target that governs nothing, the fast-forward reads the adopted commits to see whether they carry config settings (an f: predicate or type), and stages them for the config-group checks only then.
  • Uniqueness on branch operations. The shared tail brings uniqueness to merge, rebase and revert as well as to the Turtle insert lane Claude's review named: keeping it out there would have kept the per-lane difference the tail removes. Branch operations run the config guard in a branch-operation scope (the split-group and second-value checks; config typed outside the config graph is refused when the data is written).
  • where graph names. A query names no ledger, so "config" and "txn-meta" in a query's where pass through as written, and its fromNamed names stay aliases (even under @base). The ["graph", …] array form is unchanged (see Follow-ups).
  • where "default" in an update. When a top-level graph or from gives the where another default graph, a where node's "@graph": "default" reads the ledger's default graph by a name the update adds to its WHERE dataset, only when the where uses it (GRAPH ?g does not enumerate it). The name, @ledger default, contains a space, so no graph written under an IRI has it, and an update refuses it where the update itself writes it: as a node-level or ["graph", …] name, as a VALUES value (in the where or the update's values), and as a fromNamed alias. A graph registered under it anyway (see Follow-ups) makes an update that reads the default graph by name fail rather than read that graph. A name bound from data (a string or an IRI) can still name it, as bound names reach the dataset's other aliases; it reads only the graph the same where already reads by "default". Otherwise the where's own default graph is the ledger's, and nothing is added.
  • Body options. opts.uniqueProperties is read from the body along with opts.shapes, with the precedence validationMode already has (a programmatic setting wins). A build without SHACL support refuses inline shapes rather than dropping them.
  • Enable recipe. The single-recipe form (OPTIONAL … BIND(COALESCE(?c0, <iri>) AS ?c)) fails on a ledger without config: an IRI produced by BIND cannot be an INSERT template subject. The docs give two recipes, attach to the existing config subject (INSERT … WHERE) and INSERT DATA for a ledger without config; both are tested. The BIND limitation predates this PR (see Follow-ups).
  • vocabulary.md txn-meta example reads with "from": "mydb:main#txn-meta": a node-level @graph in where is now a GRAPH pattern, and resolving a ledger alias as a graph name inside where is separate work.
  • The annotation-scope mutation. The annotation walker no longer carries the update graph key's scope (siblings take the parser's root scope, and one emitter anchors them), so the mutation as written no longer applies. Its remaining counterpart, the top-level routing graph key not being read as a selector, had no test; one was added and the mutation fails it.
  • The no-config mutation. As specified (no config → validate every graph), it cannot fail the explicit-only matrix, whose rows all carry a config; a second mutation (no SHACL group → validate) covers them.
  • Config guard, single-valued set. The seven group pointers and every setting the reader takes one value of (listed in config_guard.rs). Multi-valued, not checked: f:policyClass, f:reasoningModes, f:constraintsSource, f:graphOverrides, f:allowedIdentities, f:property, f:ontologyImportMap. Several f:GraphConfig overrides for one graph are reported by the duplicate-graph-override diagnostic rather than refused.
  • Config read failure. Tested through a #[cfg(test)] fault hook in load_transaction_config (unit test in tx.rs): config reads fail only on storage errors, which an integration test cannot raise without failing the write for other reasons. The status and error class are unchanged from base (400, Parse error: failed to load ledger config: …): the SHACL path already failed closed this way, and uniqueness now does too.
  • Override refusal message. inline SHACL shapes (opts.shapes) refused by this ledger's override control (f:overrideControl f:OverrideNone) for the default graph (or for graph <iri>): no backticks, and the default graph named in words.
  • Shapes-present diagnostic uses the existing type-instance lookup rather than a LIMIT 1 probe (ledger info only; shape counts are small).
  • Reifier cross-check runs once any template names a graph (see Performance).
  • Graph-sync target label. A malformed sync target is still refused as sync target: … (an existing test pins the label). A graph insert or sync whose payload addresses another graph is refused as payload must not address a graph other than the target (it said "named graphs", even for "default").
  • fluree info prints configDiagnostics for local ledgers; a tracked (remote) ledger's diagnostics are in its ledger-info JSON.

Commits

27 commits on 61b836e9a, reviewable in order; every fix commit carries its tests, and commits 17–27 answer an adversarial review pass (Claude). The diff is 83 files, +12.5k / −2.3k.

The commit list, and the diff by kind
  1. bench: jsonld_parse, a parse-only JSON-LD transaction bench (baseline; no production change).
  2. json-ld: one @graph classifier shared by every reader (graph_shape.rs, is_graph_key).
  3. transact: one lexical graph scope for JSON-LD templates (GraphScope, WriteGraphs, one resolver over dataset_ref::GraphSel).
  4. tests: JSON-LD graph scope end to end, with SPARQL twins.
  5. transact: JSON-LD 1.1 named-graph objects; only envelopes carry txn-meta.
  6. transact: one blank-node label sequence per JSON-LD document.
  7. json-ld/transact: @reverse, @included and @nest; refuse other keywords.
  8. transact: annotation lowering shares the scope; one f:reifiesGraph emitter.
  9. query: node-level @graph in a JSON-LD where is GRAPH sugar.
  10. import: bulk JSON-LD import refuses @graph scoping.
  11. api: config reader normalization + diagnostics.
  12. tests: SHACL tests enable SHACL explicitly (no behavior change; see the test-helper row of the mutation table below).
  13. api: explicit-only SHACL posture.
  14. api: resolve validation artifacts only for participating graphs.
  15. api: staged config guard, merged with the reasoning-mode check.
  16. docs: graph scoping, explicit-only SHACL, config checks and recipes.
  17. json-ld: a top-level single bare {"@id"} in @graph is envelope content.
  18. query/transact: a where node's @graph names its graph as templates do (parse/graph_name.rs, shared by the template resolver and the where sugar).
  19. api: one post-staging check tail for every write lane (check_staged_write, WriteChecks).
  20. merge: validate fast-forwards into a target whose config governs writes.
  21. api: the config guard refuses a group split across transactions.
  22. transact: read opts.shapes and opts.uniqueProperties from the body.
  23. api/transact: review nits (annotations under a variable graph, the graph-insert selector message, two diagnostics).
  24. docs: compatibility notes for the review changes.
  25. merge/rebase/revert: re-check the target's config groups.
  26. api: the config guard reads config only for config writes (is_config_field).
  27. query/transact: a where's "default" is the ledger's default graph.

Diff (83 files, +12.5k / -2.3k): production Rust about +5.1k / -1.8k (excluding in-file test modules), tests about +6.9k (+4.8k integration and shacl_tests.rs, +2.1k in-file), docs +0.3k, bench +0.2k.

Tests and non-vacuity

New and changed tests cover every area this touches: JSON-LD scope and named graphs, txn-meta, annotations, the where graph names, the bulk-import refusal, the config reader and diagnostics, the explicit-only matrix, fast-forward validation and uniqueness on branch operations, inline shapes from options and from the body, the cross-ledger fail-closed pairs, and the config guard on every lane. Every fix was checked by reverting only the fix, watching its tests fail, and restoring. The area list and the mutation table are folded:

Tests by area, and the mutation table

New and changed tests, by area (all in the owning groups): JSON-LD scope, named graphs, blank issuer, keywords and the refusal-prefix table (fluree-db-transact unit tests); graph scope end to end with SPARQL/TriG twins (it_jsonld_graph_scope); txn-meta (it_txn_meta), including the single-object envelope; annotations, including a variable graph and the ledger's own address as the update's graph; node-level @graph in where and its name resolution, including "default" under a graph key and under from (with the SPARQL spelling), a graph named @default beside such a where, the reserved name refused where an update writes it, and a graph registered under the reserved name; bulk import refusal; config reader and diagnostics; the explicit-only matrix over SPARQL-written configs; the Turtle, merge and push lanes without config; fast-forward validation, governed and ungoverned, and uniqueness on branch operations and Turtle insert (it_branch_shacl, it_config_graph); inline shapes and unique properties, from options and from the body (it_shapes_inline, the CLI, and over HTTP in override_control_identity); the dropped-model brick and fail-closed pairs (it_shapes_cross_ledger, it_constraints_cross_ledger, it_policy_cross_ledger, it_config_graph); the injected config-read failure (tx::tests); the config guard on JSON-LD, SPARQL, TriG, Turtle and a branch, across transactions, and on merges, fast-forwards and reverts (refusals and accepted cases, with previews); the config-field vocabulary; the documented recipes and examples.

Every fix was checked by reverting only the fix (a mutation), watching its tests fail, and restoring:

Mutation Killed by
classifier: bare graph always a graph key; top-level selector treated as envelope graph_term_in_context_is_a_property_not_a_graph_key, top_level_graph_selector_is_a_node_not_an_envelope
nested nodes scoped to the default graph 6 scope unit tests; 7 of 8 end-to-end scope tests
resolver drops the Var arm; "default" keyword dropped variable_graph_in_update_templates_is_var; the default-keyword test
named-graph content treated as a selector 6 named-graph tests
txn-meta keyed on contains_key("@graph") 2 unit + 2 integration
a top-level single node object in @graph read as a selector the doc_shape table, single_bare_reference_envelope_keeps_its_txn_meta, single_bare_reference_envelope_is_refused_like_its_array_form
issuer reset per item; no collision re-parse; annotation-label firewall off issuer unit + integration; user_b_label_never_collides…; firewall test
keyword refusal off; term @reverse forward; @nest off; adapter @reverse off keyword tests (4 mutations)
emit anchor off unit + it_jsonld_graph_scope + 27 existing annotation tests
walker ignores ["graph", g, …] items (re-run after the cross-check's perf change) the reifier cross-check fires (annotation_bundle_lands_in_and_names_the_edge_graph)
walker skips an invalid @graph value without scanning it for annotation keywords an_annotation_key_on_a_wrapper_is_still_refused (existing)
(redefined, see Deviations) the root routing graph key read as a selector root_graph_routing_key_does_not_scope_annotations
annotations under a variable graph not refused annotations_under_a_variable_graph_are_refused
an annotated edge under the ledger's own address keeps its reifier anchor annotation_under_the_ledger_address_lands_in_the_default_graph
where node-level @graph off node-level where integration test
where name taken as written (no expansion); "default" inside a graph pattern accepted (2 sites); a query's fromNamed aliases not passed node_level_graph_in_where_resolves_names_like_templates (unit and end to end); node_level_graph_in_query_keeps_from_named_aliases
import refusal off import integration test
reader takes an empty group; diagnostics off (2) reader and diagnostics tests
diagnostics: per-graph enablement ignored; duplicate overrides not reported diagnostics_count_shacl_enabled_for_one_graph; diagnostics_report_duplicate_graph_overrides
no config → validate every graph (the heuristic) shapes-only, Turtle, merge and push lanes
no SHACL group → validate explicit-only matrix
remove the enable config from the test helpers (after commit 13) about 70 shacl_tests, 6 it_branch_shacl, GraphQL and Turtle tests; before commit 13 the same tests still pass through the heuristic, so the migration changed no behavior
inline shapes gated on f:shaclEnabled; override gate off; refusal downgraded to a warning; inline pass off; inline pass compiles stored shapes the corresponding it_shapes_inline rows
inline shapes' policy-scope check off (staging; server); config check reads only the ledger-wide policy group embedded policy-scope tests; the HTTP test; inline_shapes_are_refused_under_policy_defaults_for_any_graph
the body's opts.shapes / opts.uniqueProperties not read body_constraints_are_read_from_opts; 3 it_shapes_inline body tests; insert_honors_inline_shapes_in_the_body (CLI)
a build without SHACL support drops inline shapes instead of refusing them inline_shapes_are_refused_without_shacl_support
resolve before the participation check dropped-model tests (SHACL off; schema) and the fail-closed pair
system graphs participate (SHACL; SHACL and uniqueness) config-repair rows (shapes, local graph, constraints)
cross-ledger policy failure treated as no policy the policy counter-test
read error treated as no config config_read_failure_refuses_a_governed_write
schema source resolved before the no-policy shortcut the dropped-schema test
the check tail skips uniqueness 12 tests, including unique_enforced_on_turtle_insert, merge_enforces_uniqueness_like_a_transaction and the uniqueness fast-forward test
a branch-operation preview reports only SHACL rejections merge_enforces_uniqueness_like_a_transaction
fast-forward not validated; no target governed; every target governed; preview skips a fast-forward 3, 4, 1 (fast_forward_into_an_ungoverned_target_is_not_validated) and 3 of the fast-forward tests
the split-group, typed-outside and second-value checks off the split, typed-outside (incl. branch) and second-value tests
the split-group check across transactions: fields for an already-linked group; an edge to fields elsewhere; fields the transaction retracts still counted config_guard_refuses_a_group_split_across_transactions (each)
branch operations skip the guard; a guard refusal counted as a failure to check (previews error instead of reporting); the refusal does not point at the repair config_guard_refuses_a_merge_that_splits_a_group, config_guard_refuses_a_fast_forward_carrying_a_split_group, config_guard_refuses_a_revert_that_undoes_a_repair (each)
a fast-forward into an ungoverned target is never checked config_guard_refuses_a_fast_forward_carrying_a_split_group
every f: field outside the config graph counted as a split group config_guard_merges_a_branch_while_the_target_changes_its_config
every f: predicate outside the system graphs counted as a config field (an annotation write reads the config) config_fields_are_the_reader_vocabulary; the write's outcome is the same either way, so only the vocabulary test can tell
a where's "default" read as the where's own default graph under graph / from node_level_graph_in_where_resolves_names_like_templates (unit), where_default_is_the_ledger_default_graph
the update does not add the ledger's default graph to its WHERE dataset; the where's use of the name is not recorded where_default_is_the_ledger_default_graph (each), and update_where_refuses_the_reserved_graph_name (unit) for the second
the reserved name back to @default, a string a graph can be registered under where_default_leaves_a_graph_named_at_default_alone
the reserved name not refused as a node-level name, a ["graph", …] name, a where VALUES value (each also in the unit test), an update values value, a fromNamed alias update_refuses_the_reserved_graph_name (each)
a graph registered under the reserved name read instead of refused; the name added whenever the where reads another default graph, used or not where_default_refuses_a_graph_registered_under_its_name (each)

Gates

Rebased onto main at 61b836e9a (#1997 merged), keeping #1997's semantics where the JSON-LD parser and update docs conflicted. At the head (tree fd198bfd2), on a shared Mac under load, these pass: fmt; the --all-features, default-feature and no-default-feature checks; clippy -D warnings; both wasm32 clippy runs; doc tests; and testsuite-sparql. nextest is 4,487 passed / 1 skipped over the transact, JSON-LD, query, core, GraphQL, CLI and server crates; 4,185 passed / 20 skipped for fluree-db-api with shacl,graphql (3 background-indexing tests timed out at nextest's 6-minute limit under load, and each passed alone; a fourth that timed out in the previous run passed in this one); 839 for the api lib without SHACL; and 1,153 over the remaining crates. Not run locally: the workspace-wide --all-features nextest that CI runs (api's iceberg, sql, delta, aws, credential and vector targets, and the crates not listed), testsuite-shacl, the wasm probe and browser jobs, the live SQL bridge lane, and the benchmarks: the shacl ones ran on EC2 instead (see Performance), and the default-feature ones didn't run.

Full gate log

Rebased onto main at 61b836e9a (#1997 merged). The JSON-LD parser and update-docs conflicts were resolved keeping #1997's semantics: the update's graph key opens the template-default scope (Txn::template_default_graph, TripleTemplate::graph_from_template_default), which staging maps to the default graph when it names the ledger's own address (ir::names_ledger); a node's @graph, a ["graph", …] item and a named-graph object resolve the address through the registry as before. Run locally at the head (tree fd198bfd2) on a shared 16-core Mac under load from other builds (load 6-18).

  • cargo fmt --all -- --check: pass.
  • cargo check --workspace --all-targets --all-features --locked: pass.
  • cargo clippy --all --all-features --all-targets --locked -- -D warnings, after touching every changed file: pass.
  • cargo check --workspace --all-targets --locked (default features): pass.
  • cargo check -p fluree-db-server --no-default-features --features native --locked: pass. Its 2 warnings are in files this PR does not touch.
  • Both wasm32 clippy invocations from CI (-D warnings): pass.
  • cargo nextest run over fluree-db-transact (unit and integration tests), fluree-graph-json-ld, fluree-db-query, fluree-db-core, fluree-db-graphql, fluree-db-cli and fluree-db-server: 4487 passed, 1 skipped.
  • cargo nextest run -p fluree-db-api --features shacl,graphql: 4185 passed, 20 skipped (#[ignore]), 3 timed out at nextest's 6-minute limit: it_fwd_pack_compaction::a_namespace_that_goes_quiet_stays_bounded_and_readable, it_fwd_pack_compaction::incremental_cycles_compact_the_forward_pack_tail and distinct_object_count_counts_numbig_object_keys_exactly, background-indexing tests whose subject this PR does not change, which time out under load in every full run here. Each then passed alone with no limit (cargo test, 866 s, 652 s and 400 s). In the previous full run, one change earlier, indexed_rdf_type_star_count_exact_across_incremental_builds also timed out (it had passed in 304 s and 337 s before) and then passed alone in 226 s; it passed in this run.
  • cargo nextest run -p fluree-db-api --lib (default features, so without SHACL): 839 passed, 1 skipped.
  • cargo nextest run over fluree-db-memory, fluree-db-mcp, fluree-db-cypher, fluree-db-shacl, fluree-db-policy, fluree-db-reasoner, fluree-db-sparql and fluree-db-nameservice-sync: 1153 passed.
  • Doc tests for fluree-db-api (shacl,graphql), fluree-db-transact, fluree-graph-json-ld, fluree-db-core and fluree-db-query: pass.
  • testsuite-sparql (fmt, clippy with -D warnings, and cargo test, as CI runs them): pass, with w3c_sparql 36 manifest-level tests, w3c_rdf 3 and 45 unit tests.

Not run locally:

  • The workspace-wide --all-features nextest, which CI runs: fluree-db-api's iceberg, sql, delta, aws, credential and vector test targets, and the crates not listed above.
  • testsuite-shacl (excluded workspace, not in CI). It validates through validate_ledger, which this PR does not change.
  • The wasm probe and browser jobs, and the live SQL bridge lane.
  • Benchmarks with the shacl feature, and the default-feature benchmarks at this head (see Before ready).

Times fluree_db_transact::parse_transaction alone (context handling,
edge-annotation lowering, expansion, template construction) with no
ledger, staging or commit, so parser changes are attributable. Four
shapes: a flat insert envelope, depth-3 node trees under a node-level
graph selector, an update of ["graph", g, node] items, and annotated
edges. Registered in regression-budget.json and the bench categories
table.
graph_shape::classify_graph_value decides what a node's @graph value is:
a graph selector (a string, a bare {"@id"} object, or a one-element
array of either), JSON-LD 1.1 named-graph content (node objects), or
invalid (numbers, booleans, null, value objects, nested arrays).
doc_shape classifies a top-level object as an envelope, a named graph or
a node from the same answer, and is_graph_key recognizes @graph, a
context alias of it, and the legacy bare `graph` key only when the
context does not define `graph` as a term.

The expander adopts both: a context term `graph` is now an ordinary
property at every level instead of being hijacked as @graph (the triple
was dropped and the node re-routed), and a top-level @graph is an
envelope only when it holds node objects and the object has no @id; a
top-level graph selector expands as a node.
A JSON-LD node's statements, and those of every node nested in it, now
land in the node's scope: its own graph selector if it has one, else the
enclosing node's. Before, only a node's own triples followed its
"@graph" selector; nested nodes, anonymous nodes and @list items fell back
to the default graph (or the update's "graph" key), so the nested config
groups of #1979 landed in the default graph, a delete or upsert of a nested
node under a selector retracted in the wrong graph, and list members'
properties split from their list.

- GraphScope (ir.rs) is passed down the recursion by reference; every
  value/list/node function takes one, so a nested node cannot be emitted
  without a scope. WriteGraphs::scope is the only way to get a named
  scope, so every graph a template names is registered as a write target.
  The ambient TemplateParseCtx::default_graph and its save/restore are
  gone.
- One graph-name resolver for node selectors, ["graph", g, ...] items and
  the update "graph" key: a variable (update templates only) is the WHERE
  binding (TemplateGraph::Var, as SPARQL GRAPH ?g), a fromNamed alias its
  IRI, then the keyword table (dataset_ref::GraphSel, new in
  fluree-db-core): "default" is the default graph everywhere, "config" the
  ledger's config graph, "txn-meta" is refused; anything else expands
  @id-style and must be an absolute IRI. A variable graph used to write a
  graph literally named "?g"; "default" meant a graph named "default"
  outside the update key; relative names minted relative-IRI graphs.
- Graph insert / sync parse with the request's graph as the fixed root
  scope instead of re-homing templates afterwards; a selector naming any
  other graph is refused.
- parse_transaction / parse_graph_insert / parse_sync_transaction take the
  target ledger id (for the "config" keyword).
The #1979 recipe from docs/ledger-config/writing-config.md, verbatim:
all four statements land in #config and the configured SHACL refuses
the forbidden record. Nested @id, anonymous and @list-item nodes land in
the selector's graph (P1, P10'); a node selector beats the update graph
key for its subtree (P7c); delete and upsert of a nested node act in its
graph (P7e, P9); a variable graph writes the WHERE-bound graph; the
"config" keyword writes #config. Each compares against the SPARQL
spelling of the same write where one exists.

Renames it_config_graph::config_write_json_ld to
config_write_trig_iri_groups: its body writes TriG.
A node object with @graph content, {"@id": G, "@graph": [nodes]}, is a
JSON-LD 1.1 named graph (section 4.9): the content nodes are written to G
and the node's own properties stay in the enclosing scope. Named graphs
nest (innermost wins) and may appear as property values. Before, the
content was dropped and the first content node's @id was read as the
node's graph selector; a property-bearing object @graph lost its data the
same way.

- A named graph needs an IRI name: a blank-node or variable name, or a
  graph object without @id below the top level, is refused.
- Graph insert / sync refuse named-graph objects (the request names the
  graph). {"@id": G, "@graph": []} is no longer mistaken for the
  explicitly empty envelope a sync may clear a graph with.
- txn-meta extraction and the reserved-key strip use doc_shape: only an
  envelope's top-level keys are metadata. A single node carrying a graph
  selector (P10e) and a named graph's own keys are data; they used to be
  written as data and also as txn-meta on the commit.
- doc_shape and the expander: at the top level, an array @graph without
  @id is an envelope even when it holds one bare {"@id"} node, as every
  reader has always treated it; a single property-bearing object @graph
  is an envelope too (the single-node write form some clients send).
Anonymous nodes are labelled _:b0, _:b1, ... by one issuer for the whole
transaction body. The counter used to restart for every expansion call,
so the anonymous nodes of different ["graph", g, ...] items (and of the
plain items after them) all became _:b0 and merged into one node.

A user-written label of the issuer's own form (_:b3) would merge with the
anonymous node given that number. The parse notes such labels (templates
and VALUES), and when one was also issued it parses the document once
more with the issuer skipping those numbers. The positional labels are
part of a stored blank node's identity under upsert and sync, so their
spelling is unchanged; only documents that were being corrupted get
different labels.

The edge-annotation firewall now refuses user labels in the lowering's
reserved _:fluree_ann_ prefix, which would merge with the lowering's
anonymous annotation nodes.
Keywords on node objects no longer leak into the data as predicates named
after the keyword:

- @reverse (JSON-LD 1.1 section 4.8): each value is the subject of a
  statement pointing at the node, written in the node's scope. The
  expander used to write a context term defined with @reverse as a
  forward property, inverting the statement; it now expands into the
  node's @reverse map, as the keyword does.
- @included (section 4.7): the included nodes are written in the node's
  scope with no linking statement.
- @nest (section 4.6): the expander merges a nested value's properties
  and types into the enclosing node, recursively; a nested value cannot
  carry @id or @graph.
- @index carries no statement and is dropped.
- Any other key of JSON-LD keyword form (@ then letters, e.g. @language
  on a node) is refused. Keys that merely start with @ (@odata.etag) are
  ordinary property names, as before.

Bulk import reads @reverse and @included the same way (they were
dropped). The edge-annotation lowering treats @included nodes as nodes
and refuses an annotation inside @reverse or @nest, which it would have
lowered against the wrong edge.
…itter

The edge-annotation lowering computed "which graph is this edge in" on
its own, and disagreed with the parser: it ignored the update's graph key
(A1: the annotation was refused as a GraphMismatch), the ["graph", g, ...]
item (A2) and the array selector (A3), writing the bundle to the default
graph without an anchor while the edge was in g, and never lowered
annotations inside named-graph content.

- The walkers read @graph with the shared classifier and is_graph_key
  (aliases included; the root document's bare `graph` routing key is not a
  selector), recognize ["graph", g, ...] items in update clauses, and
  descend into named-graph content with the graph's @id as the scope.
  Siblings are placed with the edge's scope as their graph selector, which
  the parser resolves exactly as it resolved the edge's. attach_siblings
  reads the document shape: a top-level named graph is re-wrapped, not
  appended into its content.
- GraphScope::emit is the one template-level writer of f:reifiesGraph: a
  reifier's f:reifiesSubject emitted in a named scope brings
  (reifier, f:reifiesGraph, scope graph) with it, for assertions and
  retractions alike. The walkers' stamps and graph insert's post-hoc
  anchor loop are deleted.
- With #1997, an update's `graph` key that names the ledger's own address
  writes the default graph: staging moves the templates of that template
  default there. An annotated edge under it now moves with its bundle,
  and staging drops the bundle's anchor, which named the address: a
  default-graph bundle has none, so the annotation is found with its edge
  in the default graph.
- A fail-closed cross-check: after an annotated document parses, every
  reifier bundle must reify an edge asserted in the bundle's own graph.
  It runs once any template names a graph (a document entirely in the
  default graph has no scoping to disagree about) and allocates nothing
  per template, which keeps the annotation parse bench within noise.
A node-level "@graph" selector in a where node-map now scopes that
node's patterns (and those of nodes nested in it) to the named graph,
exactly as ["graph", <name>, {...}] does, so the same key means the same
scope in where, delete and insert. It used to be parsed as a predicate
named @graph: a query matched nothing, and an update's WHERE matched
nothing, so the update committed zero flakes and its target survived.

The name follows the existing where-clause GRAPH rule (an alias or the
graph's IRI as written); a context alias of @graph works the same way,
and a bare `graph` key stays an ordinary property in a pattern. A node
with @graph content (a JSON-LD named graph) is refused in a where.
The bulk-import JSON-LD adapter skipped every @-key but @type, so a
node's graph selector was dropped (its statements folded into the
default graph) and a JSON-LD named graph's content never reached the
sink. The GraphSink contract calls that folding data loss. The adapter
now refuses a node-level @graph (selector or content) with a message
pointing at `fluree insert` or a TriG / N-Quads import. Writing graph
scopes from bulk JSON-LD (ImportSink quads) is left for a follow-up; a
top-level envelope is unwrapped by expansion and imports as before.
The config reader took the first binding of a setting-group pointer, so
a config with two f:shaclDefaults values (a re-inserted config) could
read either group, and a group whose fields were written outside the
config graph (the #1979 shape) read as a present-but-empty group.

- A group that sets nothing in the config graph reads as absent. That is
  observably the same as before for every group; it makes the reader
  deterministic and keeps "empty group" from diverging from "no group".
- Several values of one pointer: empty groups are skipped and the rest
  ordered by decoded IRI, the rule several f:LedgerConfig subjects
  already follow. A rate-limited warning names the pointer.
- config_resolver::diagnose reports degenerate config state: several
  f:LedgerConfig subjects, ambiguous group pointers, empty groups and
  the graphs their fields were stranded in, and shapes that no config
  enables ("shapes present; SHACL enforcement not configured").
- Ledger info carries them as `configDiagnostics` (omitted when there
  are none), and `fluree info` prints them.
The SHACL enforcement tests relied on shapes alone enabling validation (the
shapes-exist heuristic), which the next commit removes: shapes will be
enforced only where the ledger config sets f:shaclEnabled true. Each test
that exercises enforcement now writes that config with its shapes.

- shacl_tests.rs: every ledger is created by create_shacl_ledger, which
  commits the enabling config after creation.
- tests/support: shacl_enabled_config_node() (a context-free config node
  for a test's @graph) and enable_shacl().
- it_branch_shacl, it_config_graph (shacl_config_disables_validation,
  shacl_turtle_insert_rejected_when_violating), it_optimistic_rebase,
  it_graphql_mutations and the server's graphql_http_integration write the
  config beside their shapes.

No behavior change: every migrated test passes with the heuristic present,
and (checked with the heuristic removed) passes on the config alone.
Fixes the other half of #1979: a ledger with shapes and no config enabling
SHACL enforced them anyway (the shapes-exist heuristic), while any config
switched that off unless it also set f:shaclEnabled true. SHACL is now
enforced exactly where the effective config (ledger-wide merged with the
graph's override) sets f:shaclEnabled true, as override-control.md and
setting-groups.md already document. Breaking for ledgers that relied on
shapes alone: they stop enforcing until SHACL is enabled in config (ledger
info reports "shapes present; SHACL enforcement not configured").

- apply_shacl_policy_to_staged_view: the config-driven per-graph map is the
  only source of participating graphs. When no graph participates it
  returns before compiling shapes. Every lane shares it: JSON-LD and SPARQL
  transactions, Turtle insert, branch merge/rebase/revert, push.
- validate_view_with_shacl takes the per-graph map as required, so "no map
  means validate every graph in Reject" cannot be expressed.
- Inline request shapes (opts.shapes) are a request-time setting: they
  validate every user graph the transaction writes, in the requested mode,
  in a pass of their own compiled from the request's bundle alone, so they
  never switch the ledger's stored shapes on. Each graph's effective SHACL
  override control gates them (not f:shaclEnabled); where it refuses them
  (f:OverrideNone, or f:IdentityRestricted without a matching verified
  identity) the transaction fails with RequestOverrideRefused (400) rather
  than committing unchecked data. The stored-shape compile cache no longer
  goes unused when a request carries inline shapes.
- Inline request shapes are not supported in a policy-scoped request: one
  with a policy context that restricts anything or with policy inputs on
  the HTTP request, or on a ledger whose config sets policy defaults
  (ledger-wide or for any graph) other than an unrestricted
  f:defaultAllow true. Such a transaction fails with "Unsupported feature:
  inline request shapes are not supported in a policy-scoped request"
  (400). Support for inline shapes in policy-scoped requests is a
  follow-up.
- CLI and GraphQL texts that described shapes as activating validation are
  corrected.

Tests: the heuristic test becomes shacl_shapes_without_config_do_not_enforce
(the #1979 control; config_write_json_ld_nested_groups_land_in_config is its
enabled twin), and the heuristic's warn-refusal test is removed. New: the
explicit-only matrix over SPARQL-written configs, the Turtle, merge and push
lanes without config, and the inline-shapes rows (shaclEnabled false,
OverrideNone with nothing committed, IdentityRestricted, per-graph refusal,
warn mode, stored shapes untouched, and the policy-scoped refusals, embedded
and over HTTP).
A data ledger whose config names a cross-ledger shapes source, with
f:shaclEnabled false, refused every write once the model ledger went away,
including the config write that would fix it: the transaction path resolved
the shapes wire, the model ledger and the cross-ledger schema before it
looked at whether SHACL was enabled. A local f:shapesSource naming a missing
graph, and uniqueness with a cross-ledger constraints source, bricked config
repairs the same way. Uniqueness also read a config it failed to load as "no
config", so a read error admitted the write unchecked.

- apply_shacl_policy_to_staged_view owns resolution: it computes which
  written graphs participate (stored shapes where config enables SHACL,
  inline shapes where override control permits them) and returns before
  resolving any shapes source, schema source or model ledger when none
  does. Callers pass the per-transaction ResolveCtx instead of pre-resolved
  wires; the pre-resolution in the JSON-LD/SPARQL, Turtle insert and branch
  operation paths is gone. Push has no resolver and keeps skipping
  cross-ledger re-validation. A stored graph's participation is decided per
  written graph, so a graph the transaction does not write never triggers
  resolution.
- The system graphs (#config, #txn-meta) never participate in SHACL or
  uniqueness, so a transaction that writes only config resolves no shapes,
  schema or constraints source and is never refused because one is
  unavailable. Data writes where enforcement is on still fail closed, with
  the resolver's error naming the model ledger.
- Config is read once per transaction, after staging, and shared by SHACL
  and uniqueness; a read error now fails the transaction on every lane
  (uniqueness included), and a transaction that writes only system graphs
  reads none.
- build_transact_policy_context resolves a cross-ledger schema source only
  when it builds a policy context, so a write without policy does not depend
  on that model ledger either. Policy sources are unchanged and still fail
  every write closed, config writes included: policy decides who may write
  the config.
- The Turtle insert path now reads the cross-ledger schema source for SHACL
  targeting, as the other lanes do.

Tests: the dropped-model brick with SHACL off, the fail-closed pair with a
JSON-LD and a SPARQL config repair, a cross-ledger schema source with SHACL
off, the local unknown-shapes-graph analog, the uniqueness analog, the
policy counter-test, and an injected config read failure that refuses a
uniqueness-governed write.
Ledger configuration is read only from the ledger's own config graph, from
one f:LedgerConfig subject, taking one value of each single-valued setting.
Writes that break this committed and then quietly did something else: a
setting group whose fields landed in another graph read as empty (#1979),
a config typed in another graph was never read, and of two values for one
setting the reader picked one. The guard refuses each before commit, as a
Parse error (HTTP 400) whose message says how to write it:

- a config edge (f:*Defaults, f:graphOverrides, f:overrideControl, the
  source and reference edges) written into the config graph whose target
  node gets f: fields in another graph in the same transaction;
- rdf:type f:LedgerConfig / f:GraphConfig written outside the ledger's own
  config graph;
- a single-valued config predicate that would hold more than one value
  after the transaction (pre-transaction values, less those retracted, plus
  those asserted), or a second f:LedgerConfig subject. Writing the same
  value again is not refused.

The guard reads only the staged flakes and the pre-transaction config graph,
never a shapes, schema, constraints or policy artifact, so a config repair is
judged by it alone. One pass over the staged flakes establishes that a plain
data transaction writes nothing config-shaped, so it costs no more than the
reasoning-mode check it absorbs. It runs where transactions are authored:
JSON-LD and SPARQL transactions and, newly, Turtle insert. Commit replay and
bulk import skip it.

The test seeding two f:LedgerConfig subjects now writes them unchecked, as
history written before the guard would reach a ledger.
- reference/compatibility.md: behavior changes for this release. SHACL is
  enforced only where the ledger config enables it (breaking), inline
  request shapes follow override control and are not supported in
  policy-scoped requests yet, config writes are checked and config-only
  writes are never blocked by validation artifacts, and JSON-LD nested
  nodes follow their enclosing graph (named graph objects are stored);
  JSON-LD 1.1 support list updated (named graph objects, @reverse,
  @included, @nest; @index ignored; other @-keywords refused).
- ledger-config/writing-config.md: nested groups inherit the node's
  @graph; the "config" keyword, the named graph form and the update graph
  key; enabling SHACL on the existing config subject; what a config write
  is checked for; the repair recipe for configs split by #1979.
- setting-groups.md, guides/cookbook-shacl.md, cli/model.md,
  cli/graphql.md: shapes alone never enable SHACL; the explicit-only truth
  table; inline shapes under override control.
- transactions/insert.md and update-where-delete-insert.md: named graphs in
  JSON-LD, nested inheritance, "default" / "config", variable graphs,
  relative graph names refused.
- concepts/reasoning.md (config written to the config graph),
  indexing-and-search/fulltext.md (the HTTP recipe posts to /insert),
  reference/vocabulary.md (txn-meta query through from),
  concepts/datasets-and-named-graphs.md, cookbook-edge-annotations.md,
  cli/info.md (config diagnostics).

Tests run the documented enable recipes, the JSON-LD config forms, the
graph-key update and the fulltext verification query.
With no top-level @id, a one-element array [{"@id": X}] was an envelope
but the same value as a single object {"@id": X} was read as a graph
selector: the document became an anonymous node in a new graph X, and its
other keys (f:message and the like) were written there as data instead of
being transaction metadata. Solo's MCP stamp_provenance produces exactly
that shape when an agent upserts a context-free bare reference. Base
refused it ("Insert must contain at least one predicate or @type").

is_envelope_graph now reads a single node object at the top level the way
it reads the one-element array (below the top level both bare forms stay
selectors), which restores base's reading for doc_shape, the expander and
txn-meta alike. The annotation walker reads the document's own @graph
through the same predicate, so it cannot disagree with the parser about
the root.

Tests: doc_shape table rows; txn-meta keeps its siblings for both forms;
the stamp_provenance shape gives the same outcome in both forms for insert
and upsert and registers no graph named after the reference.
The node-level @graph sugar in `where` took the graph name as written
(an alias or a full IRI), while the same key in `insert`/`delete`
expands compact IRIs and resolves keywords. One document, one name, two
graphs: `{"where": {"@id": "ex:a", "@graph": "ex:g", …}, "delete": {"@id":
"ex:a", "@graph": "ex:g", …}}` matched nothing and deleted nothing.

- The resolution order (variable, fromNamed alias, keyword, expansion
  against the @context) is now one function, classify_written_graph_name
  in fluree-db-query, and both sides call it: the template resolver in
  the transact parser and the where sugar, which maps the result to a
  GRAPH name. An update's where knows its ledger and aliases, so
  `config` and `txn-meta` name this ledger's graphs there and aliases
  stay the name the dataset resolves; a query passes its fromNamed
  names so they stay aliases even under @base.
- `default` in a where matches the where's default graph. A where pattern
  cannot leave the graph an enclosing selector chose, so `default` inside
  one (node-level or ["graph", …]) is refused rather than silently kept in
  the outer graph.
- A nested node's own @graph in `where` now scopes that node's patterns,
  as a nested selector scopes a node in a template (it was read as a
  predicate named @graph).
- The ["graph", <name>, …] array form in `where` keeps taking its name as
  written; the docs say so.

Tests: the classifier's order; where-side resolution (compact names,
keywords, aliases, default, nested default refused) and the query alias
case; end to end, the where+delete update with "@graph": "ex:g" deletes
what the insert wrote, and `default` and `config` read their graphs.
The checks a staged write runs before it may commit lived in three
copies: stage_and_check (JSON-LD, SPARQL, TriG, upsert) ran the config
read, SHACL, uniqueness and the config guard; the plain Turtle insert lane
copied the config read, SHACL and the guard but not uniqueness, so a
duplicate a JSON-LD insert refuses committed there (its comment claimed
parity); and branch operations (merge, rebase, revert and their previews)
ran SHACL only.

check_staged_write is now the one tail: one pre-transaction config read
(enforcement_config), then SHACL, uniqueness and, for authoring lanes,
the staged-config guard. WriteChecks carries what a lane brings (the
request's SHACL inputs, inline unique properties, tracker, @context,
whether the guard runs); a branch operation brings nothing, and does not
run the guard here.

- The Turtle insert lane calls it, so it enforces uniqueness like the
  other lanes.
- Branch operations call it from validate_branch_op_view, so a merge,
  rebase or revert that would leave a duplicate unique value is refused
  like the transaction producing that state (behavior change: they
  enforced only SHACL). BranchOpValidation carries the rejection itself
  (a SHACL report or a uniqueness violation) and previews report either.
- enforcement_config decides "writes a user graph?" with an early-exit
  scan instead of collecting and sorting a graph id per staged flake, and
  written_user_graphs collects into a set.

Tests: a plain Turtle insert refuses the duplicate a JSON-LD insert
refuses; a general merge of a branch write that duplicates main's unique
value is refused, and its preview reports the violation.
A fast-forward adopts the source's commits as they are and skipped
validation on the premise that they "were validated when they were
authored". Nothing guarantees that commits authored on another branch
satisfy the target's configuration, and the general merge and rebase
paths validate the same change against the target, so a fast-forward
could bring in what they refuse. Uniqueness had the same gap.

A fast-forward into a target whose config enables SHACL or uniqueness
(ledger-wide or for any graph) now runs the checks a transaction writing
the adopted change would face: the source line's net change since the
target's head is staged on the target's pre-merge state and checked by
the shared tail (check_staged_write) under the target's pre-merge
config, before any commit is copied or the ref advanced. A merge whose
commits also change the target's config is judged by the config it
merges into, as a transaction's own config writes do not apply to it.
The apply's ref CAS expects the validated head, so a commit that lands
on the target in between fails the merge instead of slipping past.

A fast-forward into a target that enables neither is unchanged: no
validation, no staging. The only added work there is reading the
target's config, from the ledger cache when it holds the target at the
merge's head, else from a load of the target.

The merge preview reports validation for a fast-forward exactly when the
merge validates it; docs (branch CLI, endpoints, Rust API, server
integration, cookbooks) say so instead of "validated when authored".

Tests: a violating branch write fast-forwarded into a SHACL-enabled main
is refused (preview non-conforming, head unchanged) and the general merge
of it too; a conforming change previews validated and merges; an
ungoverned target fast-forwards unvalidated with no validation in the
preview; the pre-merge config decides both ways (SHACL switched off on
the branch is still refused, switched on is not validated); a uniqueness
twin. The pinned "fast-forward preview carries no validation" test is
replaced by the governed and ungoverned cases.
R1 refused a setting group split across graphs only within one
transaction, so the #1979 end state was still reachable in two accepted
writes: an edge into the config graph first and the group's fields later
in the default graph (a policy group's f:defaultAllow false lost, fail
open), or the fields first and the edge later (a SHACL group read as off).
Only ledger info's configDiagnostics reported it.

The guard now also refuses, in either order:
- f: fields a transaction writes in a user graph for a node the config
  graph's edges already point at (the config graph is read once);
- a config edge a transaction writes into the config graph to a node that
  already has f: fields in a user graph (that node's statements in the
  user graphs are read).
Fields the same transaction retracts do not count, so moving them into
the config graph together with the edge (the repair) is accepted. The
extra reads run only for a transaction that writes the config graph or
an f: field in a user graph; a plain data transaction still costs one
pass over its staged flakes.

Tests: both orders refused with the group, the edge, the fields and the
graph named; the edge plus the moved fields in one transaction commits
and enables SHACL.
A transaction body's `opts.shapes` and `opts.uniqueProperties` were read
only by the HTTP server's route. The embedded API and the CLI's local
mode parsed the same body and dropped them, committing a record the
caller had asked to be checked.

parse_rooted, which every surface's JSON transaction goes through, now
fills both from the body when the caller set neither programmatically
(the precedence validationMode already has): `shapes` must be a JSON-LD
object or an array of them, `uniqueProperties` an array of IRI strings,
and anything else is refused as a Parse error instead of being ignored.
Whether the constraints apply is still decided at staging: override
control and the policy-scope rule for inline shapes. A build without
SHACL support refuses a transaction carrying inline shapes rather than
committing it unchecked.

Tests: the parser reads both for an insert and an update, keeps a
caller's own options, and refuses malformed values; through the embedded
API a record breaking the body's shapes is refused (insert and update),
a conforming one commits, the body's shapes are refused in a
policy-scoped request, and the body's unique properties are enforced;
the CLI in local mode refuses and accepts the same way; without SHACL
support the inline shapes are refused.
- An edge annotation under a variable graph (["graph", "?g", …] or
  "@graph": "?g") is refused while parsing, with a message that says
  so. Flake generation used to fail on the reifier's f:reifiesGraph ?g
  anchor ("Raw IRI from graph source cannot be used as object").
- A graph insert or sync whose payload addresses another graph through a
  selector ("default" included) is refused as "must not address a graph
  other than the target"; it said "named graphs" even for the default.
- The shacl-not-configured diagnostic counts SHACL enabled for any graph
  (a graph override), not only the default graph's posture, so a config
  that enables SHACL for one graph is not reported as unconfigured.
- A new diagnostic, duplicate-graph-override: several f:GraphConfig
  overrides for one target graph, of which the reader uses one.

Tests: the variable-graph refusal for both spellings; the graph-insert
refusal for another named graph and for "default"; per-graph enablement
clears the diagnostic; two overrides for one graph are reported.
The behavior-change list in docs/reference/compatibility.md gains what
the review fixes changed: a fast-forward into a target whose config
enables SHACL or uniqueness is validated against that config as it was
before the merge; merge, rebase and revert enforce uniqueness, and so
does a plain Turtle insert; a transaction body's opts.shapes and
opts.uniqueProperties are read by the embedded API and the CLI's local
mode; a where node's @graph resolves its graph name as templates do.
The staged-config guard ran only where transactions are authored, and
branch operations stage commits nobody checked as a combination: a
merge can bring together a config edge written on one side and the
group's fields written outside the config graph on the other, each
accepted where it was made, and a revert can undo the repair of a split
group. The result is the #1979 state: a group that reads as empty.

Merges (fast-forwards included), rebases and reverts now run the guard
on what they stage, in a branch-operation scope. R2 is enforced when the
data is written; branch operations re-check the integrity of the
target's config groups (R1, within the change and across transactions,
and R3). A refusal is the same Parse error (HTTP 400) a transaction
gets; it names the target's config group and points at the repair
("Repairing a config split across graphs"), and previews report it.

- config_guard: GuardScope picks the checks (Authoring: all of them;
  BranchOperation: R1 and R3, with branch-operation messages), and a
  GuardError tells a refusal from a failure to read.
- check_staged_write returns StagedWriteError, Rejected (a SHACL or
  uniqueness violation, or a guard refusal) or Failed, so a branch
  operation reports a rejection without matching error variants
  (is_rejection is gone).
- A fast-forward into a target that governs no writes is checked too
  when the commits it adopts carry config settings (an f: predicate, or
  an f: type): fast_forward_base returns the target's state with whether
  it is governed, and validate_fast_forward stages the adopted change
  only when either holds. Every other fast-forward is unchanged.
- Docs: branch CLI, endpoints, server integration, Rust API, cookbook,
  writing-config (what a config write is checked for) and compatibility.

Tests: a merge refused for splitting a group the target linked after the
fork (the branch's field written through JSON-LD and through SPARQL);
a fast-forward refused for carrying a split group from before these
checks; a revert refused for undoing a repair; and, accepted with the
guard running, a merge while the target changes its config and a revert
of a config change. Previews report each refusal. A fast-forward into an
ungoverned target that carries no config settings is not validated.
R1's gate fired on any f: predicate written to a user graph and then
read the config graph: every edge-annotation write (its f:reifies*
bundle), every f:enforceUnique annotation and every policy document paid
a config-graph read for nothing.

R1 now counts only the predicates the config reader reads as a group's
fields: the group pointers and source edges, the single-valued settings,
and the multi-valued ones (f:policyClass, f:reasoningModes,
f:allowedIdentities). Other f: data in a user graph is data, and writing
it reads no config. The same set decides what counts as a stray field
within one transaction and across two.

Test: the config-field set is the reader's vocabulary; f:reifies*,
f:enforceUnique and f:allow are not fields.
A node-level "@graph": "default" meant the ledger's default graph in a
template and the WHERE's own default graph in a where. They differ when
the update's graph key (SPARQL WITH) or `from` gives the WHERE another
default graph, so the natural move from the default graph into g1,
{"graph": "ex:g1", "where": {…"@graph": "default"…}, "delete":
{…"@graph": "default"…}, "insert": {…}}, read g1 and moved nothing.

In an update's where, "default" now names the ledger's default graph, as
in the templates. When the WHERE reads another default graph, the where
reads the ledger's default graph by a name the update adds to its WHERE
dataset, only when the where uses it; GRAPH ?g never enumerates it.
Otherwise the patterns stay as they are.

The name contains a space, so no graph written under an IRI has it, and
an update refuses it where the update itself writes it: as a graph name
in the where (node-level or ["graph", …]), as a VALUES value, or as a
fromNamed alias. A graph registered under it anyway (the Turtle lexer
decodes an escaped space inside an IRI) makes an update that reads the
default graph by that name fail, rather than read that graph instead.

A where pattern still cannot leave an enclosing graph, so "default"
inside one is refused as before. In a query, "default" stays the query's
default graph.

Tests: the where-side resolution under another default graph, the
refusal inside an enclosing graph, the reserved name refused where an
update writes it, and the record that the where read by it; end to end,
the move under a graph key and under `from`, with its SPARQL spelling; a
graph named @default read by its name and listed by GRAPH ?g beside
such a where; the reserved name refused in each form; a graph registered
under the reserved name refused rather than read.
@aaj3f aaj3f added bug Something isn't working as expected breaking-change Backwards-incompatible change; drives the Breaking Changes release-notes section area:transact Staging, commit, import/bulk-import, novelty, retraction semantics labels Oct 1, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:transact Staging, commit, import/bulk-import, novelty, retraction semantics breaking-change Backwards-incompatible change; drives the Breaking Changes release-notes section bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant