Repository navigation
Conversation
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
marked this pull request as ready for review
October 2, 2026 02:07
This was referenced Oct 2, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 TriGGRAPH-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 nestedf:shaclDefaultsgroup 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 truestop 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:
@listitems, anonymous nodes,@reverse/@included/@nestcontent) are written to their enclosing node's graph. A nested node's own@graphwins for its subtree;"default"names the default graph.{"@id": G, "@graph": [...]}) are supported. Named-graph object content that was silently dropped is now stored.@graphclassifier and one graph-name resolution order (classify_written_graph_name), shared by templates andwhere: a variable, afromNamedalias, 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?vargraph name in an update template writes to the graph the WHERE binds (SPARQLGRAPH ?gparity); elsewhere it is refused.["graph", …]items; user labels never collide with minted ones).@graphselector does not. At the top level, a document without@idwhose@graphis 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.)f:reifiesGraphemitter (GraphScope::emit) and a fail-closed check that every reifier bundle sits beside its edge. An annotated edge under a top-levelgraphnaming 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.@graphin a JSON-LDwhereis aGRAPHpattern, and its name resolves in the order above, so an update whosewhereanddeleteboth 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-levelgraphorfromgives thewhereanother default graph, so moving data from the default graph into a named graph reads what it deletes. A nested node's own@graphscopes its patterns;"default"inside an enclosing graph pattern is refused (awherepattern cannot leave it).@reverse,@includedand@nestare implemented;@indexis ignored; other keyword-form (^@[A-Za-z]+$) keys on a node are refused. Non-keyword@keys such as@odata.etagbehave exactly as before.@graphscoping instead of folding it into the default graph.B2: config integrity and explicit-only SHACL.
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).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.Parse errora transaction gets, naming the target's config group and pointing at the repair recipe; previews report it.opts.shapes) validate on their own pass, gated by the SHACL group'sf:overrideControlrather thanf: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.shapesandopts.uniquePropertiesin 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.configDiagnostics(shacl-not-configured,empty-group,stranded-fields,ambiguous-pointer,multiple-ledger-configs,duplicate-graph-override), andfluree infoprints them.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 secondf:LedgerConfigsubject. 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. Otherf:data in a user graph (an edge annotation'sf:reifies*, a property'sf:enforceUnique, a policy document'sf: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 indocs/reference/compatibility.md#behavior-changes.f:shaclEnabled true(including shapes published through GraphQL orfluree 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 asshapes present; SHACL enforcement not configured. To keep enforcing, attach SHACL to the existing config subject (recipe below).fluree branch diff/GET /merge-previewreport itsvalidation. Fast-forwards into other targets are still not validated against shapes or uniqueness.@graphselector 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".@graphselector, and a named-graph object's own keys, are data only (they were written as data and as metadata). A top-level{"@graph": {…}}without@idstays an envelope, as before.@graphin a JSON-LDwhereis aGRAPHpattern, with the graph name resolved as in a template (it was read as a predicate named@graphand matched nothing). In an update,"default"there names the ledger's default graph, as in the templates, even under a top-levelgraphorfrom; in a query it names the query's default graph."config"names the ledger's config graph.f:shaclEnabled false, and are refused underf:OverrideNone); they are refused in policy-scoped requests.opts.shapesandopts.uniquePropertiesin a transaction body are honored by the embedded API and the CLI's local mode (they were ignored there), and malformed values are refused.@keys on a node are refused;@nestis honored;@indexis ignored. Edge annotations under a variable graph name are refused while parsing (they failed at commit with an unhelpful message).@graphscoping; usefluree insert, or TriG / N-Quads.Enable SHACL (ledger with a config subject;
docs/ledger-config/writing-config.md#enabling-shaclhas the variant for a ledger without config):Repair a config split by #1979 (policy and SHACL groups it affected: a split group's settings,
f:defaultAllow falseincluded, aren't in effect until it's repaired; merges, rebases and reverts that carry a split config are refused until it is repaired). Run withSELECT ?n ?p ?ofirst: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:
<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 asvalidation(mergeablefalse). Solo's review UI should show a fast-forward preview'svalidationtoo. Merge, rebase and revert also refuse uniqueness violations now, and a result that would split one of the target's config groups (aParse errornaming the group); a fast-forward whose commits carry config settings is checked that way even into an ungoverned main.readPosturerenders a ledger with no storedf:validationModeas "enforce" (graphs.ts:254-287,QualityTab.tsx:151). For ungoverned ledgers that hold their own shapes and no config (user imports, ledgers unbound withgovernanceTx(…, 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 setf:shaclEnabled trueand are unaffected.ensure-fulltext-configwould add a secondf:fullTextDefaultsgroup (make it idempotent). Optional: makepostureTxdelete the three predicates it asserts.…:knowledge) and semantic-transformmodel_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@idas pack-ner and apply-schema do. MCPstamp_provenanceon an agent document with a top-level@idand 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. MCPjsonld_update: awherenode's"@graph"now selects that graph, with compact names expanded against the document's@contextand"default"naming the ledger's default graph (it matched nothing before). Bulk import of JSON-LD with@graphscoping now fails with the engine message instead of folding the data.@odata.*keys behave as before; custom webhooks sending@nestnow merge it.Parse error:,JSON-LD error:orUnsupported feature:(all mapped to 400 by solo's router), exceptRequestOverrideRefusedfor inline shapes, which solo does not send, and a named-graph object whose@idis the ledger's#txn-metagraph: staging's existing reserved-graph check refuses it astransaction 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'supdate_sugarcase is 22% faster. The one bench that went over the 5% budget,jsonld_parseannotations 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% atsmall). No loss by the rule.jsonld_parsewins onupdate_sugar(−14.7%) andflat_envelope(−15.3%; the base there was bimodal, 148 or 170 ms, but the head was at or below it in every round), andannotationsis +4.4%, slower in every round but under the budget.insert_formats(JSON-LD and Turtle) is stable.transact_commitwas inconclusive onpopulated_ledgerin 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 withshacl, 7 alternating rounds againstmainat61b836e9a),transact_commitis stable by the rule:fresh_ledger−9.4% andpopulated_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 onpopulated_ledger(−0.4% to +1.7%) and 9-10% faster onfresh_ledger. To see whether the head does more work per commit, a harness (not committed;dev-fastprofile,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). Base61b836e9aagainst this head, 4 interleaved rounds of 290 fresh and 50 populated commits, medians:perf staton 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-fastprofile) 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 onto61b836e9a, 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:fluree branch mergeinto a target with a long unindexed history pays that replay, as every CLI command that reads the target does.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.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 basee4793617fagainst this branch as of commit 16, before its rebase ontobf523e24e. Each bench ran back to back (before, then after) in two rounds on a shared 16-core macOS machine (load 9-22). The untouched Turtleimport_bulkpath 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-@graphscan), neither of which runs on these documents, and commits 17-27 (see below). Medians:jsonld_parseflat_envelope (20k nodes)jsonld_parsenested_selector (20k nodes, depth 3)jsonld_parseupdate_sugar (1k items)insert_formatsjsonld 10 txn × 100 nodesinsert_formatsjsonld 10 × 10insert_formatstrig 10 × 100insert_formatstrig 10 × 10insert_formatsturtle 10 × 100insert_formatsturtle 10 × 10transact_commitfresh ledgertransact_commitpopulated ledgerimport_bulkdefault threadsimport_bulksingle threadannotation_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_parseannotations (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.jsongets ajsonld_parseentry (10% tiny, 5% small), likeinsert_formats.Commits 17-27 on the transaction path, not re-benchmarked with criterion locally (EC2 and in-process counts for
transact_commitare under Commit cost withshaclabove):f:predicate outside the system graphs triggered them, including every edge annotation'sf:reifies*; commit 26 restricts the trigger to the config reader's vocabulary.dev-fastprofile) ran 60 inserts of 200 annotated edges each and timed the last 50, for base61b836e9aand 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.wherereads the ledger's default graph by name (a"@graph": "default"while agraphorfromnames another graph), one entry to its WHERE dataset and one pass over the dataset's names; other updates compare eachwheregraph name and VALUES value with the reserved name, and do no other extra work.Before ready
61b836e9a: done on EC2 withshacl(insert_formats,jsonld_parseandtransact_commit; no loss by the rule) and counted in process fortransact_commit(see Commit cost withshacl). Not run: the default-feature build.Follow-ups (not filed yet)
BINDcannot be anINSERTtemplate 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.["graph", <name>, …]array form in awheretakes its name as written (an alias or a full IRI), as before; resolving it like the node-level form would change existing queries.@context, not a node-local or scoped one (as before).@foowhere insert refuses it, and drops@odata.etagwhere insert stores it (the latter predates this PR).@graphon their own rather than through the shared classifier.\uescape inside an IRI to a character an IRI cannot contain (turtle-eval-bad-01to-03are 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.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
build_transact_policy_contextresolved a cross-ledgerf:schemaSourcebefore 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).sh:classmembership in the inline pass reads the default graph plus the value-set facts in the request's own bundle (asfluree validatedoes for inline shapes), not the configuredf:shapesSourcegraph.f:defaultAllow true) and the server route (any request policy input: identity, bearer identity, policy class, inline policy, policy values). The refusal isUnsupportedFeature(Unsupported feature: inline request shapes are not supported in a policy-scoped request), a 400 here and in solo's router.f:schemaSourcefor SHACL targeting like the other lanes (it passed none), and runs the whole check tail (the config guard, and uniqueness since commit 19).f:predicate or type), and stages them for the config-group checks only then.wheregraph names. A query names no ledger, so"config"and"txn-meta"in a query'swherepass through as written, and itsfromNamednames stay aliases (even under@base). The["graph", …]array form is unchanged (see Follow-ups).where"default"in an update. When a top-levelgraphorfromgives thewhereanother default graph, awherenode's"@graph": "default"reads the ledger's default graph by a name the update adds to its WHERE dataset, only when thewhereuses it (GRAPH ?gdoes 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 thewhereor the update'svalues), and as afromNamedalias. 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 samewherealready reads by"default". Otherwise thewhere's own default graph is the ledger's, and nothing is added.opts.uniquePropertiesis read from the body along withopts.shapes, with the precedencevalidationModealready has (a programmatic setting wins). A build without SHACL support refuses inline shapes rather than dropping them.OPTIONAL … BIND(COALESCE(?c0, <iri>) AS ?c)) fails on a ledger without config: an IRI produced byBINDcannot be anINSERTtemplate subject. The docs give two recipes, attach to the existing config subject (INSERT … WHERE) andINSERT DATAfor a ledger without config; both are tested. TheBINDlimitation predates this PR (see Follow-ups).vocabulary.mdtxn-meta example reads with"from": "mydb:main#txn-meta": a node-level@graphinwhereis now aGRAPHpattern, and resolving a ledger alias as a graph name insidewhereis separate work.graphkey'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 routinggraphkey not being read as a selector, had no test; one was added and the mutation fails it.config_guard.rs). Multi-valued, not checked:f:policyClass,f:reasoningModes,f:constraintsSource,f:graphOverrides,f:allowedIdentities,f:property,f:ontologyImportMap. Severalf:GraphConfigoverrides for one graph are reported by theduplicate-graph-overridediagnostic rather than refused.#[cfg(test)]fault hook inload_transaction_config(unit test intx.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.inline SHACL shapes (opts.shapes) refused by this ledger's override control (f:overrideControl f:OverrideNone) for the default graph(orfor graph <iri>): no backticks, and the default graph named in words.LIMIT 1probe (ledger info only; shape counts are small).sync target: …(an existing test pins the label). A graph insert or sync whose payload addresses another graph is refused aspayload must not address a graph other than the target(it said "named graphs", even for"default").fluree infoprintsconfigDiagnosticsfor 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
bench: jsonld_parse, a parse-only JSON-LD transaction bench(baseline; no production change).json-ld: one @graph classifier shared by every reader(graph_shape.rs,is_graph_key).transact: one lexical graph scope for JSON-LD templates(GraphScope,WriteGraphs, one resolver overdataset_ref::GraphSel).tests: JSON-LD graph scope end to end, with SPARQL twins.transact: JSON-LD 1.1 named-graph objects; only envelopes carry txn-meta.transact: one blank-node label sequence per JSON-LD document.json-ld/transact: @reverse, @included and @nest; refuse other keywords.transact: annotation lowering shares the scope; one f:reifiesGraph emitter.query: node-level @graph in a JSON-LD where is GRAPH sugar.import: bulk JSON-LD import refuses @graph scoping.api: config reader normalization + diagnostics.tests: SHACL tests enable SHACL explicitly(no behavior change; see the test-helper row of the mutation table below).api: explicit-only SHACL posture.api: resolve validation artifacts only for participating graphs.api: staged config guard, merged with the reasoning-mode check.docs: graph scoping, explicit-only SHACL, config checks and recipes.json-ld: a top-level single bare {"@id"} in @graph is envelope content.query/transact: a where node's @graph names its graph as templates do(parse/graph_name.rs, shared by the template resolver and thewheresugar).api: one post-staging check tail for every write lane(check_staged_write,WriteChecks).merge: validate fast-forwards into a target whose config governs writes.api: the config guard refuses a group split across transactions.transact: read opts.shapes and opts.uniqueProperties from the body.api/transact: review nits(annotations under a variable graph, the graph-insert selector message, two diagnostics).docs: compatibility notes for the review changes.merge/rebase/revert: re-check the target's config groups.api: the config guard reads config only for config writes(is_config_field).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
wheregraph 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-transactunit 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'sgraph; node-level@graphinwhereand its name resolution, including"default"under agraphkey and underfrom(with the SPARQL spelling), a graph named@defaultbeside such awhere, 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 inoverride_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:
graphalways a graph key; top-level selector treated as envelopegraph_term_in_context_is_a_property_not_a_graph_key,top_level_graph_selector_is_a_node_not_an_envelopeVararm;"default"keyword droppedvariable_graph_in_update_templates_is_var; the default-keyword testcontains_key("@graph")@graphread as a selectordoc_shapetable,single_bare_reference_envelope_keeps_its_txn_meta,single_bare_reference_envelope_is_refused_like_its_array_formuser_b_label_never_collides…; firewall test@reverseforward;@nestoff; adapter@reverseoffit_jsonld_graph_scope+ 27 existing annotation tests["graph", g, …]items (re-run after the cross-check's perf change)annotation_bundle_lands_in_and_names_the_edge_graph)@graphvalue without scanning it for annotation keywordsan_annotation_key_on_a_wrapper_is_still_refused(existing)graphkey read as a selectorroot_graph_routing_key_does_not_scope_annotationsannotations_under_a_variable_graph_are_refusedannotation_under_the_ledger_address_lands_in_the_default_graphwherenode-level@graphoffwhereintegration testwherename taken as written (no expansion);"default"inside a graph pattern accepted (2 sites); a query'sfromNamedaliases not passednode_level_graph_in_where_resolves_names_like_templates(unit and end to end);node_level_graph_in_query_keeps_from_named_aliasesdiagnostics_count_shacl_enabled_for_one_graph;diagnostics_report_duplicate_graph_overridesshacl_tests, 6it_branch_shacl, GraphQL and Turtle tests; before commit 13 the same tests still pass through the heuristic, so the migration changed no behaviorf:shaclEnabled; override gate off; refusal downgraded to a warning; inline pass off; inline pass compiles stored shapesit_shapes_inlinerowsinline_shapes_are_refused_under_policy_defaults_for_any_graphopts.shapes/opts.uniquePropertiesnot readbody_constraints_are_read_from_opts; 3it_shapes_inlinebody tests;insert_honors_inline_shapes_in_the_body(CLI)inline_shapes_are_refused_without_shacl_supportconfig_read_failure_refuses_a_governed_writeunique_enforced_on_turtle_insert,merge_enforces_uniqueness_like_a_transactionand the uniqueness fast-forward testmerge_enforces_uniqueness_like_a_transactionfast_forward_into_an_ungoverned_target_is_not_validated) and 3 of the fast-forward testsconfig_guard_refuses_a_group_split_across_transactions(each)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)config_guard_refuses_a_fast_forward_carrying_a_split_groupf:field outside the config graph counted as a split groupconfig_guard_merges_a_branch_while_the_target_changes_its_configf: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 tellwhere's"default"read as thewhere's own default graph undergraph/fromnode_level_graph_in_where_resolves_names_like_templates(unit),where_default_is_the_ledger_default_graphwhere's use of the name is not recordedwhere_default_is_the_ledger_default_graph(each), andupdate_where_refuses_the_reserved_graph_name(unit) for the second@default, a string a graph can be registered underwhere_default_leaves_a_graph_named_at_default_alone["graph", …]name, awhereVALUES value (each also in the unit test), an updatevaluesvalue, afromNamedaliasupdate_refuses_the_reserved_graph_name(each)wherereads another default graph, used or notwhere_default_refuses_a_graph_registered_under_its_name(each)Gates
Rebased onto
mainat61b836e9a(#1997 merged), keeping #1997's semantics where the JSON-LD parser and update docs conflicted. At the head (treefd198bfd2), 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; andtestsuite-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 forfluree-db-apiwithshacl,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-featuresnextest that CI runs (api'siceberg,sql,delta,aws,credentialandvectortargets, and the crates not listed),testsuite-shacl, the wasm probe and browser jobs, the live SQL bridge lane, and the benchmarks: theshaclones ran on EC2 instead (see Performance), and the default-feature ones didn't run.Full gate log
Rebased onto
mainat61b836e9a(#1997 merged). The JSON-LD parser and update-docs conflicts were resolved keeping #1997's semantics: the update'sgraphkey 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 (treefd198bfd2) 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.-D warnings): pass.cargo nextest runoverfluree-db-transact(unit and integration tests),fluree-graph-json-ld,fluree-db-query,fluree-db-core,fluree-db-graphql,fluree-db-cliandfluree-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_tailanddistinct_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_buildsalso 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 runoverfluree-db-memory,fluree-db-mcp,fluree-db-cypher,fluree-db-shacl,fluree-db-policy,fluree-db-reasoner,fluree-db-sparqlandfluree-db-nameservice-sync: 1153 passed.fluree-db-api(shacl,graphql),fluree-db-transact,fluree-graph-json-ld,fluree-db-coreandfluree-db-query: pass.testsuite-sparql(fmt, clippy with-D warnings, andcargo test, as CI runs them): pass, withw3c_sparql36 manifest-level tests,w3c_rdf3 and 45 unit tests.Not run locally:
--all-featuresnextest, which CI runs:fluree-db-api'siceberg,sql,delta,aws,credentialandvectortest targets, and the crates not listed above.testsuite-shacl(excluded workspace, not in CI). It validates throughvalidate_ledger, which this PR does not change.shaclfeature, and the default-feature benchmarks at this head (see Before ready).