Make the export → import round trip faithful (breaking: export's default output changes) - #1875
Conversation
`fluree export` refused every RDF format on a ledger that had been committed to but never indexed, with "no binary index available for export (is the ledger indexed?)". The requirement was structural, not informational: `write_to` attaches a novelty overlay two lines after the bail-out, and on a never-indexed ledger that overlay holds the entire ledger. The writers just needed a `&BinaryIndexStore` to scan through. Give them an empty one. `BinaryIndexStore::empty` is the production constructor for a field-complete store with no graphs, dicts or leaves (the test helper that already built one now delegates to it), seeded here with the snapshot's namespace table so ID→IRI resolution completes through `DictNovelty`. The three writers stop bailing when a graph has no SPOT branch and pass an empty `BranchManifest` instead, so `BinaryCursor` exhausts its zero-length leaf range and falls through to its overlay-only tail — which emits exactly the rows in question. That also fixes the narrower case on an indexed ledger: a named graph first written after the last index build exported nothing. Export stays a read. Building an index here instead would have tripled the ledger's on-disk footprint (6.1M → 34M at 1M triples) as a side effect of a read-shaped command, with nothing to clean it up. The command in the report, `fluree export mydb -o mydb.flpack`, had a second defect that auto-indexing would have made silent rather than loud: `--format` defaults to `turtle` and nothing consulted the output name, so it ran the *Turtle* writer into a file named `.flpack`. `--format` becomes `Option<String>`; an absent one is inferred as `ledger` from a `.flpack` output extension, and an explicit RDF format contradicting that extension is refused naming both sides rather than guessed. Every export test in the CLI suite asserted a failure, which is why both defects shipped. They now assert output, including a parity test pinning the never-indexed export's triples to the same ledger's after `fluree index`. `docs/cli/export.md` claimed all formats require an index (false even before this change — `--format ledger` never did) and prescribed running the server to get one. `docs/cli/server-integration.md` published the removed error to third-party implementors; it now says the response no longer occurs.
Bulk import bypasses `stage()`, so #1838's data-write reserved-graph guard never sees a `GRAPH <urn:fluree:{ledger}#txn-meta> { ... }` block. The parser assumes otherwise -- `parse/trig_meta.rs` notes that "a write to the ledger's full txn-meta IRI is refused in `stage()`" -- and that holds for every write surface except this one. The result was a record that is correct and invisible. The import path allocates the reserved g_id correctly (the shared graph allocator is pre-seeded with both reserved IRIs), and the commit writer encodes the flakes faithfully. The loss happens afterwards, in the index builder, which is graph-scoped: each pass filters records against a single `target_g_id`, `g_id 1` is built only from the synthetic commit-metadata chunk, and `g_id 2` has no pass at all. A correctly-routed record from a data chunk is therefore filtered out of every index segment while staying durable in the commit blob. Refuse both reserved IRIs at the top of the named-graph loop, by literal IRI and again by the g_id the block resolves to. `#txn-meta` reuses `ReservedGraphTarget`, so the message matches every other refusing surface. `#config` gets a new `ConfigGraphImportUnsupported` variant instead: ordinary transactions may write ledger configuration, so refusing it at import is a capability gap rather than a policy, and the two must not read alike. The `<#txn-meta>` sentinel spelling is untouched: `parse_trig_phase1` routes it to the commit envelope's `txn_meta` field, so legitimate commit metadata never reaches this loop. A test pins that, so the guard cannot later be "tightened" to match the fragment. Cost on the common path is zero. The guard is per graph block, not per triple or per flake, and a single-graph import never executes it at all -- `import_trig_commit` early-returns to `import_commit` before the loop exists when a file names no graphs. Tests assert the end-to-end property rather than a local one: for a TriG file naming any graph, the import is either refused or the block reads back, never committed-and-unreachable. That is the shape that catches this class, since the write path is correct. Coverage includes `#config`, which the issue never tested, and an unindexed reload -- on an indexed ledger the dropped records read as "absent", so an indexed-only test reports the benign answer. Fixes #1846
The docs explained what happens when you retract a base edge, and separately
showed the annotation form for inserts, but never connected the two. A reader
had no reason to expect that the annotation form of a *delete* is a base-edge
retraction, and the surprise is not one rule but two that meet:
1. The annotation form ASSERTS the base triple, so deleting it retracts the
base edge. RDF 1.2 Turtle 2.11.1 defines `s p o ~ :r {| ... |}` as syntax
that both reifies and asserts, and SPARQL 1.2 Update 3.1.2 admits the same
production into DELETE DATA. This half is spec-mandated -- a store that kept
the edge here would be the one diverging.
2. Retracting a base edge cascades to every reifier attached to it, including
ones the delete never named. This half is Fluree's own; neither spec entails
it, and SPARQL 1.2 Update 3.1.2 Example 6 makes the converse point. It is
now said out loud, so a reader who checks the spec and finds Fluree deleting
more than it names does not conclude there is a second bug and file one.
The table covers three spellings the issue does not mention, two of which
change the guidance. `DELETE DATA { s p o ~ :claim1 }` -- a bare reifier with
no body block -- reads as "detach claim1" and is the worst outcome available:
the edge goes, both claims are detached, and both bodies are left standing as
orphans. A `DELETE WHERE` with a variable reifier matches every claim on the
edge and strips the named properties from all of them. And an `upsert` that
changes an annotated edge's object fires the identical cascade with no delete
written anywhere, so the concept page rather than any one surface's page is
where this belongs.
Also says what to do instead, which the issue did not ask for but is the
reason someone arrives at the section: retract the body facts to withdraw one
claim, use the JSON-LD `@annotation` delete to detach one while keeping its
body, and set `lpgEdgeLifecycle` to remove an edge and everything about it.
No behavior change. The matrix is measured rather than asserted from reading
the code, and pinned by tests so the documented table cannot drift: the
cascade rows fail if `cleanup_metadata` stops discriminating anonymous from
explicit reifiers, and the base-edge rows fail if the annotation expansion
stops emitting the base triple.
Partially addresses #1861. The open question of whether a cascade should also
warn at commit time is left undecided; there is no advisory channel on a
successful transaction today, and a warning printed after the commit reports a
loss that already happened.
`--all-graphs` is not, as #1847 reports, omitted — it works, and has since the v4 baseline. Three things around it do not. `fluree-db-api/src/export.rs` defines `is_system_graph` under the comment "System graph IDs excluded from dataset exports", and nothing has ever called it. `--all-graphs` emitted `#txn-meta` and `#config` alongside user graphs, so an export of ledger A re-imported into a ledger named A lands A's commit metadata on the target's own reserved graph ids, where it is unreachable, and into a ledger named B lands a foreign ledger's commit history in an ordinary user graph. Neither is a round trip. The filter now runs at the two `iter_entries()` call sites it was written for. `--system-graphs` keeps the old bytes available for diagnostics; it is a modifier on `--all-graphs`, not a selector, and the docs say plainly that its output does not re-import cleanly. `docs/cli/export.md` described the unfiltered behaviour as intended ("including system graphs"). It was retrofitted to the bug. The CLI discarded `ExportStats` at both `write_to` call sites, so a `--format trig` export that dropped every named graph in the ledger printed exactly what a complete one printed — the literal complaint in the issue was "nothing in the output to suggest anything is missing". Export now prints a summary to stderr (stdout stays pure RDF, so redirecting still yields a clean file), and names the flag when it left graphs behind. Turtle and N-Triples get a different remedy in that message: `--all-graphs` is rejected for them, so the fix there is the format, not the flag. `ExportStats` grows `graphs_written` and `named_graphs_omitted` to carry that. The HTTP route logs both and accepts `system_graphs`, so the `--remote` path stays in step with the local one. Tests: the export block had no positive `--all-graphs` coverage anywhere in the repo, which is how a dead filter survived three years. It now has a system-graph exclusion pin, the stats/warning assertions, and an export → re-ingest round trip. That round trip asserts against a *differently* named target ledger on purpose: into a same-named one a leaked `#txn-meta` collides and vanishes (#1846), so that direction cannot tell the fix from the bug.
Mechanical. `export_graph_turtle`, `export_graph_ntriples` and `export_graph_jsonld` become `async fn`; the ten call sites gain `.await`, and `materialize::spool_twin_ntriples_indexed` becomes async to carry one of them (its own caller, `verify_twin_full`, already was). No behaviour changes. The annotation arena reader is async, and the next commit probes it from inside the per-batch loop, so the writers have to be able to await. Landing the signature change on its own keeps that diff reviewable and lets anything stacked on top rebase across one mechanical change rather than a behavioural one.
Genuine commit records are derived, never carried. `generate_commit_flakes`
rebuilds them from the commit envelope on every load with `g: None`, and they
acquire the txn-meta graph Sid only from `stamp_graph_on_commit_flakes`
afterwards; user-supplied transaction metadata rides the envelope's separate
`txn_meta` field. So a flake that arrives in a commit's *blob* already carrying
the txn-meta graph Sid and already claiming a `FLUREE_COMMIT`-namespace subject
did not come from any writer -- it is forged by construction, and the replay
paths route it by graph Sid with none of the index builder's graph filtering.
The previous commit closes the one route our own tooling offered to create such
a commit. This closes the consumption side, which is the half that matters for
a ledger received from somewhere else: a peer is not bound by our write guards,
and `import_commits_bulk` / `import_commits_incremental` accept commit bytes
directly.
Keyed on the conjunction, deliberately. Either half alone is ordinary data: a
commit-namespace subject with no graph is the *generated* shape and gets
stamped, and a non-commit subject already in txn-meta cannot steer a commit
resolver. Config (g_id 2) is the deliberate opposite case and is never touched
-- `stage()` permits ordinary transactions to write ledger configuration, those
flakes legitimately ride the blob body, and `config_write_t` is maintained from
exactly that, so a drop generalised to "reserved graphs" would delete ledger
config and silently freeze the resolved-config cache's invalidation key. Tests
pin both halves of the conjunction and the config case for that reason.
Applied at the five sites where a blob's flakes enter novelty: `load_novelty`
and `apply_single_commit` in the ledger crate, the historical replay, and the
clone and push ingest paths.
Costs nothing. At the two sites that replay blob and generated flakes together,
`stamp_commit_flakes_dropping_forgeries` replaces the existing stamping pass
and does both jobs in one traversal -- there is no new pass over the flakes,
and a non-commit flake, which is essentially all of a ledger, still pays the
single `u16` namespace comparison it paid before. The other three sites keep
blob flakes in their own vector and take one `retain` over it.
Backward compatibility checked rather than assumed. No writer in this tree
produces such a flake, and the ordering makes that structural rather than
incidental: `commit_txn` writes and publishes the blob *before*
`finalize_state_with_base` generates any metadata. The ingest paths clone
`commit.flakes` untouched and append generated metadata to novelty only. The
legacy v3 blob format has readers but no writer here, was produced by the
implementation that predates this one, and carries no `graph_delta` at all
(`legacy_v3.rs`, with its own regression test); the ledger-scoped
`urn:fluree:{ledger}#txn-meta` scheme it would need did not exist then. Outside
tests, `generate_commit_flakes` is the only producer of `FLUREE_COMMIT`-subject
Sids in the workspace, and it is called only on load and apply paths.
Dropped flakes are logged at warn with the ledger and `t`, so an affected
ledger is diagnosable rather than silently altered.
Fixes #1846
`fluree export` emitted the seven reserved `f:reifies*` system facts as ordinary triples on all five RDF formats. No other RDF 1.2 tool reads that, and Fluree's own insert and update surfaces reject it: hand-written `f:reifies*` is refused as system-controlled. Fluree exported a form Fluree will not let you write. Export now writes annotation syntax by default — `s p o ~ <r1> ~ <r2>` in Turtle and TriG, `<r> rdf:reifies <<( s p o )>>` in N-Triples and N-Quads, `"@annotation": {"@id": …}` in JSON-LD — and suppresses rows whose predicate satisfies `is_reserved_reifies_predicate` (all seven, not the three the issue happened to show). `--raw-reifies` keeps the old bytes. The reifier marker is inline and the reifier's own properties are left where the scan already puts them, later in the stream as an ordinary subject. Inlining a `{| … |}` body instead would mean a random SPOT seek per reifier, out of scan order, at the moment the base edge is written — the difference between a one-pass fix and a two-pass one. Both spellings are the same RDF 1.2 and both re-import identically. Where "which reifiers point at this edge" comes from is chosen once per export, in the new `export_annotations` module: - no `f:reifies*` flake ever observed, on the snapshot or the overlay: no source at all. Two boolean reads, then the scan runs exactly as it did before. This is the common case and it pays nothing. - sealed arena, empty attachment overlay: one batched forward probe per `ColumnBatch` — `current_annotations_batch`, whose doc comment describes this access pattern. - sealed arena plus attachments committed since: the per-edge merged path, because the batched read is arena-only by contract and cannot see a novelty retract of an indexed attachment. - no arena — which is where `fluree index` leaves every annotated ledger — the bundles are recovered from the base index by the same PSOT scan the arena's own seal pass uses, `O(annotations)` rather than `O(dataset)`. Refusing here was the plan until it turned out `fluree reindex` cannot clear that state, which would have made the refusal a dead end on ledgers SPARQL serves fine through its own fallback. Annotations written inside a named graph are the one shape that does not reach the output: both forward lookups are blind to them (the PSOT scan returns nothing for a named graph, and the arena is built from it), while the rows are in the ledger and SPARQL reads them. Rather than suppress the bundle and emit nothing, export counts it as `ExportStats.annotations_unresolved` and the CLI says so, naming `--raw-reifies` as the way to get those bundles out. That gap is pre-existing and not fixable from the read side. `ExportStats` also gains `annotations_out_of_scope`: a `--graph <IRI>` export can legitimately name a reifier whose own description is in another graph. Reporting it is the alternative to dropping it silently.
`worktree-prb-transact` (#1846, #1861) into the export half (#1574, #1847, #1859). One PR: the export fixes are what make a `fluree export → fluree create --from` round trip an operation anyone can complete, and completing it is how you reach the import-side defects. Disjoint file sets, no conflicts. The two halves meet in one place and it is covered by a test rather than by inspection: #1847 now emits user named graphs in volume, and #1846's guard must refuse the two reserved graph IRIs without catching any of them. `export_all_graphs_round_trips_into_a_same_named_ledger` imports a `--all-graphs` export containing a user named graph, so an over-broad guard fails it — a property that passes on each half alone and can only fail on the join.
Review found that a never-indexed export emits the same subject as more than one block: `ex:bob` opens, closes, and reopens later with `"Bob"@en` stranded at the end of the file. Overlay rows that miss V3 translation bypass the cursor's sorted merge — the contract `surviving_untranslated` states outright — so they were emitted after the stream, past the point the writer's subject grouping had already moved on. That was always reachable, since any commit after the last index build can produce one. What made it the *normal* case rather than the edge one is this PR: a never-indexed ledger has no persisted dictionary to encode against, so a plain string, a language tag and a decimal all miss, and that is precisely the ledger #1574 newly lets you export. The output was valid Turtle, carried every triple, and re-imported correctly. What it lost is that export's shape no longer depended only on the data — it depended on whether the ledger happened to be indexed, which is the one property a faithful-round-trip change cannot afford to add. On a large never-indexed ledger it also fragments every subject spanning the base/overlay boundary, defeating the grouping the writer exists to do. `UntranslatedBySubject` indexes those rows by subject IRI. Each writer folds a subject's rows into its block as the block closes, and emits whatever the base stream never reached as its own blocks afterwards, in IRI order so the result is deterministic. Turtle, TriG and JSON-LD all had the defect and all take the fix; N-Triples has no grouping to break. Memory is unchanged — these flakes were already held for the whole export as a `Vec` — and a ledger with nothing untranslated builds an empty map and takes no other new work. Subject grouping is now identical with and without an index, pinned two ways. Intra-block predicate order still differs, because untranslated rows append rather than sorting into `p_id` position; that is deliberate and unasserted, since it carries no meaning in Turtle while grouping does. The tests assert the block structure, not the exit code. Reverting the fix leaves 21 of the 23 export tests passing — including the one that compares triple sets across an index build — and fails only the two new ones. A success-only or set-equality test cannot see this defect.
`fluree export --format trig` now produces these routinely, so feeding one straight back to `insert` is the obvious next move. It cannot work — insert has nowhere to put a named graph — but it failed inside the Turtle parser with `expected subject, found 'GRAPH'`, which names neither the cause nor the command that does. Both routes in are covered: an explicit `--format trig`, and a `.trig` / `.nq` path that would otherwise sniff as Turtle and die on the first `GRAPH`. The message still lists every format the flag accepts. `the_usage_error_names_every_format_the_flag_accepts` used `trig` as its probe for "user guessed wrong", and its reasoning — that someone who reached this error has already guessed once, so a short list is where being incomplete costs most — applies to this branch exactly as much as to the generic one. Rather than repoint that test at another token, the new message satisfies it, and the test now runs both branches. Also drops an unused `is_empty` on `UntranslatedBySubject` from the previous commit.
The grouping fix has two paths and the tests only exercised one. Both the existing fixtures give every subject at least one translatable row, so they run the fold-into-an-open-block path and never the pass that drains subjects the base stream never reached. A subject whose only property is a language-tagged literal has nothing to translate without a persisted dictionary, so it produces no cursor row at all and exists solely in that second pass. Getting it wrong drops the subject or emits it as a bare one-line statement; the behaviour was right, but nothing said so. Asserts the block form specifically — subject alone on its line, predicate indented beneath — because that is what distinguishes it from the stranded statement it replaced. Reverting the pass to `write_raw_flake_turtle` fails this test and no other.
Nothing in fluree-db-server/tests sent a POST /export at all, so the three x-fluree-export-* headers were pinned only by the code that builds them: a rename, a reordering against Response::builder, or a routing change would have failed nothing. Two targets now drive the real route through the real router. export_omission_headers (in grp_http) asserts named-graphs-omitted both ways, asserts all three absent on a clean export, and pins that an annotation inside a named graph survives export through both annotation sources an HTTP client can reach — the novelty overlay before an index exists, and the sealed arena after POST /reindex. export_scan_source is standalone because it sets FLUREE_EXPORT_ANNOTATION_SCAN, which is process-global while the assertions around it are per-test — the same reason telemetry_test is standalone. It is where annotations-unresolved goes non-zero, and the only coverage the kill switch has: the base-index scan is the one annotation source blind to named-graph annotations. Every header-absence assertion is paired with a positive assertion about body content, so an export that produced nothing cannot satisfy them. x-fluree-export-rows-skipped is asserted absent only. Every site that increments it is a dictionary miss that no well-formed request produces; reaching it over the wire would mean writing a corrupt store. Corrects two sentences in docs/cli/export.md that this measurement contradicts. Named-graph annotations are not universally unrepresentable — two of the three sources resolve them — and the kill switch's note that "the two sources should agree" is wrong in exactly the case it exists to find.
bplatz
left a comment
There was a problem hiding this comment.
Approving. The analysis here is unusually good — several of the linked issues get their premises correctly overturned rather than taken at face value, and the is_system_graph find, the "every export test asserted a failure" observation, and the decision not to ship a refusal whose remedy sends the user in a circle are all right calls.
That said, I'd ask you to make your own assessment of the items below before merging. I think three of them are load-bearing for the PR's headline claim. I built the branch and reproduced all three, so these aren't reading-the-diff guesses — but you know this code better than I do and may see a reason they're fine or handled elsewhere.
Worth looking at first
-
Untranslated overlay rows bypass
f:reifies*suppression (export.rs:1988). On a never-indexed ledger with a literal-object annotated edge, the default export emits a partialf:reifies*bundle — so #1859's defect is back on exactly the path #1574 opens, and the round trip plants a reserved predicate into the target ledger as ordinary user data. -
xsd:decimalobjects lose their annotation on every path, including a sealed arena (export.rs:417). Ref, string, lang, integer, boolean and date all keep their marker; decimal alone drops it and leaves an orphan reifier.export_annotation_object_shapescovers Ref/Lit/Lang but no numeric literal, which is why it's green. -
Export is byte-nondeterministic run to run (
export.rs:1928). Same ledger, same command, six distinct outputs in six runs, in both Turtle and N-Triples. The triple set is identical — it's purely ordering out ofsurviving_untranslated'sHashMap. The body sets intra-block order aside as meaningless in Turtle, which is right for indexed-vs-not but doesn't cover diffing, checksumming or reproducible backups.
Also worth a look, lower stakes
run_remote_rdfdiscards the stats and the new headers, so #1847's fix doesn't reach the CLI whenever a local server is running (commands/export.rs:452).- The HTTP surface emits
x-fluree-export-named-graphs-omittedon a single-graph request where the CLI correctly stays quiet (routes/export.rs:156). annotations_out_of_scopehas a CLI warning but no header (routes/export.rs:168).FLUREE_EXPORT_ANNOTATION_SCANis read with.is_ok(), so=0enables it (export_annotations.rs:167).raw_reifiesis missing from thePOST /exportfield table that names it as the remedy (server-integration.md:2066).- Perf:
batch_reifiersis O(dataset) rather than O(annotations) (export.rs:417), and the arena reader is rebuilt perColumnBatch(export_annotations.rs:218). graph_sid_ofskips thefind_subject_sidround-trip its sibling documents as required (export_builder.rs:267), and--atresolves annotations at HEAD on the arena-less path (export_annotations.rs:259).
None of this argues against the shape of the PR — the export half is clearly the right design, and the test restructuring is the most valuable part of it. Items 1-3 are all about object shapes the new tests don't happen to cover, which is the same failure mode the PR itself diagnoses in the old suite.
| /// | ||
| /// Returns `false` when the value variant is not representable, which is the | ||
| /// caller's cue to count a skipped row and emit nothing. | ||
| fn write_raw_po_turtle( |
There was a problem hiding this comment.
Untranslated overlay rows bypass f:reifies* suppression.
These three emitters — write_raw_po_turtle, write_raw_flake_ntriples (:1894) and merge_untranslated_jsonld (:983) — resolve flake.p directly and never call ann.is_reifies_row. Only write_batch does. So any reifies flake that misses V3 translation is emitted raw, which puts #1859's defect back on exactly the path #1574 opens.
Reproduced on this branch:
fluree create la
fluree insert la --format turtle -e \
'ex:alice ex:label "Alice"@en ~ ex:claimL {| ex:src ex:a |} .'
fluree export la --format turtle # never indexed
<http://example.org/claimL>
<http://example.org/src> <http://example.org/a> ;
<https://ns.flur.ee/db#reifiesObject> "Alice"@en . # leaked
<http://example.org/alice>
<http://example.org/label> "Alice"@en . # no ~ marker
Two things make this worse than a leak:
- It is a partial bundle. The other six facts translated fine and were suppressed by
is_reifies_row, so the output is neither RDF 1.2 annotation syntax nor the oldf:reifies*form. - Round-tripping it lands
f:reifiesObjectin the target ledger as ordinary user data, and the annotation is gone.create --fromon that file warnsAnnotation arena was not sealed (no attachment events resolved). Bulk TriG import persists reserved-graph triples into an unreachable graph id #1846's guard doesn't catch it — that guards reserved graphs, not reserved predicates.
The base row also loses its marker: none of the untranslated emitters take a reifiers slice, so an annotated edge whose own base row is untranslated can never get ~ <r>.
The comment in export_never_indexed_groups_each_subject_once notes that a lang tag and a decimal both miss translation without a persisted dictionary — and all three never-indexed annotation tests use ex:knows ex:bob, the one object shape that translates.
There was a problem hiding this comment.
Confirmed, and reproduced in all three formats rather than Turtle alone — the same partial bundle reaches N-Triples and JSON-LD output. Fixed in d36b71196.
The obvious fix is a trap, and it is worth saying why before the fix itself. Filtering f:reifies* out of the untranslated set at its single collection point is one line, and config.annotations is already in scope there. It would also have made this strictly worse: the unresolved counter only moves where the translated path calls note_bundle_in_scope, so a silent filter converts a visible wrong answer into an invisible one — no marker, no bundle, no number. A leaked reserved triple is at least evidence. An annotation that vanishes without trace is not.
So suppression and accounting had to be the same change. And that is also what makes your second paragraph — the base row never getting its marker — the same defect rather than a separate one: emitting the marker needs the reifier, and reporting the omission needs to know there was one. Both want the untranslated path to consult the probe rather than merely be filtered by it. They are fixed together; 1b is not deferred.
resolve_untranslated partitions the set once, notes every suppressed bundle, and resolves every reifier in one live_reifiers call. Deliberately not one probe per row — your finding below about batch_reifiers being O(dataset) is the reason, and a second per-row probe would have been the wrong direction for a cost already under review. When that one is remediated this call site should extend rather than need rework.
Each format emits through the path its translated rows already use: Turtle ~ <r>, N-Triples a triple term under rdf:reifies, JSON-LD @annotation via the existing annotated_jsonld_values.
is_any_reifies(&Sid) now sits with the seven individual checks in namespaces.rs, and EdgeKey::from_reifies_facts's inline seven-way disjunction calls it — a seven-way || written twice is where an eighth predicate gets added to one copy.
On your diagnosis of why the old tests missed this: you are right and it is the part I would not want softened. All three never-indexed annotation tests used ex:knows ex:bob, the one object shape that translates. That is the same failure this PR diagnoses in the suite it replaced, repeated by me in the replacement. A green suite over a homogeneous fixture is exactly as uninformative as the one it replaced.
There was a problem hiding this comment.
Correcting one thing in my reply above. I said is_any_reifies(&Sid) "now sits with the seven individual checks". It should never have existed: is_reserved_reifies_predicate already covered exactly that set — re-exported from fluree_db_core, unit-tested in namespaces.rs, used by the indexer, and named in annotation_arena/bundle.rs as the canonical check. I found it by reading AnnotationContext::new, which already calls it by name.
So my commit argued that a seven-way disjunction written twice is where an eighth predicate gets added to only one copy, and in making that argument I created a third copy. Removed in fdd377729; both call sites now use the existing helper. No behaviour change — the sets were identical — but the claim in the reply was wrong and the duplication was the exact hazard it named.
| /// same compaction. A repeated `@id` node object is legal JSON-LD and merges | ||
| /// on parse, but it is still a shape that depends on index state, which is | ||
| /// what `UntranslatedBySubject` exists to remove. | ||
| fn merge_untranslated_jsonld( |
There was a problem hiding this comment.
Same gap as write_raw_po_turtle (:1988) — this path resolves flake.p with no is_reifies_row check, so a reifies flake that missed V3 translation reaches the JSON-LD output as an ordinary property. See that comment for the repro and the round-trip consequence.
There was a problem hiding this comment.
Same defect, same fix — see the reply on export.rs:1988. The JSON-LD path now routes untranslated rows through annotated_jsonld_values, the same helper the translated rows use, so the @annotation shape is identical rather than reimplemented.
Confirmed reproduced here before fixing: "https://ns.flur.ee/db#reifiesObject": {"@value":"Alice","@language":"en"} in the node body, and no @annotation on the edge.
| /// deliberate path separation: it happens only for ledgers that carry | ||
| /// annotations, and it keeps the row writers' existing loop untouched for | ||
| /// every ledger that does not. | ||
| async fn batch_reifiers( |
There was a problem hiding this comment.
xsd:decimal objects lose their annotation on every lookup path, including a sealed arena.
The EdgeKey built here is the seek key, and decimal appears not to match what the arena stored. Measured on create --from (sealed arena, the best case):
ex:pRef ex:bob ~ ex:cRef ok
ex:pStr "plain" ~ ex:cStr ok
ex:pLang "hi"@en ~ ex:cLang ok
ex:pInt "42"^^xsd:integer ~ ex:cInt ok
ex:pDec "1.5"^^xsd:decimal marker dropped
ex:pBool "true"^^xsd:boolean ~ ex:cBool ok
ex:pDate "2020-01-01"^^xsd:date ~ ex:cDate ok
ex:cDec is then emitted as an orphan reifier that nothing points at. It is loud (annotations_unresolved fires), but the documented known gap is named-graph annotations — this is a plain default-graph edge. export_annotation_object_shapes covers Ref/Lit/Lang but no numeric literal, which is why it passes. Worth checking whether it is the same decimal representation mismatch that has bitten typed equality elsewhere.
Separately: the cost here is O(dataset), not O(annotations).
An EdgeKey is built for every non-reifies row of the whole ledger whenever annotations are present — a second full decode plus a Sid clone, a FlakeValue clone and an owned lang String per row. On a 10M-triple ledger with three annotations that is 10M constructions.
The comment frames this as deliberate path separation scoped to annotated ledgers, which is true, but the perf table measures only the annotation-free ledger, which short-circuits before reaching here. Driving from the set the annotation source already knows it covers (the arena's forward branch carries first_edge/last_edge per leaf, scanned is already a HashMap<EdgeKey, _>, the overlay is a BTreeMap) would make it proportional to the feature.
There was a problem hiding this comment.
Confirmed, reproduced, and wider than reported. Fixed in 0ca5d05c3.
The mechanism is not a decimal representation mismatch — it is upstream of any comparison. The seek key needs a datatype Sid, and batch_reifiers asked resolve_datatype_sid(o_type):
let Some(dt) = resolver.store.resolve_datatype_sid(o_type) else {
continue;
};That returns None for the NUM_BIG_OVERFLOW arena, because the arena holds both overflow xsd:integer and xsd:decimal and the o_type alone cannot say which. So the continue skipped the row before a key was ever built — which is why it fails on every lookup path including a sealed arena, and why the reifier survives as an orphan.
resolve_datatype_sid_for_value exists for exactly this ambiguity and falls back to the decoded value's variant. It was added for #1329, where the same gap rendered arena-served big numerics with an empty @type. This call site had not adopted it. That is the whole fix.
Two ways it is wider than the report, both now in the fixture:
- Overflow-magnitude
xsd:integerdrops its annotation identically, since it shares that arena. Your fixture used"42"^^xsd:integer, small enough to miss the arena, so this half was invisible from it. - A bare Turtle numeric parses as
xsd:decimal. So the exposure is not people writing explicit decimal datatypes — it is anyone writingex:score 0.9the ordinary way, with nothing in the source to hint at why the annotation vanished.
Measured before and after, on create --from with a sealed arena:
before: pRef ok pStr ok pLang ok pInt ok pBool ok pDate ok pDec DROPPED
(and a separate fixture: bare 1.5 DROPPED, big xsd:integer DROPPED)
after: all shapes keep their marker, counter silent
The regression test covers all three arena-served shapes and keeps a ref and a small integer as controls, so a regression that broke everything could not satisfy it.
| Ok(()) | ||
| } | ||
|
|
||
| /// Overlay rows that missed V3 translation, indexed by the subject they belong |
There was a problem hiding this comment.
Export is byte-nondeterministic run to run whenever untranslated rows exist.
UntranslatedBySubject fixes which block a row lands in, but not the order inside it: the Vec<Flake> per subject keeps the iteration order of surviving_untranslated's HashMap<Flake, Flake> (latest.into_values(), default randomly-seeded hasher). The N-Triples path bypasses this struct entirely and iterates that Vec directly (:1482).
Same ledger, same command, no writes in between:
6x fluree export nt --format ntriples | md5 -> 6 distinct outputs
6x fluree export nt --format turtle | md5 -> 6 distinct outputs
sorted triple-set hash -> identical (pure ordering)
The PR body sets intra-block predicate order aside as carrying no meaning in Turtle. That holds for comparing an indexed export against a non-indexed one, which is what it was written about. It doesn't cover diffing two exports of the same ledger, checksumming or content-addressing an export, or reproducible backups — all of which this breaks.
export_never_indexed_matches_indexed sorts before comparing, so it can't see it. A sort_unstable_by on (p, o) in surviving_untranslated would close it for all three formats at once.
There was a problem hiding this comment.
Confirmed and fixed. surviving_untranslated now sorts before returning.
One change from your suggestion: I keyed the sort on the whole fact identity — s, p, o, dt, lang, list-index — rather than (p, o). Flake's hand-written Eq keys on (s, p, o, dt, m), so two surviving rows can differ in nothing but a language tag; a (p, o) key would leave those tied, and sort_unstable_by gives ties an unspecified order. Sorting on the full identity is total over the map's own key, so nothing can tie.
Pinned by a test that exports the same never-indexed ledger five times in five processes and compares bytes, with a mixed-shape fixture (lang tag and decimal, both of which miss V3 translation without a persisted dictionary). Removing the sort fails it at run 2, not run 5 — the reordering is strong rather than marginal, which is consistent with your six-for-six.
The part worth more than the fix: your byte-identical result could not reliably have shown otherwise. The PR body leans on an indexed-vs-never-indexed export coming out byte-identical at 633 bytes, cmp clean — and on this code that comparison had a random component on both sides. Two runs agreeing was luck, not evidence, and the conclusion drawn from it was not licensed. The same applies to the earlier reviewer's measurement of the same thing.
What the fixture can support is set equality, which is what the N-Triples comparison actually established. The byte-identity claim needs restating, and after this fix it becomes true rather than lucky — but it should be re-measured and re-worded rather than left standing on the old run. I am rewriting that paragraph in the body.
| } | ||
| if raw_reifies { | ||
| body["raw_reifies"] = serde_json::Value::Bool(true); | ||
| } |
There was a problem hiding this comment.
#1847's fix does not reach the CLI's default path.
run_remote_rdf ends here: report_rdf_stats is never called, and RemoteClient::export_rdf returns resp.bytes() (remote_client.rs:1934) — the three new x-fluree-export-* headers the server just learned to send are discarded before they get here.
Whenever a local server is running, if !direct { if let Some(client) = context::try_server_route_client(dirs) { ... } } (:118-137) takes this branch. So fluree export mydb --format trig drops every named graph and prints nothing at all — the literal complaint in #1847, on what is the common path for anyone running a server.
Every new CLI test in this PR runs against a temp dir with no server configured, so only the local path is covered.
| // Kill switch: take the base-index scan even when an arena is | ||
| // sealed. The two sources should agree; this is how you find out | ||
| // when they do not, without rebuilding an index. | ||
| if std::env::var("FLUREE_EXPORT_ANNOTATION_SCAN").is_ok() { |
There was a problem hiding this comment.
.is_ok() tests for presence, so FLUREE_EXPORT_ANNOTATION_SCAN=0 enables the scan. docs/cli/export.md documents =1 as the way to force it, which implies =0 turns it off.
On a ledger with a sealed arena that silently trades a correct arena read for the lossy fallback plus an annotations_unresolved warning. The FLUREE_DISABLE_* flags in this tree read by presence and are named so presence-only is correct; a positively-named flag wants its value parsed.
There was a problem hiding this comment.
Confirmed and fixed. FLUREE_EXPORT_ANNOTATION_SCAN=0 enabled the scan.
Your framing decided the fix: the FLUREE_DISABLE_* flags read by presence and are named so that presence-only is correct, and this one is not one of those. indexer_attachment_provider::force_annotation_bootstrap is the crate's other positively-named flag and already parses its value — so this is now the same shape rather than a new convention.
One deliberate structural choice: the parse is split from the env read, so env_flag_enabled(Option<&str>) is unit-testable without mutating a process-global. That is not only tidiness. An env var set inside a test is precisely the shared-process-state hazard behind a family of flakes we have been measuring today — threshold tests that pass isolated and under cargo nextest (one process per test) but fail under cargo test (threads in one process). Testing this one by setting the variable would have added an instance of the problem in order to check a fix.
The assertions pin =0, false and set-but-empty as off, and 1/true/TRUE as on. Reverting the parse to value.is_some() fails it.
The wire-level test that legitimately does need the variable set (export_scan_source) already lives in its own [[test]] target for the same reason, alongside telemetry_test — and it still selects the scan after this change, which is the other half of the check.
| // each edge merges its own event stream instead. Slower, and | ||
| // confined to ledgers with pending annotation novelty. | ||
| (Some((root, store)), Some(novelty)) => { | ||
| let reader = AnnotationArenaReader::new(root, store.as_ref()); |
There was a problem hiding this comment.
Two costs stack in this arm, and edges here is every non-reifies row in the batch rather than only the annotated ones:
AnnotationArenaReader::newis constructed insidelive_reifiers, which runs once perColumnBatch— so the branch/leaf caches are rebuilt and thrown away for every batch of the export.current_annotations_mergedis then called per edge, walking the forward branch each time.
The trigger is ordinary: a sealed arena plus any attachment committed since the last index build. The perf table measures the annotation-free ledger, which never reaches here. Hoisting the reader to the export (the source is already chosen once per export) would at least stop the per-batch rebuild.
| /// outcome than one that fails outright on a ledger every other reader can | ||
| /// serve. | ||
| #[cfg(not(target_arch = "wasm32"))] | ||
| async fn scan_bundles(ledger: &LedgerState, as_of_t: i64) -> HashMap<EdgeKey, Vec<Sid>> { |
There was a problem hiding this comment.
scan_base_index_for_attachment_events_in computes let to_t = t.max(snapshot.t) (indexer_attachment_provider.rs:366), so this resolves annotations at HEAD rather than at the requested --at. The filter below only drops forward (t > as_of_t) and never restores backward.
For --at t:5 on a ledger where an annotation was asserted at t=3 and retracted at t=7, the range scan at to_t = snapshot.t no longer returns the bundle, so scanned has nothing — while the export cursor runs at to_t = 5, where the bundle rows are live and is_reifies_row suppresses them. The annotation disappears from a point-in-time export that should contain it, and since no edge is known to be annotated it doesn't even bump annotations_unresolved.
Narrow (arena-less + --at + a retracted annotation), but silent.
There was a problem hiding this comment.
Confirmed and fixed in 13ccb5f35. Your reading of the mechanism was exact — t.max(snapshot.t) raising the bound to HEAD, and the filter below only able to drop forward, never restore.
The fix makes the bound the caller's rather than the function's. A seal pass genuinely does want every bundle the base carries, so the two seal callers now clamp it themselves and keep today's behaviour; the export passes the time it was actually asked for. That seemed better than a flag, since the two callers want different things for good reasons rather than one of them being wrong.
Reproduced and mutation-checked, because the fixture has three requirements and getting any of them wrong gives a test that passes without touching the bug:
- the ledger must be indexed past the requested
t, orsnapshot.tis 0 and the clamp is a no-op — my first attempt at this fixture had exactly that flaw and passed before the fix as well as after; - the annotation must be retracted after the requested
t, or it is live at HEAD anyway; - the scan must be the annotation source, since that is the path the bound belongs to.
With all three: --at 1 emits the marker with the change, emits nothing with the clamp reinstated, and HEAD correctly shows neither.
Worth adding to your last sentence — "narrow, but silent". It is slightly worse than silent: because no edge is known to be annotated, annotations_unresolved does not move either, so the one signal designed to catch a dropped annotation is also absent. A point-in-time export came back clean and short.
| }; | ||
| // `EdgeKey.g` for a graph being scanned. Computed per graph rather | ||
| // than per row, and not at all when nothing will probe it. | ||
| let graph_sid_of = |g_id: u16| -> Option<fluree_db_core::Sid> { |
There was a problem hiding this comment.
This builds the g component of the same seek key that ExportResolver::resolve_subject_sid builds the s component of, but with a bare encode_iri — skipping the find_subject_sid round-trip. That function's own doc comment says why it matters: "a Sid that differs in any position lands on the wrong span and reports 'no annotations' rather than failing."
Wherever a stored graph Sid differs from a freshly encoded one (namespace split, overflow/EMPTY namespace), every named-graph annotation probe returns empty — indistinguishable from the known named-graph gap, so the two couldn't be told apart once the seal-scan defect is fixed upstream.
There was a problem hiding this comment.
Taking the round-trip here — find_subject_sid(&iri)?.unwrap_or_else(|| encode_iri(&iri)), matching what resolve_subject_sid already does — and in one more place you could not have seen: the same bare encode_iri is how I resolve the graph Sid in the stacked fix for the named-graph seal defect. Correct whether or not a fixture exists, so it goes in regardless of the rest of this comment.
You are right about the construct, and the divergence condition is narrower than "a stored Sid differs from a freshly encoded one". The two functions disagree in exactly one situation:
// encode_iri — split form whenever the PREFIX is registered
if let Some(&code) = namespace_reverse.get(canonical_prefix) {
return Sid::new(code, canonical_suffix);
}
// find_subject_sid — additionally requires the SUBJECT KEY to exist
if let Some(&ns_code) = namespace_reverse.get(canonical_prefix) {
if reverse_lookup_subject_key(ns_code, canonical_suffix)?.is_some() {
return Ok(Some(Sid::new(ns_code, canonical_suffix)));
}
}
Ok(find_full_iri_subject_fallback(iri)?.map(|(ns, _)| Sid::new(ns, iri)))So it needs an IRI stored full-IRI while its prefix is separately registered. encode_iri then returns (code, "g1") against stored (full_iri_code, "http://…/g1") — different position under the derived Ord, landing on the wrong span and reporting "no annotations" rather than failing, exactly as resolve_subject_sid warns.
I could not construct that for a graph IRI, and I would rather ask than assert it is unconstructible. Instrumenting graph_sid_of to compute both and compare, then running five deliberately varied graph IRIs through create --from:
http://example.org/g1 agree
urn:uuid:5f4d3c2b-1a09-4876-b5e4-3d2c1b0a9f8e agree
http://example.org/deeply/nested/path/.../g3 agree
https://a.example/x#frag agree
tag:example.org,2026:g5 agree
All five stored in split form under a freshly registered namespace (codes 13–17). The two shapes I can see reaching it are namespace-table overflow, and an IRI written before its namespace existed and registered by a later write. Have you seen either in practice? You found the construct, so you may know the shape that produces it — that is a question you can answer and I cannot, and it decides whether this is a latent class or a live defect.
Either way the fix lands, and I am describing it as removing the class rather than as recovering anything measured.
Incidentally, that same five-graph fixture reported Annotation arena was not sealed (no attachment events resolved) and emitted zero ~ markers — an independent reproduction of the named-graph gap at a scale I had not tried, which is useful evidence for the stacked fix.
There was a problem hiding this comment.
Correcting myself: I said above I could not construct the divergence. I found it, and it is the namespace-overflow shape. Your finding is confirmed rather than merely conceded.
It is in the write path. NamespaceRegistry::sid_for_iri (fluree-db-transact/src/namespace.rs:492) is how every graph Sid is derived on transact:
let code = self.get_or_allocate(prefix);
if code == OVERFLOW {
Sid::new(OVERFLOW, iri) // full IRI as name
} else {
Sid::new(code, suffix)
}Read-side encode_iri has no OVERFLOW branch — an unregistered prefix falls to Sid::new(EMPTY, iri) — and ensure_code documents OVERFLOW as "a sentinel, never register it". So once the namespace table overflows, a graph is stored (OVERFLOW, iri) and read back (EMPTY, iri): identical name, different namespace code, wrong span under the derived Ord, silent "no annotations" exactly as you described.
My five-IRI fixture missed it because every namespace in it was allocated a real code; nothing I varied about IRI shape could reach it, because the trigger is table pressure rather than the IRI.
One methodological note, since it nearly went the other way. I had already committed to the round-trip here when I read LedgerSnapshot::build_reverse_graph, which derives graph Sids with a bare encode_iri and treats it as authoritative. That reads as "encode_iri is the graph-Sid convention, so the round-trip is wrong", and I came close to reversing on it. What settled it was going to where the value is written rather than arbitrating between two read sites — convention at a read site is not evidence about storage.
That turned up something adjacent I am filing separately rather than fixing here: build_reverse_graph has the same mismatch, keying graph Sid → GraphId with encode_iri on the novelty routing path, where an overflowed graph would be keyed (EMPTY, iri) against a stored (OVERFLOW, iri) and never match. Its ok_or_else error branch is also dead — LedgerSnapshot::encode_iri returns Some(..) unconditionally, so the "no matching namespace prefix" error it is written to raise cannot fire. Unmeasured, outside this PR's surface, and bigger than the corner you flagged.
Fix here is find_subject_sid(iri).ok().flatten().unwrap_or_else(|| encode_iri(iri)), matching ExportResolver::resolve_subject_sid.
There was a problem hiding this comment.
Filed the core half as #1891 — outside your twelve, and unmeasured, so not a finding you missed.
The part worth thirty seconds of your time is the dead branch. build_reverse_graph does:
let sid = self.encode_iri(iri).ok_or_else(|| {
Error::invalid_index("graph IRI ... has no matching namespace prefix")
})?;and LedgerSnapshot::encode_iri is Some(self.encode_iri_inner(iri)) — unconditionally. The ok_or_else cannot fire. An unregistered graph IRI does not raise that error; it silently becomes (EMPTY, iri) and the routing lookup misses. encode_iri_strict is the variant that returns None, which is the tell that this call meant to use it.
Underneath it is the same OVERFLOW mismatch as here, on the novelty routing path. Mechanism read from source; symptom not reproduced. Detail and three candidate fixes are in #1891 — I deliberately recommended none, since it is a LedgerSnapshot API question rather than an export one.
Fix for this comment is landed in c8cbaf951, and in the stacked seal PR as 3d465481d under the same commit message so the two halves read as one change.
| | `graph` | string | No | — | IRI of a single named graph to export. Mutually exclusive with `all_graphs`. | | ||
| | `context` | object | No | ledger default | Prefix map for Turtle/TriG/JSON-LD output. Either a bare object (`{ "ex": "..." }`) or `{ "@context": {...} }`. Falls back to the ledger's stored default context when absent. | | ||
| | `at` | string | No | latest | Time spec — integer (`"42"`), ISO-8601 datetime (`"2026-01-15T10:30:00Z"`), or commit CID prefix (`"bafy…"`). Identical to the local `--at` flag. | | ||
| | `at` | string | No | latest | Time spec — `t:<N>` (transaction number), `t:latest` or `latest`, `iso:<ISO-8601>` (commit event time), `recorded:<ISO-8601>` (the wall-clock time the commit was recorded), or `commit:<hex-prefix>` (min 6 chars). A bare transaction number, ISO-8601 timestamp or commit prefix also works; a bare integer is read as a transaction number, so use `commit:<prefix>` to force an all-digit prefix. Identical to the local `--at` flag. | |
There was a problem hiding this comment.
raw_reifies is missing from this table and from the example body above, though ExportRequest accepts it (routes/export.rs:25) and the response-header section at :1066 tells clients to "re-request with raw_reifies". An implementor building against this page can't implement the escape hatch the page itself points at.
The breaking default change isn't stated here either — POST /export response bodies now carry RDF 1.2 annotation syntax instead of f:reifies* triples. Given this section is what third-party implementors build against, that seems like a Required-semantics item.
There was a problem hiding this comment.
All three gaps confirmed and fixed in 6f7c9d972.
raw_reifies is now in the request field table, cross-referenced to the header that names it as the remedy — you were right that an implementor could not implement the escape hatch the page points at.
The new x-fluree-export-annotations-out-of-scope header is in the response table too.
And the breaking default is stated, as its own paragraph under the field table rather than buried in a row:
Breaking change in 4.2. Response bodies now carry RDF 1.2 annotation syntax for edge annotations —
s p o ~ <r>in Turtle and TriG, a triple term underrdf:reifiesin N-Triples and N-Quads,@annotationin JSON-LD — where previous versions emitted the underlyingf:reifies*triples. A consumer that parsed those triples directly will not find them. Setraw_reifiesto keep the old bytes.
Your reasoning for why it belongs on this page specifically — that it is what third-party implementors build against — is why it is stated in the reference rather than only in the PR description and the CLI guide.
`encode_iri` returns the split form whenever a namespace prefix is registered. The write path does not always store that form: `NamespaceRegistry::sid_for_iri` allocates a code per prefix and, once the namespace table overflows, stores the graph as `Sid::new(OVERFLOW, iri)` with the full IRI as the name. `ensure_code` documents OVERFLOW as a sentinel that is never registered, so the read side finds no prefix and falls to `Sid::new(EMPTY, iri)`. Same name, different namespace code. The result is a seek key that lands on the wrong span under the derived `Ord` and reports "no annotations" rather than failing — the exact silent mode `ExportResolver::resolve_subject_sid` warns about, which is why that function tries `find_subject_sid` first and only then falls back. Both graph-Sid sites now do the same. The store is reachable at each: `BinaryRangeProvider::store()` is public and this crate already performs the downcast in `ledger_manager`, so this needed no core API change. Reported in review as a latent risk. Five deliberately varied graph IRIs — http, urn:uuid, deep path, fragment, tag: — all agreed between the two derivations, because every namespace in that fixture was allocated a real code; nothing about IRI *shape* reaches this, since the trigger is table pressure. The mechanism was confirmed by reading the write path rather than by reproducing the symptom, and the fix is correct whether or not a fixture exists. Not fixed here, and filed separately: `LedgerSnapshot::build_reverse_graph` has the same mismatch on the novelty routing path, where an overflowed graph would be keyed `(EMPTY, iri)` against a stored `(OVERFLOW, iri)`. Its `ok_or_else` error branch is also dead, because `encode_iri` returns `Some` unconditionally. Unmeasured, outside this surface, and a core question that should not be settled inside an export change.
`surviving_untranslated` collapsed fact identities through a `HashMap` and returned `into_values()`, so the order of untranslated rows came from the randomly-seeded hasher and changed every process. Same ledger, same command, six runs, six different files. The triple set was always right; only the order moved. Intra-block predicate order carries no meaning in Turtle, which is true for comparing an indexed export against a never-indexed one and is what that argument was written about. It does not cover diffing two exports of one ledger, checksumming an export, or content-addressing a backup — and #1574 makes untranslated rows the normal case for a never-indexed ledger rather than a corner. Sorted on the whole of `Flake`'s fact identity — `s, p, o, dt, lang, list-index` — rather than on `(p, o)`. Two surviving rows can differ in nothing but a language tag, and `sort_unstable_by` leaves ties in an unspecified order, so a narrower key would be deterministic only most of the time. Consequence for this PR's own evidence: the indexed-vs-never-indexed byte-identity result it reports was taken on this code, so it had a random component on both sides. Two runs agreeing was luck rather than evidence. The set-equality result stands; the byte-identity claim is re-measured against this commit.
Untranslated rows never pass through `is_reifies_row` — only the translated writers call it — so an `f:reifies*` row that missed V3 translation reached the output raw while its siblings that did translate were suppressed. The result was a *partial* bundle: neither RDF 1.2 annotation syntax nor the old `f:reifies*` form, in all three formats. Round-tripping such a file plants a reserved predicate in the target ledger as ordinary user data, and the base edge lost its marker because none of the untranslated emitters could see a reifier. Filtering the rows out would have been worse than the leak. The unresolved counter only moves where the translated path calls `note_bundle_in_scope`, so a silent filter converts a visible wrong answer into an invisible one: no marker, no bundle, and no number. Suppression and accounting are the same change, and emitting the marker needs the same lookup the accounting does — which is why the two halves of this finding are one commit. `resolve_untranslated` partitions the untranslated set once, notes every suppressed bundle, and resolves every reifier in a single `live_reifiers` call. Not one probe per row: `batch_reifiers` is already a per-row probe on annotated ledgers, and stacking a second one is the wrong direction for a cost that is already under review. Each format emits its own spelling, through the path the translated rows already use — Turtle `~ <r>`, N-Triples a triple term as the object of `rdf:reifies`, JSON-LD `@annotation` via `annotated_jsonld_values`. `is_any_reifies` joins the seven individual predicate checks in `namespaces.rs`, and `EdgeKey::from_reifies_facts`'s inline seven-way disjunction now calls it. A seven-way `||` written twice is where an eighth predicate gets added to one copy. The regression tests are paired deliberately. Asserting only that no warning appears is satisfied by a counter that has stopped working, so a fixture that genuinely cannot resolve asserts the counter still fires. That canary has a known expiry, recorded at the test: the stacked seal fix makes named-graph annotations resolve, and the replacement wanted is a bundle the decoder rejects outright — a corruption state no supported write surface can produce, since every write path rejects hand-written `f:reifies*`.
# Conflicts: # docs/cli/export.md # docs/cli/server-integration.md
The seek key `batch_reifiers` builds needs a datatype `Sid`, and it asked
`resolve_datatype_sid(o_type)`. That returns `None` for the
`NUM_BIG_OVERFLOW` arena, because the arena holds both overflow
`xsd:integer` and `xsd:decimal` and the o_type alone cannot say which. The
`else { continue }` then skipped the row before any key was built, so those
edges could never be matched against the arena and lost their `~ <r>`
marker on every lookup path — sealed arena included — leaving the reifier
in the output as an orphan nothing points at.
`resolve_datatype_sid_for_value` exists for exactly this ambiguity: it
falls back to the decoded value's own variant. It was added for #1329,
where the same gap rendered arena-served big numerics with an empty
`@type`. This call site had not adopted it. The fix is to use it.
Reported against `xsd:decimal`. It is wider than that, and the fixture says
so: the same arena serves overflow-magnitude `xsd:integer`, which drops its
annotation identically. The reported fixture used `"42"^^xsd:integer`,
small enough to miss the arena entirely, so that half was invisible.
The exposure is also wider than "people who write explicit decimal
datatypes". A bare Turtle numeric parses as `xsd:decimal`, so `ex:score 0.9`
written the ordinary way hits this, with nothing in the source to suggest
why the annotation vanished.
The test covers all three arena-served shapes — explicit decimal, bare
numeric, overflow integer — and keeps a ref and a small integer as
controls, so a regression that broke everything cannot satisfy it.
`scan_base_index_for_attachment_events_in` computed its own upper bound as `t.max(snapshot.t)`. That is right for a seal pass, which wants every bundle the base carries, and wrong for a read at a requested time: raising the bound to HEAD means the range never returns a bundle retracted after the requested `t`, and the filter below it can only drop rows, never restore them. So a point-in-time export lost an annotation that was live at the time it asked for — and lost it silently, because with no edge known to be annotated nothing incremented the unresolved counter either. The bound is now the caller's. The two seal callers clamp it themselves and keep today's behaviour; the export passes the time it was asked for. Measured on an indexed ledger with the annotation retracted after t=1, scan forced: `--at 1` emits the marker with this change and emits nothing without it, HEAD correctly shows neither.
`FLUREE_EXPORT_ANNOTATION_SCAN` was tested with `.is_ok()`, so setting it to `0` enabled the scan. The flag is positively named and documented as `=1` forces the base-index scan, which implies `=0` does not — and on a ledger with a sealed arena, accidentally enabling it trades a correct arena read for the lossy fallback plus an `annotations_unresolved` warning. The `FLUREE_DISABLE_*` flags in this tree are presence-only and named so that presence-only is correct. This one is not one of those. `indexer_attachment_provider::force_annotation_bootstrap` is the crate's other positively-named flag and already parses its value; this now matches it. The parse is split from the read so it can be tested without mutating a process-global — which matters here beyond tidiness, since an env var set by one test is exactly the shared-process-state hazard that makes sibling tests flaky under `cargo test`.
Three ways the two surfaces disagreed about what the export left out.
A targeted single-graph request reported every *other* user graph as
omitted. `omitted_named_graph_count` zeroed only under `all_graphs`, so
`{"graph": "<iri>"}` on a three-graph ledger answered
`x-fluree-export-named-graphs-omitted: 2`. The count answers "your request
lost data you did not ask to drop", and a targeted export has nothing to
report — not returning the graphs you did not name is the feature. The CLI
gates the same warning on `all_graphs || graph.is_some()` and correctly
stayed quiet; the condition now matches, so a client acting on the header
no longer sees a false positive on every targeted export.
`annotations_out_of_scope` had a tracing field and a CLI warning but no
header, so HTTP reported three omission classes where the CLI reported
four. It now has one. Note what it can and cannot do: the counter is
`named - in_scope`, and the write path co-locates a bundle with the edge it
describes, so no selection of graphs can include a marker and exclude its
bundle. Like `rows-skipped`, it is a corruption-class guard rather than a
reachable state, and the test file says so rather than implying coverage it
does not have.
And the reference page omitted `raw_reifies` from the request field table
while the response-header section told clients to re-request with it — an
implementor could not implement the escape hatch the page points at. The
same page did not state that 4.2 changed the default response bytes from
`f:reifies*` triples to RDF 1.2 annotation syntax, which is the thing a
third-party consumer most needs to know.
Left behind when the out-of-scope wire test was removed: the counter it asserted cannot be driven above zero through any supported request, since the write path co-locates an annotation bundle with the edge it describes. The header is still emitted and still asserted absent; the constant had no remaining reader. Caught by clippy, after I read a failure count as a pass and pushed on it.
`AnnotationArenaReader` memoises the forward branch and every forward leaf it touches, behind mutexes, so repeated lookups against one arena are cheap. It was constructed inside `AnnotationProbe::live_reifiers`, which runs once per `ColumnBatch` — so the caches were rebuilt and discarded for every batch, and each batch re-fetched the same branch. The annotation source is chosen once per export, so the reader can be built once too. It now lives on the probe beside the arena it reads, and both arms that need it borrow it. This is the cheap half of the review's cost finding; the other half — that `batch_reifiers` builds a seek key for every row rather than every annotated one — is separate.
`is_reserved_reifies_predicate` already covered the seven reserved `f:reifies*` predicates — re-exported from `fluree_db_core`, unit-tested in `namespaces.rs`, used by the indexer, and named in `annotation_arena/bundle.rs` as the canonical check for ad-hoc cases. `is_any_reifies`, added earlier on this branch, duplicated it. The commit that added it argued that a seven-way disjunction written twice is where an eighth predicate gets added to only one copy — and in doing so created a third copy of the same set. Both of its call sites, in `EdgeKey::from_reifies_facts` and the untranslated-row filter, now call the existing helper, and the duplicate is gone. Found while reading `AnnotationContext::new`, which already calls the existing helper by name.
One conflict, in `fluree-db-cli/tests/integration.rs`, resolved by taking main's file and re-applying the five tests that exist only here — computed by name rather than remembered, since a textual "keep both" on this file spliced mid-function last time. Two tests arrive from main that this branch's fix invalidates, and both are removed for the same reason as the previous merge: `export_reports_annotations_it_could_not_resolve` asserts the named-graph defect that this branch repairs, and its converted form already lives here as `a_named_graph_annotation_exports_with_its_marker`. The canary `the_unresolved_counter_still_fires_when_it_should` used the named-graph fixture as its `MustFire`, and this branch is what makes that fixture resolve — the expiry its own doc comment predicted. `fluree-db-api/src/indexer_attachment_provider.rs` and `export_annotations.rs` auto-merged, which is not a semantic guarantee: main brought the caller-owned `to_t`, the hoisted arena reader and the value-parsing scan flag into the same functions this branch stamps the graph `Sid` in. `cargo check --workspace --all-targets` is clean on the result.
fluree export's default output changes. Annotated ledgers now emit RDF 1.2 annotation syntaxinstead of the reserved
f:reifies*system triples, and--all-graphsno longer emits the ledger'sown
#txn-metaand#config.--raw-reifiesrestores the old annotation bytes and--system-graphsrestores the old--all-graphspayload, so nothing consuming today's exports iswithout a path forward. Both old forms were unreadable in ways this PR is about: the first is a
shape Fluree's own write surfaces reject, and the second does not re-import into any ledger.
fluree exportproduced a file you could not read back. Three defects made that true in differentways, and fixing them turns the round trip into an operation people will actually use — which is
what makes the fourth and fifth worth fixing in the same PR, because a normal round trip is how you
reach them.
fluree export(#1574, #1847, #1859)#1574 — export a never-indexed ledger, and stop writing Turtle into
.flpackEvery RDF format refused on a ledger that had been committed to but never indexed:
no binary index available for export (is the ledger indexed?). That requirement was structural,not informational —
write_toattaches a novelty overlay two lines after the bail-out, and on sucha ledger that overlay holds the whole ledger.
The writers now get an empty
BinaryIndexStore, seeded with the snapshot's namespace table soID→IRI resolution completes through
DictNovelty, and they stop bailing when a graph has no SPOTbranch —
BinaryCursorexhausts a zero-length leaf range and falls through to its overlay-onlytail, which emits exactly those rows. That second half independently fixes a narrower case: on an
indexed ledger, a named graph first written after the last index build exported nothing.
Export stays a read. Auto-indexing was the other candidate and it is the wrong shape here: it
triples the ledger's on-disk footprint (6.1 M → 34 M at 1 M triples) as a side effect of a
read-shaped command, with nothing that would ever clean it up.
One consequence of opening this path needed fixing with it, and review caught it. Overlay rows
that miss V3 translation bypass the cursor's sorted merge, and a never-indexed ledger maximizes
them — with no persisted dictionary to encode against, a plain string, a language tag and a decimal
all miss. They were emitted after the stream, which reopened a subject block that had already
closed:
ex:bobappeared twice with"Bob"@enstranded at the end of the file. Valid Turtle,every triple present, and re-imports correctly — but export's output shape then depended on
whether the ledger happened to be indexed, which is the one property a faithful-round-trip change
cannot afford to add.
UntranslatedBySubjectindexes those rows by subject so each writer foldsthem into the block they belong to as it closes, and emits whatever the base stream never reached
as its own blocks. Memory is unchanged — they were already held for the whole export as a
Vec.After the fix, subject grouping is index-independent in Turtle, TriG and JSON-LD, pinned by
export_subject_grouping_is_the_same_indexed_or_not. Intra-block predicate order still differs(untranslated rows append rather than sorting into
p_idposition) and is deliberately notasserted: it carries no meaning in Turtle, and the triple set is already pinned exactly.
The command in the report had a second defect, and auto-indexing would have made it silent
rather than loud.
fluree export mydb -o mydb.flpackpasses no--format; the default isturtle,and nothing consulted the output name — so it ran the Turtle writer into a file named
.flpack.--formatbecomes optional: absent plus a.flpackoutput name infersledger, and an explicitRDF format contradicting that extension is refused naming both sides rather than guessed.
#1847 — the filed premise is wrong; three real defects sit behind it
--all-graphsworks, and has since the v4 baseline. It is documented, and--graph <IRI>workstoo; the repro simply did not pass the flag (and would not have run as written —
fluree exporttakes the ledger positionally and rejects
-l). A reviewer should know that going in, because thetitle sends you looking for a bug that is not there. What is there:
is_system_graphwas never called.fluree-db-api/src/export.rs:62-69defines it under thecomment "System graph IDs excluded from dataset exports", and
git log -Lputs it at the v4baseline, added and never wired. So
--all-graphsemitted the ledger's own#txn-metaand#configalongside user graphs — a file that, re-imported into a ledger of the same name, landsthat commit metadata on the target's reserved graph ids, and into a differently-named one lands a
foreign ledger's commit history in an ordinary user graph.
docs/cli/export.mdhad been written todescribe the unfiltered behaviour as intended. The filter now runs at the two call sites it was
written for.
--system-graphskeeps the old bytes available for diagnostics, as a modifier on--all-graphsrather than a selector, and its help text and the docs say plainly that its outputdoes not re-import cleanly anywhere.
The CLI discarded
ExportStatsat both call sites, so a--format trigexport that droppedevery named graph in the ledger printed exactly what a complete one printed. The literal complaint
in the issue was "nothing in the output to suggest anything is missing", and that was accurate.
Export now summarizes to stderr — stdout stays pure RDF, so redirecting still yields a clean file —
and names the flag that would have included them. Turtle and N-Triples get a different remedy in
that message, because
--all-graphsis rejected for them: there the fix is the format, not theflag.
Retitle suggestion, as a note rather than an edit: "
fluree exportleaks system graphs into--all-graphs, and says nothing when it drops user graphs". The reporter's underlying worry —"export → import silently loses rows" — is correct, by two mechanisms he did not identify, and that
title names both.
#1859 — export emitted a form Fluree refuses to ingest
All five RDF formats emitted the seven reserved
f:reifies*system facts as ordinary triples. Noother RDF 1.2 tool reads that, and Fluree's own insert and update surfaces reject it: hand-written
f:reifies*is refused as a system-controlled predicate. Fluree exported a form Fluree will not letyou write.
The new syntax is the default, with
--raw-reifiespreserving today's bytes — not behind aflag, not a clean break. Per format:
s p o ~ <r1> ~ <r2>in Turtle and TriG,<r> rdf:reifies <<( s p o )>>in N-Triples and N-Quads,"@annotation": {"@id": …}in JSON-LD.All five suppress rows whose predicate satisfies
is_reserved_reifies_predicate— all seven, notthe three the issue happened to show; a lang-tagged object adds
reifiesLangand a plain literaladds
reifiesDatatype.The reifier marker is emitted inline and the reifier's own properties are left where the scan
already puts them, later in the stream as an ordinary subject. Inlining a
{| … |}body insteadwould mean a random SPOT seek per reifier, out of scan order, at the exact moment the base edge is
written — the difference between a one-pass fix and a two-pass one. Both spellings are the same RDF
1.2, and the target syntax was verified to round-trip byte-identically against a released binary
before any of this was written, so it was known to be ingestible going in.
Where "which reifiers point at this edge" comes from is decided once per export, three ways,
and the cost of each is the reason the design is shaped this way:
f:reifies*flake ever observedcurrent_annotations_batchColumnBatch,O(edges·log edges + covered leaves)current_annotations_merged, per edgeO(annotations), once at export startThat last row was going to be a refusal with a
fluree reindexhint. It is not, because the hintdoes not work:
fluree indexleaves an annotated ledger athas_annotations = true, annotation_index = None, had_annotation_arena = true, and that sticky bitpermanently blocks the bootstrap at
indexer_attachment_provider.rs:304-306, so no laterreindexrecovers it. A refusal whose remedy sends the user in a circle is worse than no refusal — and
refusing would have made export the only surface in the system that cannot read a ledger everything
else reads, since SPARQL-star returns those annotations correctly through its own fallback. That
indexer defect is pre-existing, is being written up separately, and is the reason this decision
changed. The scan reuses
scan_base_index_for_attachment_events_in— the routine the arena's ownseal pass uses, decoding through the canonical
EdgeKey::from_reifies_facts— rather than adding asecond scanner. The refusal survives for the one genuinely unreadable case: an arena root present
with no content store to read it from.
The perf commitment is intact, and measured rather than asserted. Common export (no
annotations, no named graphs, indexed ledger), 200 000 triples, release binaries, this branch
against
origin/main@3d89e8f71:--format ntriples--format turtleorigin/mainBoth binaries were timed in the same window on a shared machine, so the
comparison holds even though the absolutes would move on a quiet one — the
pairing is the claim, not the milliseconds. The byte-identical result below is
not time-dependent at all.
and the output is byte-identical on both formats. Structurally, not by luck: a ledger with no
annotations short-circuits on two booleans, never opens an arena, never builds the suppression id
list, and
batch_reifiersreturns before touching the batch — so the second decode of each row,which is the real cost of annotation emission, happens only on ledgers that carry annotations. Per
row the writers gain one
if let Some(…)on aNone.That measurement is on an indexed, annotation-free ledger, which review correctly noted is the one
shape that cannot exhibit the subject-splitting above. Measured separately on a never-indexed
ledger after the grouping fix: identical subject grouping and an identical triple set against the
same ledger post-
fluree index, with only intra-block predicate order differing. A never-indexedexport is not a hot path and was not timed; the claim above is scoped to the indexed one.
Known gap, reported rather than dropped. Annotations written inside a named graph do not reach
the output: both forward lookups are blind to them, because the PSOT range that feeds the arena's
seal comes back empty for a named graph and the arena is built from that same scan. The rows are in
the ledger and SPARQL reads them fine. Rather than suppress the bundle and emit nothing, export
counts it and says so:
That is a fidelity tradeoff worth a reviewer's eye: for that one shape the output moves from "wrong
syntax but complete" to "right syntax, incomplete, and loud, with a working escape hatch". The clean
fix is upstream in the seal scan, not in export, which is why it is not in this PR.
The test coverage is the structural half of this
Every export test in
fluree-db-cli/tests/integration.rsasserted a failure. That is how afilter designed, written and documented at the v4 baseline stayed uncalled for three years, and how
an output format Fluree itself rejects shipped on five surfaces: nothing ever looked at what export
produced.
--all-graphshad no positive coverage anywhere in the repo.The block is now positive-path and round-trip: export → re-ingest → query, per format, per
annotation object shape, and across all three annotation sources (never-indexed,
fluree index,create --from). One of those tests caught a vacuity in another: the--all-graphsround triporiginally imported into a ledger of the same name, where a leaked
#txn-metacollides andvanishes — so it passed with the system-graph fix reverted. It now also imports into a differently
named ledger, where a leak is visible.
Import and delete (#1846, #1861)
These are here because the export fixes above are what make them reachable. A round trip through
fluree export → fluree create --fromwas not an operation anyone could complete; now it is, andit is the normal way to produce a TriG file with
GRAPHblocks in it.#1846 — reserved graphs, refused at import and dropped at load
The filed mechanism is not the one. The issue proposes running
stage()'s IRI-shape check overgraph_deltabeforeapply_delta. That check isvalidate_absolute_graph_iri, andurn:fluree:imp:main#txn-metapasses it cleanly — it is a perfectly well-formed absolute IRI. Thecheck that matters is the reserved-graph guard, and
apply_deltais the wrong layer anyway: by thenthe flakes are encoded and the blob is written.
What actually happened was a record that is correct and invisible. Bulk import bypasses
stage(), so #1838's reserved-graph guard never saw aGRAPH <urn:fluree:{ledger}#txn-meta>block.The import path allocated the reserved g_id correctly and the commit writer encoded the flakes
faithfully; the loss came afterwards, in the graph-scoped index builder, where
g_id 1is builtonly from the synthetic commit-metadata chunk and
g_id 2has no pass at all. A correctly-routedrecord from a data chunk was filtered out of every index segment while staying durable in the blob.
An indexed-only test reports the benign answer here, which is part of why this survived.
Both reserved IRIs are now refused at the top of the named-graph loop, by literal IRI and again by
the g_id the block resolves to.
#txn-metareusesReservedGraphTargetso the message matches everyother refusing surface;
#configgets a distinctConfigGraphImportUnsupported, because ordinarytransactions may write ledger configuration and refusing it at import is a capability gap rather
than a policy — the two must not read alike. The
<#txn-meta>sentinel spelling is untouched andpinned by a test, since
parse_trig_phase1routes it to the commit envelope where legitimate commitmetadata belongs.
The consumption side is closed too, and that is the half that matters for a ledger received from
somewhere else. Genuine commit records are derived, never carried:
generate_commit_flakesrebuildsthem from the envelope on every load with
g: None, and they acquire the txn-meta graph Sid onlyfrom
stamp_graph_on_commit_flakesafterwards. So a flake arriving in a commit blob alreadycarrying that graph Sid and already claiming a
FLUREE_COMMIT-namespace subject did not come fromany writer — it is forged by construction. Those are dropped, at the five sites where a blob's
flakes enter novelty, and logged at warn with the ledger and
tso an affected ledger isdiagnosable rather than silently altered.
The drop is keyed on the conjunction, deliberately: either half alone is ordinary data, and a drop
generalised to "reserved graphs" would delete ledger config and freeze the resolved-config cache's
invalidation key. Config is the deliberate opposite case and is never touched. Tests pin both halves
and the config case.
Backward compatibility was checked rather than assumed — no writer in this tree produces such a
flake, and the ordering makes that structural:
commit_txnwrites and publishes the blob before anymetadata is generated. Cost is nil: at the two sites replaying blob and generated flakes together,
one traversal now does both jobs where there were two, and a non-commit flake — essentially all of a
ledger — still pays the single
u16comparison it paid before.These two halves meet the export half in one place, and it is worth checking deliberately. The
transact audit asked that #1847's fix exclude reserved graphs from the emitted dataset, so that a
--all-graphsexport does not produce exactly the file #1846 refuses, and that #1846's refusal bescoped to reserved graphs only — never to user named graphs, which #1847's fix now emits in volume.
Both hold, and
export_all_graphs_round_trips_into_a_same_named_ledgerexercises the seam: itimports a
--all-graphsexport containing a user named graph, so an over-broad import guard wouldfail it.
#1861 — what each spelling of an annotation delete actually removes
The docs explained base-edge retraction, and separately showed the annotation form for inserts, and
never connected the two. The surprise is two rules meeting:
Turtle 2.11.1 defines
s p o ~ :r {| … |}as syntax that both reifies and asserts, and SPARQL 1.2Update 3.1.2 admits the same production into
DELETE DATA. This half is spec-mandated — a storethat kept the edge here would be the one diverging.
never named. This half is Fluree's own; neither spec entails it. It is now said out loud, so a
reader who checks the spec and finds Fluree deleting more than it names does not conclude there is
a second bug and file one.
The table covers three spellings the issue does not mention, two of which change the guidance —
including
DELETE DATA { s p o ~ :claim1 }, a bare reifier with no body block, which reads as"detach claim1" and is the worst outcome available: the edge goes, both claims are detached, and
both bodies are left standing as orphans. It also says what to do instead, which is the reason
someone arrives at the section. The matrix is measured rather than read off the code, and pinned by
tests so the documented table cannot drift.
The issue's second ask — a commit-time warning on the cascade — is not built, and that is why
#1861 stays open below. There is no advisory channel on a successful transaction today; adding one
means a new field on the stage result threaded through four crates for one string. It is also aimed
at the wrong moment: a warning printed after the commit reports a loss that already happened. If it
is worth engineering against rather than documenting, the instrument is a pre-commit one.
Merge order with #1871
No longer a hazard: this PR now carries #1871's text verbatim for the three places the two
collide on wording —
docs/cli/server-integration.md'satrow and Required-semantics item 3, anddocs/cli/export.md's--atrow. Whichever lands first, the other's merge is a no-op on thoselines instead of a conflict.
That is worth the duplication because of what the conflict would have looked like rather than
because conflicts are expensive. This branch inserts a
system_graphsrow immediately above theatrow and edits Required-semantics items 2 and 5, which bracket item 3 — so both hunks carriedthe old time-spec text as context, and "take #1875's side" would have been the natural-looking
resolution while silently reinstating a normative spec of the #1805 bug. That section is what
third-party
POST /exportimplementors build against; someone built this bug from that pagealready. A resolution note in a body relies on the resolver reading it and then correctly
identifying which side is which mid-conflict. Copying the text removes the decision.
What ran, and what did not
Locally, green:
cargo fmt --all(after the last edit);cargo clippy --all-targets --no-depsoneach crate the export half touches —
fluree-db-api,fluree-db-cli,fluree-db-binary-index,fluree-db-server;cargo test -p fluree-db-cli(383 tests);cargo test -p fluree-db-binary-index(412). On the merged tree specifically:cargo fmt --all --check,cargo check --workspace --all-targets, and the cross-half seam test — run at the workspace levelrather than per crate on purpose, since the two halves touch different crates and a zero-conflict
merge can still be a broken tree.
cargo test -p fluree-db-apiran on the transact half: 61 suites, 0 failures. But 17 of thatcrate's 77
[[test]]targets never built, because they carryrequired-featuresthat are off bydefault —
iceberg(9),aws-testcontainers(3),sql(2),credential,residency. None sitson a commit-replay or ingest path, but they did not run, and "irrelevant" is a judgement rather
than a result. CI's
testjob isnextest --workspace --all-features, so those 17 first executethere, not locally.
Not run locally, and not to be read as passing: the workspace-wide test and clippy runs.
Workspace clippy is skipped deliberately — the environment's clippy is newer than this repo's
baseline and fires pre-existing lints everywhere, so it was isolated per crate.
testsuite-sparqlis untouched by the export half, so its separate fmt/clippy pair does not apply there; note it is
excluded from the workspace, so the clean workspace check does not cover it, and the new
TransactError::ConfigGraphImportUnsupportedvariant is on a public enum that is not#[non_exhaustive]— that crate's only reference toTransactError::is inside a doc comment(
query_handler.rs:675), checked by hand, and CI'stestsuite-sparqljob is the real gate. CI isthe gate for all of these.
Eight mutations were run to prove the new tests non-vacuous — each fix reverted in turn, tests
confirmed failing, fix restored. Details in the commit messages.
Issues
Fixes #1574
Fixes #1847
Fixes #1846
Partially addresses #1859 — annotation syntax ships on all five RDF formats by default, for every
object shape, with
--raw-reifiesas the escape hatch. What remains is annotations written inside anamed graph, and the distinction matters: they are reported, not silently dropped, and the
defect that blocks them is in the arena's seal scan, not in export. The PSOT range that feeds
the seal comes back empty for a named graph, so the bundles never reach the arena and neither
forward lookup can see them; export cannot fix that from the read side. This is the boundary drawn
where the defect actually is, rather than work left unfinished — the seal-scan defect is written up
separately.
Partially addresses #1861 — the documentation half is done, including three spellings the issue does
not mention. The commit-time cascade warning is not built; see above for why, and for what would be
the better instrument if it is wanted.