diff --git a/docs/concepts/iri-and-context.md b/docs/concepts/iri-and-context.md
index b0111ac7c..d0d40330f 100644
--- a/docs/concepts/iri-and-context.md
+++ b/docs/concepts/iri-and-context.md
@@ -256,9 +256,13 @@ For programmatic use from Rust, transactions can also set `TxnOpts.strict_compac
```
Blank nodes are:
-- Local to a single transaction
-- Cannot be referenced across transactions
-- Useful for temporary or anonymous data
+- Skolemized at insert time into the reserved `_:fdb-...` label space, which
+ queries return as the node's `@id`
+- Addressable afterwards via that stable `_:fdb-...` id in queries and
+ transactions (see
+ [Editing blank-node structures](../transactions/update-where-delete-insert.md#editing-blank-node-structures-stable-_fdb--ids))
+- Client-authored labels (`_:b0`) stay local to a single transaction — reusing
+ the same label later mints a new node
## Best Practices
diff --git a/docs/transactions/insert.md b/docs/transactions/insert.md
index 59d0ccea4..240e7e20f 100644
--- a/docs/transactions/insert.md
+++ b/docs/transactions/insert.md
@@ -280,7 +280,10 @@ Create entities without explicit IRIs:
}
```
-Fluree generates a unique IRI for the blank node address.
+Fluree generates a unique IRI for the blank node address, in the reserved
+`_:fdb-...` label space. Queries return that id, and it can be used later to
+address the node directly — see
+[Editing blank-node structures](update-where-delete-insert.md#editing-blank-node-structures-stable-_fdb--ids).
## Adding to Existing Entities
diff --git a/docs/transactions/update-where-delete-insert.md b/docs/transactions/update-where-delete-insert.md
index 75dba38cc..da6a02adf 100644
--- a/docs/transactions/update-where-delete-insert.md
+++ b/docs/transactions/update-where-delete-insert.md
@@ -556,6 +556,65 @@ Calculate new values based on old:
}
```
+## Editing Blank-Node Structures (Stable `_:fdb-` Ids)
+
+Fluree skolemizes every blank node at insert time into the reserved
+`_:fdb-...` label space, and queries return those labels as the node's `@id`.
+These ids are **stable**: referencing an `_:fdb-...` id in a later query or
+transaction denotes the existing stored node rather than minting a fresh one
+(the blank-node-syntax equivalent of RDF 1.1 §3.5 skolem IRIs). This makes
+blank-node-rooted structures — OWL restrictions, address objects, RDF lists —
+editable in place, without retracting and re-asserting the whole subtree.
+
+Workflow: query for the node's id, then use it as an ordinary `@id`:
+
+```json
+{
+ "select": "?r",
+ "where": { "@id": "ex:ClassA", "ex:restriction": "?r" }
+}
+```
+
+returns e.g. `"_:fdb-1751612345678901234-0-b0"`, which can then be edited
+directly:
+
+```json
+{
+ "where": { "@id": "_:fdb-1751612345678901234-0-b0", "owl:someValuesFrom": "?old" },
+ "delete": { "@id": "_:fdb-1751612345678901234-0-b0", "owl:someValuesFrom": "?old" },
+ "insert": { "@id": "_:fdb-1751612345678901234-0-b0", "owl:someValuesFrom": { "@id": "ex:Gadget" } }
+}
+```
+
+The parent's reference to the node is untouched and the node keeps its
+identity across the edit.
+
+The same ids work in SPARQL, in all of SELECT patterns, `DELETE`/`INSERT`
+templates, `DELETE DATA`, `INSERT DATA`, and `DELETE WHERE`:
+
+```sparql
+DELETE { _:fdb-1751612345678901234-0-b0 owl:someValuesFrom ?old }
+INSERT { _:fdb-1751612345678901234-0-b0 owl:someValuesFrom ex:Gadget }
+WHERE { _:fdb-1751612345678901234-0-b0 owl:someValuesFrom ?old }
+```
+
+Notes:
+
+- Only labels beginning with the reserved `_:fdb-` prefix behave this way.
+ Ordinary client-authored labels (`_:b0`) keep standard RDF semantics: a
+ fresh node per transaction on the write side, and an existential variable
+ in SPARQL WHERE patterns. Clients cannot accidentally collide with the
+ reserved space — transaction skolemization wraps client labels with a
+ transaction id before prefixing `fdb-`.
+- Strictly per spec, SPARQL forbids blank nodes in `DELETE` templates and
+ treats WHERE-pattern labels as variables; accepting `_:fdb-` ids as
+ constants is a deliberate Fluree extension (the same one Virtuoso's
+ `nodeID://` refs and Jena's `<_:label>` syntax provide).
+- Some stable ids minted by bulk import embed `:` characters (e.g.
+ `_:fdb-lubm:main-1-genid10`). These are addressable from JSON-LD, but the
+ SPARQL grammar does not allow `:` inside blank-node labels, so such ids
+ cannot be written in SPARQL syntax.
+
## Error Handling
### No Match
diff --git a/fluree-db-api/src/import.rs b/fluree-db-api/src/import.rs
index 3eadddfc2..b49800118 100644
--- a/fluree-db-api/src/import.rs
+++ b/fluree-db-api/src/import.rs
@@ -4389,8 +4389,9 @@ where
let result = if chunk_source.is_trig(i) || chunk_source.is_nquads(i) {
// TriG (and N-Quads, converted to TriG) use the dedicated
// commit function for named-graph handling. It allocates
- // codes in state.ns_registry; sync them to shared_alloc
- // afterward for subsequent chunks' spool writes.
+ // codes through spool_config.ns_alloc (spool prefix
+ // lookups happen mid-parse); the sync below is a
+ // belt-and-braces consistency check.
let nq_converted;
let trig_content: &str = if chunk_source.is_nquads(i) {
nq_converted = fluree_db_transact::parse::nquads_to_trig(&content)
diff --git a/fluree-db-api/src/tx.rs b/fluree-db-api/src/tx.rs
index 53db50ff2..0eace5b31 100644
--- a/fluree-db-api/src/tx.rs
+++ b/fluree-db-api/src/tx.rs
@@ -1648,6 +1648,11 @@ fn convert_named_graphs_to_templates(
match term {
RawTerm::Iri(iri) => {
if let Some(local) = iri.strip_prefix("_:") {
+ // Stable Fluree blank-node ids address the existing node;
+ // other labels skolemize fresh at staging.
+ if let Some(sid) = fluree_db_transact::stable_blank_node_sid_from_label(local) {
+ return Ok(TemplateTerm::Sid(sid));
+ }
Ok(TemplateTerm::BlankNode(local.to_string()))
} else {
Ok(TemplateTerm::Sid(ns_registry.sid_for_iri(iri)))
@@ -1670,6 +1675,11 @@ fn convert_named_graphs_to_templates(
match obj {
RawObject::Iri(iri) => {
if let Some(local) = iri.strip_prefix("_:") {
+ // Stable Fluree blank-node ids resolve to the stored node
+ // (see convert_term).
+ if let Some(sid) = fluree_db_transact::stable_blank_node_sid_from_label(local) {
+ return Ok((TemplateTerm::Sid(sid), None));
+ }
Ok((TemplateTerm::BlankNode(local.to_string()), None))
} else {
Ok((TemplateTerm::Sid(ns_registry.sid_for_iri(iri)), None))
@@ -3041,3 +3051,44 @@ impl crate::Fluree {
fn _ensure_error_used(e: ApiError) -> ApiError {
e
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fluree_db_transact::{RawObject, RawTerm, RawTriple};
+
+ /// TriG named-graph blocks (upsert/insert-turtle path): a stable
+ /// `_:fdb-...` id must resolve to the stored node's Sid, while ordinary
+ /// labels stay `TemplateTerm::BlankNode` for fresh skolemization at
+ /// staging.
+ #[test]
+ fn named_graph_stable_blank_node_resolves_to_sid() {
+ let block = NamedGraphBlock {
+ iri: "http://example.org/g1".to_string(),
+ triples: vec![RawTriple {
+ subject: Some(RawTerm::Iri("_:fdb-1234-0-b0".to_string())),
+ predicate: RawTerm::Iri("http://example.org/knows".to_string()),
+ objects: vec![RawObject::Iri("_:other".to_string())],
+ }],
+ prefixes: rustc_hash::FxHashMap::default(),
+ };
+ let mut ns = NamespaceRegistry::new();
+ let (templates, _delta) =
+ convert_named_graphs_to_templates(&[block], &mut ns).expect("convert");
+ assert_eq!(templates.len(), 1);
+
+ let expected = ns.blank_node_sid("1234-0-b0");
+ match &templates[0].subject {
+ TemplateTerm::Sid(sid) => {
+ assert_eq!(sid, &expected, "stable id must address the stored node");
+ }
+ other => panic!("stable id must resolve to a Sid, got {other:?}"),
+ }
+ match &templates[0].object {
+ TemplateTerm::BlankNode(label) => {
+ assert_eq!(label, "other", "ordinary labels keep fresh-mint semantics");
+ }
+ other => panic!("ordinary label must stay BlankNode, got {other:?}"),
+ }
+ }
+}
diff --git a/fluree-db-api/tests/grp_transact.rs b/fluree-db-api/tests/grp_transact.rs
index ba4888e0b..2a451e44a 100644
--- a/fluree-db-api/tests/grp_transact.rs
+++ b/fluree-db-api/tests/grp_transact.rs
@@ -7,6 +7,8 @@ mod it_concurrent_update_reconcile;
mod it_enforce_unique_upsert_indexed;
#[path = "it_raw_txn_parallel_upload.rs"]
mod it_raw_txn_parallel_upload;
+#[path = "it_stable_blank_nodes.rs"]
+mod it_stable_blank_nodes;
#[path = "it_transact.rs"]
mod it_transact;
#[path = "it_transact_conditional.rs"]
diff --git a/fluree-db-api/tests/it_import.rs b/fluree-db-api/tests/it_import.rs
index dee349ea5..5427f5507 100644
--- a/fluree-db-api/tests/it_import.rs
+++ b/fluree-db-api/tests/it_import.rs
@@ -1438,6 +1438,131 @@ ex:alice schema:name "Alice" .
);
}
+// TriG scopes blank-node labels to the whole document: a label shared between
+// the default graph and a GRAPH block must skolemize to ONE node, and the same
+// label in a different document (file/commit) must stay a DIFFERENT node.
+// Regression: named-graph blanks used to mint from the bare label (`fdb-b0`) —
+// diverging from the default graph within one document AND colliding across
+// every document in the ledger.
+#[tokio::test]
+async fn import_trig_blank_label_document_scoped() {
+ let db_dir = tempfile::tempdir().expect("db tmpdir");
+ let data_dir = tempfile::tempdir().expect("data tmpdir");
+
+ // File 1: `_:shared` referenced from the default graph AND a GRAPH block.
+ let trig1 = r#"@prefix ex: .
+@prefix schema: .
+
+ex:alice ex:knows _:shared .
+_:shared schema:name "Document-scoped node" .
+
+GRAPH {
+ ex:bob ex:knows _:shared .
+}
+"#;
+ // File 2: the same label in another document's GRAPH block.
+ let trig2 = r"@prefix ex: .
+
+GRAPH {
+ ex:carol ex:knows _:shared .
+}
+";
+ std::fs::write(data_dir.path().join("a.trig"), trig1).expect("write trig1");
+ std::fs::write(data_dir.path().join("b.trig"), trig2).expect("write trig2");
+
+ let fluree = FlureeBuilder::file(db_dir.path().to_string_lossy().to_string())
+ .build()
+ .expect("build file-backed Fluree");
+
+ fluree
+ .create("test/trig-bnode-scope:main")
+ .import(data_dir.path())
+ .threads(1)
+ .memory_budget_mb(256)
+ .cleanup(false)
+ .execute()
+ .await
+ .expect("trig import should succeed");
+
+ let ledger = fluree
+ .ledger("test/trig-bnode-scope:main")
+ .await
+ .expect("load ledger");
+
+ // Scans with a variable predicate and picks out the blank-node ref, so
+ // the assertions stay focused on node identity rather than predicate
+ // resolution (covered by import_trig_bound_predicate_queryable).
+ let knows_object = |json: &serde_json::Value, subject: &str| -> String {
+ let rows = json.as_array().expect("array result");
+ let refs: Vec = rows
+ .iter()
+ .map(|r| r.as_array().expect("row"))
+ .filter(|r| r[0].as_str() == Some(subject))
+ .filter_map(|r| r[2].as_str())
+ .filter(|o| o.starts_with("_:fdb-"))
+ .map(str::to_string)
+ .collect();
+ assert_eq!(
+ refs.len(),
+ 1,
+ "expected one _:fdb- ref for {subject}: {json}"
+ );
+ refs.into_iter().next().unwrap()
+ };
+
+ // Default graph: the node alice knows.
+ let qr = support::query_jsonld(
+ &fluree,
+ &ledger,
+ &json!({
+ "select": ["?s", "?p", "?o"],
+ "where": {"@id": "?s", "?p": "?o"}
+ }),
+ )
+ .await
+ .expect("default-graph query");
+ let default_node = knows_object(
+ &qr.to_jsonld(&ledger.snapshot).expect("jsonld"),
+ "http://example.org/alice",
+ );
+
+ // Named graph g1 (same document): must be the SAME node.
+ let qr = fluree
+ .query_connection(&json!({
+ "from": "test/trig-bnode-scope:main#http://example.org/graphs/g1",
+ "select": ["?s", "?p", "?o"],
+ "where": {"@id": "?s", "?p": "?o"}
+ }))
+ .await
+ .expect("g1 query");
+ let g1_node = knows_object(
+ &qr.to_jsonld(&ledger.snapshot).expect("jsonld"),
+ "http://example.org/bob",
+ );
+ assert_eq!(
+ g1_node, default_node,
+ "TriG label scope spans default graph and GRAPH blocks of one document"
+ );
+
+ // Named graph g2 (different document): must be a DIFFERENT node.
+ let qr = fluree
+ .query_connection(&json!({
+ "from": "test/trig-bnode-scope:main#http://example.org/graphs/g2",
+ "select": ["?s", "?p", "?o"],
+ "where": {"@id": "?s", "?p": "?o"}
+ }))
+ .await
+ .expect("g2 query");
+ let g2_node = knows_object(
+ &qr.to_jsonld(&ledger.snapshot).expect("jsonld"),
+ "http://example.org/carol",
+ );
+ assert_ne!(
+ g2_node, default_node,
+ "the same label in a different document must stay a distinct node"
+ );
+}
+
// ============================================================================
// N-Quads (.nq) import
//
@@ -2082,3 +2207,94 @@ async fn import_directory_splits_large_compressed_file() {
assert!(objs.contains(&"name-0".to_string()));
assert!(objs.contains(&"name-39999".to_string()));
}
+
+// Regression: the serial TriG import path allocated namespace codes only in
+// `state.ns_registry`, invisible to the SpoolContext (which resolves
+// code->prefix via the SHARED allocator at record-push time, mid-parse). Its
+// empty-prefix fallback wrote suffix-only predicate strings ("name" instead
+// of "http://schema.org/name") into the index's predicate dict, so
+// bound-predicate patterns matched nothing and results rendered bare
+// suffixes. The TriG path now allocates through a shared-allocator-backed
+// WorkerCache like the parallel Turtle path.
+#[tokio::test]
+async fn import_trig_bound_predicate_queryable() {
+ let db_dir = tempfile::tempdir().expect("db tmpdir");
+ let data_dir = tempfile::tempdir().expect("data tmpdir");
+ let trig = r#"@prefix ex: .
+@prefix schema: .
+
+ex:alice schema:name "Alice" .
+ex:bob schema:name "Bob" .
+
+GRAPH {
+ ex:event1 schema:description "login" .
+}
+"#;
+ let path = data_dir.path().join("data.trig");
+ std::fs::write(&path, trig).expect("write trig");
+
+ let fluree = FlureeBuilder::file(db_dir.path().to_string_lossy().to_string())
+ .build()
+ .expect("build");
+ fluree
+ .create("test/bound-pred:main")
+ .import(&path)
+ .threads(1)
+ .memory_budget_mb(256)
+ .cleanup(false)
+ .execute()
+ .await
+ .expect("import");
+ let ledger = fluree.ledger("test/bound-pred:main").await.expect("ledger");
+
+ // Full scan must render complete predicate IRIs (not bare suffixes).
+ let scan = support::query_jsonld(
+ &fluree,
+ &ledger,
+ &json!({"select": ["?s","?p","?o"], "where": {"@id": "?s", "?p": "?o"}}),
+ )
+ .await
+ .expect("scan");
+ let scan_rows = scan.to_jsonld(&ledger.snapshot).expect("jsonld");
+ let preds: Vec<&str> = scan_rows
+ .as_array()
+ .expect("array")
+ .iter()
+ .filter_map(|r| r.as_array().and_then(|row| row[1].as_str()))
+ .collect();
+ assert!(
+ preds.iter().all(|p| *p == "http://schema.org/name"),
+ "predicates must decode to full IRIs; got {scan_rows}"
+ );
+
+ // Bound-predicate pattern must match via the index predicate dict.
+ let bound = support::query_jsonld(
+ &fluree,
+ &ledger,
+ &json!({"select": ["?s","?o"], "where": {"@id": "?s", "http://schema.org/name": "?o"}}),
+ )
+ .await
+ .expect("bound");
+ let bound_rows = bound.to_jsonld(&ledger.snapshot).expect("jsonld");
+ assert_eq!(
+ bound_rows.as_array().map(Vec::len),
+ Some(2),
+ "bound-predicate must match both subjects; got {bound_rows}"
+ );
+
+ // Named graph too: bound predicate against the GRAPH-block data.
+ let named = fluree
+ .query_connection(&json!({
+ "from": "test/bound-pred:main#http://example.org/graphs/g1",
+ "select": ["?s","?o"],
+ "where": {"@id": "?s", "http://schema.org/description": "?o"}
+ }))
+ .await
+ .expect("named bound");
+ let named_rows = named.to_jsonld(&ledger.snapshot).expect("jsonld");
+ assert_eq!(
+ named_rows.as_array().map(Vec::len),
+ Some(1),
+ "named-graph bound-predicate must match; got {named_rows}"
+ );
+}
diff --git a/fluree-db-api/tests/it_stable_blank_nodes.rs b/fluree-db-api/tests/it_stable_blank_nodes.rs
new file mode 100644
index 000000000..bb6224c75
--- /dev/null
+++ b/fluree-db-api/tests/it_stable_blank_nodes.rs
@@ -0,0 +1,417 @@
+//! Stable Fluree blank-node identifier tests.
+//!
+//! Fluree skolemizes every blank node into the reserved `_:fdb-...` label
+//! space at insert time. These ids are returned by queries and — as pinned
+//! here — are *stable*: when a later query or transaction references an
+//! `_:fdb-...` label, it denotes the existing stored node instead of minting
+//! a fresh one (RDF 1.1 §3.5 skolemization, kept in blank-node syntax). This
+//! makes blank-node-rooted structures (e.g. OWL restrictions) editable in
+//! place, without retracting and re-asserting the whole subtree.
+//!
+//! Ordinary client-authored labels (`_:b0`) keep standard semantics: fresh
+//! node per transaction on the write side, existential variable in SPARQL
+//! WHERE patterns.
+
+use crate::support;
+use fluree_db_api::{FlureeBuilder, LedgerState, Novelty};
+use fluree_db_core::LedgerSnapshot;
+use serde_json::{json, Value as JsonValue};
+
+fn ctx() -> JsonValue {
+ json!({
+ "ex": "http://example.org/",
+ "owl": "http://www.w3.org/2002/07/owl#"
+ })
+}
+
+/// Seed a class with a single OWL-restriction-like structure rooted at an
+/// anonymous blank node and return the fluree handle + ledger.
+async fn seed_restriction(ledger_id: &str) -> (fluree_db_api::Fluree, LedgerState) {
+ let fluree = FlureeBuilder::memory().build_memory();
+ let db0 = LedgerSnapshot::genesis(ledger_id);
+ let ledger0 = LedgerState::new(db0, Novelty::new(0));
+
+ let seeded = fluree
+ .update(
+ ledger0,
+ &json!({
+ "@context": ctx(),
+ "insert": {
+ "@id": "ex:ClassA",
+ "ex:restriction": {
+ "owl:onProperty": {"@id": "ex:hasPart"},
+ "owl:someValuesFrom": {"@id": "ex:Widget"}
+ }
+ }
+ }),
+ )
+ .await
+ .expect("seed insert");
+ (fluree, seeded.ledger)
+}
+
+async fn select_strings(
+ fluree: &fluree_db_api::Fluree,
+ ledger: &LedgerState,
+ query: &JsonValue,
+) -> Vec {
+ let result = support::query_jsonld(fluree, ledger, query)
+ .await
+ .expect("query");
+ let v = result.to_jsonld(&ledger.snapshot).expect("to_jsonld");
+ let mut out: Vec = v
+ .as_array()
+ .expect("array result")
+ .iter()
+ .map(|x| x.as_str().expect("string binding").to_string())
+ .collect();
+ out.sort();
+ out
+}
+
+/// The `_:fdb-...` id of ex:ClassA's restriction node.
+async fn restriction_id(fluree: &fluree_db_api::Fluree, ledger: &LedgerState) -> String {
+ let ids = select_strings(
+ fluree,
+ ledger,
+ &json!({
+ "@context": ctx(),
+ "select": "?r",
+ "where": {"@id": "ex:ClassA", "ex:restriction": "?r"}
+ }),
+ )
+ .await;
+ assert_eq!(ids.len(), 1, "exactly one restriction node: {ids:?}");
+ let id = ids.into_iter().next().unwrap();
+ assert!(
+ id.starts_with("_:fdb-"),
+ "restriction id should be a stable Fluree blank-node id, got {id}"
+ );
+ id
+}
+
+async fn run_sparql_update(
+ fluree: &fluree_db_api::Fluree,
+ ledger: LedgerState,
+ sparql: &str,
+) -> fluree_db_api::TransactResult {
+ let parsed = fluree_db_sparql::parse_sparql(sparql);
+ assert!(
+ !parsed.has_errors(),
+ "SPARQL parse errors: {:?}",
+ parsed.diagnostics
+ );
+ let ast = parsed.ast.expect("SPARQL AST");
+ let mut ns = fluree_db_transact::NamespaceRegistry::from_db(&ledger.snapshot);
+ let txn = fluree_db_transact::lower_sparql_update_ast(
+ &ast,
+ &mut ns,
+ fluree_db_transact::TxnOpts::default(),
+ )
+ .expect("lower SPARQL UPDATE");
+ fluree
+ .stage_owned(ledger)
+ .txn(txn)
+ .execute()
+ .await
+ .expect("stage SPARQL UPDATE")
+}
+
+// ============================================================================
+// JSON-LD transactions
+// ============================================================================
+
+/// Inserting with a stable id must extend the existing node, not mint a new
+/// one.
+#[tokio::test]
+async fn jsonld_insert_extends_existing_blank_node() {
+ let (fluree, ledger) = seed_restriction("it/stable-bnode:jsonld-insert").await;
+ let bnode = restriction_id(&fluree, &ledger).await;
+
+ let ledger = fluree
+ .update(
+ ledger,
+ &json!({
+ "@context": ctx(),
+ "insert": {"@id": bnode, "ex:note": "edited"}
+ }),
+ )
+ .await
+ .expect("insert on stable id")
+ .ledger;
+
+ // The note is reachable through the parent's ref — proof the triple
+ // landed on the same node.
+ let notes = select_strings(
+ &fluree,
+ &ledger,
+ &json!({
+ "@context": ctx(),
+ "select": "?note",
+ "where": {"@id": "ex:ClassA", "ex:restriction": {"ex:note": "?note"}}
+ }),
+ )
+ .await;
+ assert_eq!(notes, vec!["edited"]);
+
+ // Still exactly one restriction-shaped node in the ledger.
+ let restrictions = select_strings(
+ &fluree,
+ &ledger,
+ &json!({
+ "@context": ctx(),
+ "select": "?r",
+ "where": {"@id": "?r", "owl:onProperty": {"@id": "ex:hasPart"}}
+ }),
+ )
+ .await;
+ assert_eq!(restrictions.len(), 1);
+}
+
+/// where/delete/insert against a stable id edits the node in place: the
+/// parent's reference is untouched and the node id survives the edit.
+#[tokio::test]
+async fn jsonld_delete_insert_edits_blank_node_in_place() {
+ let (fluree, ledger) = seed_restriction("it/stable-bnode:jsonld-edit").await;
+ let bnode = restriction_id(&fluree, &ledger).await;
+
+ let ledger = fluree
+ .update(
+ ledger,
+ &json!({
+ "@context": ctx(),
+ "where": {"@id": bnode, "owl:someValuesFrom": "?old"},
+ "delete": {"@id": bnode, "owl:someValuesFrom": "?old"},
+ "insert": {"@id": bnode, "owl:someValuesFrom": {"@id": "ex:Gadget"}}
+ }),
+ )
+ .await
+ .expect("edit restriction in place")
+ .ledger;
+
+ let values = select_strings(
+ &fluree,
+ &ledger,
+ &json!({
+ "@context": ctx(),
+ "select": "?v",
+ "where": {"@id": "ex:ClassA", "ex:restriction": {"owl:someValuesFrom": "?v"}}
+ }),
+ )
+ .await;
+ assert_eq!(values, vec!["ex:Gadget"]);
+
+ // Node identity is stable across the edit.
+ assert_eq!(restriction_id(&fluree, &ledger).await, bnode);
+}
+
+/// Ordinary blank-node labels keep fresh-mint semantics: the same label in
+/// two transactions produces two distinct nodes.
+#[tokio::test]
+async fn jsonld_plain_blank_label_still_mints_fresh() {
+ let fluree = FlureeBuilder::memory().build_memory();
+ let db0 = LedgerSnapshot::genesis("it/stable-bnode:fresh-mint");
+ let mut ledger = LedgerState::new(db0, Novelty::new(0));
+
+ for tag in ["one", "two"] {
+ ledger = fluree
+ .update(
+ ledger,
+ &json!({
+ "@context": ctx(),
+ "insert": {"@id": "_:b0", "ex:tag": tag}
+ }),
+ )
+ .await
+ .expect("insert")
+ .ledger;
+ }
+
+ let subjects = select_strings(
+ &fluree,
+ &ledger,
+ &json!({
+ "@context": ctx(),
+ "select": "?s",
+ "where": {"@id": "?s", "ex:tag": "?t"}
+ }),
+ )
+ .await;
+ assert_eq!(
+ subjects.len(),
+ 2,
+ "same client label across transactions must mint distinct nodes: {subjects:?}"
+ );
+}
+
+// ============================================================================
+// SPARQL
+// ============================================================================
+
+/// A stable id in a SPARQL WHERE pattern is a constant pinned to the stored
+/// node, while an ordinary label stays an existential variable.
+#[tokio::test]
+async fn sparql_select_stable_blank_node_is_constant() {
+ let (fluree, ledger) = seed_restriction("it/stable-bnode:sparql-select").await;
+
+ // Add a second restriction so a wildcard match would return two rows.
+ let ledger = fluree
+ .update(
+ ledger,
+ &json!({
+ "@context": ctx(),
+ "insert": {
+ "@id": "ex:ClassB",
+ "ex:restriction": {
+ "owl:onProperty": {"@id": "ex:hasPart"},
+ "owl:someValuesFrom": {"@id": "ex:Sprocket"}
+ }
+ }
+ }),
+ )
+ .await
+ .expect("insert ClassB")
+ .ledger;
+ let bnode = restriction_id(&fluree, &ledger).await;
+
+ // Constant: only the addressed node's value comes back.
+ let sparql = format!(
+ "PREFIX owl: \n\
+ SELECT ?v WHERE {{ {bnode} owl:someValuesFrom ?v }}"
+ );
+ let result = support::query_sparql(&fluree, &ledger, &sparql)
+ .await
+ .expect("sparql select");
+ let v = result.to_jsonld(&ledger.snapshot).expect("to_jsonld");
+ let rows = v.as_array().expect("array");
+ assert_eq!(rows.len(), 1, "stable id must pin one node: {rows:?}");
+
+ // Ordinary label: existential variable, matches both restrictions.
+ let sparql = "PREFIX owl: \n\
+ SELECT ?v WHERE { _:b0 owl:someValuesFrom ?v }";
+ let result = support::query_sparql(&fluree, &ledger, sparql)
+ .await
+ .expect("sparql select wildcard");
+ let v = result.to_jsonld(&ledger.snapshot).expect("to_jsonld");
+ assert_eq!(
+ v.as_array().expect("array").len(),
+ 2,
+ "plain blank label must stay a variable"
+ );
+}
+
+/// SPARQL DELETE/INSERT WHERE addressing a stable id edits the node in place.
+#[tokio::test]
+async fn sparql_delete_insert_edits_blank_node() {
+ let (fluree, ledger) = seed_restriction("it/stable-bnode:sparql-edit").await;
+ let bnode = restriction_id(&fluree, &ledger).await;
+
+ let sparql = format!(
+ "PREFIX ex: \n\
+ PREFIX owl: \n\
+ DELETE {{ {bnode} owl:someValuesFrom ?old }}\n\
+ INSERT {{ {bnode} owl:someValuesFrom ex:Gadget }}\n\
+ WHERE {{ {bnode} owl:someValuesFrom ?old }}"
+ );
+ let ledger = run_sparql_update(&fluree, ledger, &sparql).await.ledger;
+
+ let values = select_strings(
+ &fluree,
+ &ledger,
+ &json!({
+ "@context": ctx(),
+ "select": "?v",
+ "where": {"@id": "ex:ClassA", "ex:restriction": {"owl:someValuesFrom": "?v"}}
+ }),
+ )
+ .await;
+ assert_eq!(values, vec!["ex:Gadget"]);
+ assert_eq!(restriction_id(&fluree, &ledger).await, bnode);
+}
+
+/// SPARQL DELETE DATA / INSERT DATA with a stable id retract and assert
+/// exact triples on the stored node.
+#[tokio::test]
+async fn sparql_delete_data_and_insert_data_stable_blank_node() {
+ let (fluree, ledger) = seed_restriction("it/stable-bnode:sparql-data").await;
+ let bnode = restriction_id(&fluree, &ledger).await;
+
+ let sparql = format!(
+ "PREFIX ex: \n\
+ PREFIX owl: \n\
+ DELETE DATA {{ {bnode} owl:someValuesFrom ex:Widget }}"
+ );
+ let ledger = run_sparql_update(&fluree, ledger, &sparql).await.ledger;
+
+ let values = select_strings(
+ &fluree,
+ &ledger,
+ &json!({
+ "@context": ctx(),
+ "select": "?v",
+ "where": {"@id": "ex:ClassA", "ex:restriction": {"owl:someValuesFrom": "?v"}}
+ }),
+ )
+ .await;
+ assert!(values.is_empty(), "DELETE DATA must retract: {values:?}");
+
+ let sparql = format!(
+ "PREFIX ex: \n\
+ PREFIX owl: \n\
+ INSERT DATA {{ {bnode} owl:someValuesFrom ex:Gadget }}"
+ );
+ let ledger = run_sparql_update(&fluree, ledger, &sparql).await.ledger;
+
+ let values = select_strings(
+ &fluree,
+ &ledger,
+ &json!({
+ "@context": ctx(),
+ "select": "?v",
+ "where": {"@id": "ex:ClassA", "ex:restriction": {"owl:someValuesFrom": "?v"}}
+ }),
+ )
+ .await;
+ assert_eq!(
+ values,
+ vec!["ex:Gadget"],
+ "INSERT DATA must extend the existing node"
+ );
+}
+
+/// DELETE WHERE with a stable-id subject retracts that node's matching
+/// triples only.
+#[tokio::test]
+async fn sparql_delete_where_stable_blank_node() {
+ let (fluree, ledger) = seed_restriction("it/stable-bnode:sparql-delete-where").await;
+ let bnode = restriction_id(&fluree, &ledger).await;
+
+ let sparql = format!("DELETE WHERE {{ {bnode} ?p ?o }}");
+ let ledger = run_sparql_update(&fluree, ledger, &sparql).await.ledger;
+
+ let props = select_strings(
+ &fluree,
+ &ledger,
+ &json!({
+ "@context": ctx(),
+ "select": "?p",
+ "where": {"@id": bnode, "?p": "?o"}
+ }),
+ )
+ .await;
+ assert!(props.is_empty(), "node must be emptied: {props:?}");
+
+ // The parent's ref to the (now-empty) node is a separate triple and
+ // survives — retract it explicitly if the whole subtree should go.
+ let refs = select_strings(
+ &fluree,
+ &ledger,
+ &json!({
+ "@context": ctx(),
+ "select": "?r",
+ "where": {"@id": "ex:ClassA", "ex:restriction": "?r"}
+ }),
+ )
+ .await;
+ assert_eq!(refs, vec![bnode]);
+}
diff --git a/fluree-db-core/src/ns_encoding.rs b/fluree-db-core/src/ns_encoding.rs
index 618d9c840..eb39722ff 100644
--- a/fluree-db-core/src/ns_encoding.rs
+++ b/fluree-db-core/src/ns_encoding.rs
@@ -121,6 +121,27 @@ pub fn builtin_prefix_trie() -> &'static PrefixTrie {
/// split at this boundary unconditionally, before any other splitting logic.
pub const BLANK_NODE_PREFIX: &str = "_:";
+/// Label prefix reserved for Fluree-minted stable blank-node identifiers.
+///
+/// Every skolemized blank node the system mints has a local name beginning
+/// with `fdb-` (e.g. `fdb-`, `fdb--