Summary
On the ledger-scoped query endpoint, after the sequence (1) WITH <g> DELETE { ?s ?p ?o } WHERE { ?s ?p ?o } on a named graph that does not exist yet, then (2) an upsert of TriG data into <g>, subsequent GRAPH <g> { ?s ?p ?o } queries return the union of <g> and the default graph instead of only the triples of <g>.
The data is stored correctly (the default graph is untouched, the named graph holds only its own triples), so this is a read / graph-resolution bug: named-graph isolation breaks for <g> after this specific write sequence.
Environment
- Fluree Core
main @ fc0303fff (2026-06-11)
- Single-server mode, local file storage (
fluree server run --storage-path store)
- Endpoint: ledger-scoped
POST /v1/fluree/query/{ledger}
Steps to reproduce
The bug only manifests when the default graph is large/complex enough to be indexed across multiple leaves + branches (reliably reproduced with the GEMET thesaurus: ~81.9k triples → reindex stats leaf_count: 12, branch_count: 4). It did not reproduce with 3 triples, nor with 20 000 flat synthetic triples (index tree too shallow).
B=http://localhost:8090/v1/fluree
# 1. Ledger with a large default graph (here: SKOS + GEMET thesaurus, ~81895 triples)
curl -X POST $B/create -H 'Content-Type: application/json' -d '{"ledger":"repro:main"}'
curl -X POST $B/insert/repro:main -H 'Content-Type: application/json' --data-binary @skos.jsonld
curl -X POST $B/insert/repro:main -H 'Content-Type: application/json' --data-binary @GEMET_en_es_fr.jsonld
# (default graph is now indexed across multiple leaves/branches)
# 2. TRIGGER — wipe a NON-EXISTENT named graph, then upsert into it
curl -X POST $B/update/repro:main \
-H 'Content-Type: application/sparql-update' \
--data 'WITH <urn:doc:1> DELETE { ?s ?p ?o } WHERE { ?s ?p ?o }'
printf 'GRAPH <urn:doc:1> { <http://ex.org/docA> <http://ex.org/p> "X" . }' | \
curl -X POST $B/upsert/repro:main -H 'Content-Type: application/trig' --data-binary @-
# 3. Read the named graph
curl -X POST $B/query/repro:main \
-H 'Content-Type: application/sparql-query' -H 'Accept: application/sparql-results+json' \
--data 'SELECT (COUNT(*) AS ?c) WHERE { GRAPH <urn:doc:1> { ?s ?p ?o } }'
Expected
GRAPH <urn:doc:1> contains 1 triple (<http://ex.org/docA> <http://ex.org/p> "X").
Actual
GRAPH <urn:doc:1> returns ~81896 triples — the 1 named-graph triple plus the entire default graph. A default-graph subject that was never written to the named graph resolves inside it:
ASK { GRAPH <urn:doc:1> { <http://www.eionet.europa.eu/gemet/concept/100> skos:prefLabel ?l } }
# => true (should be false)
The underlying storage is correct — the default graph is unchanged and the upserted triple is only in <urn:doc:1> at the storage level — so only the read-time graph resolution is wrong.
Once the leaked state has settled, every query shape agrees on it:
Query on GRAPH <urn:doc:1> |
Result |
Expected |
SELECT (COUNT(*) AS ?c) WHERE { GRAPH <urn:doc:1> { ?s ?p ?o } } |
81896 |
1 |
SELECT (COUNT(?s) AS ?c) WHERE { GRAPH <urn:doc:1> { ?s a skos:Concept } } |
5573 |
0 |
ASK { GRAPH <urn:doc:1> { <…/concept/100> skos:prefLabel ?l } } |
true |
false |
SELECT ?l WHERE { GRAPH <urn:doc:1> { <…/concept/100> skos:prefLabel ?l } } |
3 default-graph labels |
empty |
Secondary observation: read-after-write inconsistency on the same path
Immediately after step (2) the upsert, the first COUNT(*) on GRAPH <urn:doc:1> transiently returns 1 (the correct, isolated value); re-running the identical query a moment later returns 81896 (the leaked value), and it stays there. So the same query on an unchanged ledger returns different results depending on timing — the leaked/indexed read path and the just-written novelty appear to be merged inconsistently for a short window. This may be the same root cause surfacing at two stages (write-time graph registration and read-time resolution).
Scope / diagnostic (negative controls)
All on the same large-default-graph ledger:
| Sequence |
GRAPH <g> result |
| Read a never-written named graph |
✅ empty |
upsert into a fresh named graph without the preceding WITH … DELETE |
✅ isolated (only its own triples) |
WITH <g> DELETE WHERE { ?s ?p ?o } alone (no upsert) |
✅ empty |
WITH <g> DELETE WHERE { ?s ?p ?o } when the graph already has content + upsert |
✅ isolated |
WITH <g> DELETE WHERE { ?s ?p ?o } on a fresh graph, then upsert |
❌ returns <g> ∪ default graph |
So the trigger is specifically wipe-then-upsert on a not-yet-registered named graph, on a ledger whose default graph spans a multi-level index. Wiping an already-existing graph is fine; a plain first upsert is fine.
Impact
This breaks per-document named-graph workflows (one named graph per document, wiped-then-rewritten on each (re)publish). The first publish of any document corrupts that document's named graph so that every read of it also returns the whole default graph (the shared model / ontology), making per-document reads unusable.
Suspected area
Likely in named-graph resolution on the indexed read path vs. graph_registry g_id assignment (fluree-db-core/src/graph_registry.rs, FIRST_USER_GRAPH_ID = 3): a WITH <iri> DELETE WHERE that references a not-yet-registered graph appears to leave <iri> resolving to / aliasing the default partition (g_id = 0 / DEFAULT_GRAPH_ID) at read time once the graph is materialized by the subsequent upsert. Same area as #1279 (single-db GRAPH resolution). It only surfaces once the default graph is indexed across multiple leaves/branches, so the novelty-only read path is unaffected.
Summary
On the ledger-scoped query endpoint, after the sequence (1)
WITH <g> DELETE { ?s ?p ?o } WHERE { ?s ?p ?o }on a named graph that does not exist yet, then (2) anupsertof TriG data into<g>, subsequentGRAPH <g> { ?s ?p ?o }queries return the union of<g>and the default graph instead of only the triples of<g>.The data is stored correctly (the default graph is untouched, the named graph holds only its own triples), so this is a read / graph-resolution bug: named-graph isolation breaks for
<g>after this specific write sequence.Environment
main@fc0303fff(2026-06-11)fluree server run --storage-path store)POST /v1/fluree/query/{ledger}Steps to reproduce
The bug only manifests when the default graph is large/complex enough to be indexed across multiple leaves + branches (reliably reproduced with the GEMET thesaurus: ~81.9k triples → reindex stats
leaf_count: 12, branch_count: 4). It did not reproduce with 3 triples, nor with 20 000 flat synthetic triples (index tree too shallow).Expected
GRAPH <urn:doc:1>contains 1 triple (<http://ex.org/docA> <http://ex.org/p> "X").Actual
GRAPH <urn:doc:1>returns ~81896 triples — the 1 named-graph triple plus the entire default graph. A default-graph subject that was never written to the named graph resolves inside it:The underlying storage is correct — the default graph is unchanged and the upserted triple is only in
<urn:doc:1>at the storage level — so only the read-time graph resolution is wrong.Once the leaked state has settled, every query shape agrees on it:
GRAPH <urn:doc:1>SELECT (COUNT(*) AS ?c) WHERE { GRAPH <urn:doc:1> { ?s ?p ?o } }818961SELECT (COUNT(?s) AS ?c) WHERE { GRAPH <urn:doc:1> { ?s a skos:Concept } }55730ASK { GRAPH <urn:doc:1> { <…/concept/100> skos:prefLabel ?l } }truefalseSELECT ?l WHERE { GRAPH <urn:doc:1> { <…/concept/100> skos:prefLabel ?l } }Secondary observation: read-after-write inconsistency on the same path
Immediately after step (2) the upsert, the first
COUNT(*)onGRAPH <urn:doc:1>transiently returns1(the correct, isolated value); re-running the identical query a moment later returns81896(the leaked value), and it stays there. So the same query on an unchanged ledger returns different results depending on timing — the leaked/indexed read path and the just-written novelty appear to be merged inconsistently for a short window. This may be the same root cause surfacing at two stages (write-time graph registration and read-time resolution).Scope / diagnostic (negative controls)
All on the same large-default-graph ledger:
GRAPH <g>resultupsertinto a fresh named graph without the precedingWITH … DELETEWITH <g> DELETE WHERE { ?s ?p ?o }alone (no upsert)WITH <g> DELETE WHERE { ?s ?p ?o }when the graph already has content + upsertWITH <g> DELETE WHERE { ?s ?p ?o }on a fresh graph, then upsert<g>∪ default graphSo the trigger is specifically wipe-then-upsert on a not-yet-registered named graph, on a ledger whose default graph spans a multi-level index. Wiping an already-existing graph is fine; a plain first upsert is fine.
Impact
This breaks per-document named-graph workflows (one named graph per document, wiped-then-rewritten on each (re)publish). The first publish of any document corrupts that document's named graph so that every read of it also returns the whole default graph (the shared model / ontology), making per-document reads unusable.
Suspected area
Likely in named-graph resolution on the indexed read path vs.
graph_registryg_id assignment (fluree-db-core/src/graph_registry.rs,FIRST_USER_GRAPH_ID = 3): aWITH <iri> DELETE WHEREthat references a not-yet-registered graph appears to leave<iri>resolving to / aliasing the default partition (g_id = 0 / DEFAULT_GRAPH_ID) at read time once the graph is materialized by the subsequent upsert. Same area as #1279 (single-dbGRAPHresolution). It only surfaces once the default graph is indexed across multiple leaves/branches, so the novelty-only read path is unaffected.