feat(sparql): burn-down Wave 3 — within-ledger FROM (D-3), UPDATE graph-management (D-5/6), equality/EBV/promotion (D-12, #1447), USING+GRAPH over-delete (#1441), triple-term syntax (−145 tests) - #1462
Conversation
SPARQL CONSTRUCT templates containing blank nodes ([ ], _:a, or an RDF collection ( )) evaluated to an empty @graph: template blank nodes lower to variables the WHERE clause never binds, so instantiate_row dropped every template triple at the (Some,Some,Some) guard. Record the template blank-node variables (those whose registry name keeps the `_:` prefix the term lowerer assigns) on ConstructTemplate.bnode_vars at lowering, and mint a fresh blank node per solution row in the CONSTRUCT formatter -- shared across a row's template triples, distinct across rows. constructlist rides the same path via PR-1's collection desugaring. Greens W3C construct-3, construct-4 (data-r2) and constructlist (data-sparql11); their skip-register entries are removed suite-driven.
Add SPARQL CONSTRUCT regression tests proving a blank-node template mints a fresh blank per solution -- one node per match, linking subject / predicate / object within a row -- for both the anonymous [ ] (construct-3 shape) and labeled _:a (construct-4 shape) forms.
…nting
Adversarial review found the per-solution CONSTRUCT blank labels were minted
as b{n}, which overlaps other blank-node producers' label spaces (BNODE() emits
_:b{hex}; SERVICE passes through _:b0). Safe today only because every real
Fluree producer uses the reserved fdb- prefix and external SERVICE is rejected,
but a mixed template could otherwise silently merge a minted blank with a data
blank — and the isomorphism-based W3C CONSTRUCT suite cannot catch that. Mint
into a reserved cst{n} prefix so the output is provably disjoint.
Recognize TRIPLE/SUBJECT/PREDICATE/OBJECT/isTRIPLE as arity-validated function-call builtins that lower to not_implemented (burn-down D-1). They are contextual function-call identifiers, not reserved keywords: the lexer emits a single generic TripleTermFn token (carrying the canonical name) and the expression parser resolves it via FunctionName::parse and validates arity (TRIPLE/3, the rest/1). Greens the 3 bucket-B entries of SPARQL12_SYNTAX_TRIPLE_TERMS_POSITIVE (expr-tripleterm-03/04/05).
Accept bare `<<( s p o )>>` triple-term values in the subject, object, VALUES, and BIND positions the RDF 1.2 grammar allows, lowering them to not_implemented (burn-down D-1). A shared parse_triple_term_value enforces the grammar guardrails so the (empty) negative register stays rejected: no path/collection/reified-triple inside a term; a value-context term (VALUES/BIND) may not have a blank-node or nested-triple-term subject, while a pattern-context term may; a bare `<<( )>> .` is not a statement. BIND `<<( )>>` reuses the TRIPLE(...) function path. Greens the 24 bucket-C entries of SPARQL12_SYNTAX_TRIPLE_TERMS_POSITIVE (register now empty). Two pr-w2a unit tests that asserted the pre-D-1 parse rejection are updated to the accept-then-defer behavior; the rdf:reifies reifier path keeps rejecting nested triple-term subjects.
A single-child group graph pattern was always unwrapped to its bare child
(parse_group_graph_pattern), erasing the `{ }` scope boundary. When the child is
a FILTER, BIND, or nested Group that boundary is load-bearing: the lowerer only
wraps *Group* children as uncorrelated subqueries, so an unwrapped
`{ FILTER(?v) }` or `OPTIONAL { { ... FILTER(?title) } }` lands in the enclosing
scope and the FILTER wrongly sees an enclosing-scope variable.
Keep a single-child group wrapped when the child is scope-sensitive
(GraphPattern::is_scope_sensitive: Filter/Bind/Group); the existing lowerer path
then routes it through an uncorrelated subquery evaluated in an independent
scope. Relax the lowerer's nested-Group invariant assertion accordingly.
Greens W3C filter-nested-2 and dawg-optional-filter-005-not-simplified. Adds
JSON-LD parity regression tests (it_query_filter_scope.rs) for the shared
filter-scope semantics guarded only on the SPARQL surface by the W3C submodule.
A correlation variable a sub-SELECT binds only via OPTIONAL/UNION is not self-produced, so subquery.rs excluded it from the hash join key and the merge kept the parent's value while silently dropping the subquery's conflicting binding — W3C var-scope-join-1 returned rows where the natural join on that variable must eliminate them. Partition correlation_vars into join_keys (self-produced, unchanged hash path) and reconcile_vars (the rest). Reconcile vars are (a) excluded from the per-row seed so the subquery binds them independently instead of pinning them to the parent value, and (b) checked at the merge: a row whose reconcile var is bound on both sides to different terms is dropped (SPARQL 18.4 compatible-mapping join). self_produced_vars and the join_key hash path are untouched, so BSBM-shape correlated subqueries (self-produced correlation) stay byte-identical. Greens W3C join-scope-1. The seed exclusion also brings the JSON-LD surface's correlated sub-SELECT into parity: the SPARQL nested group lowers to an uncorrelated subquery (join-mode), where the merge reconcile suffices; JSON-LD `["query", ...]` is correlated (per-row seeded), where the merge check alone is a no-op without the seed change. Adds the JSON-LD parity test for the join-scope shape.
…he update harness The UpdateEvaluationTest guard rejected any mf:result that parsed to no ut:data/ut:graphData/ut:result, on the assumption that a deliberate empty-store expectation always carries ut:result ut:success. That is false: the W3C graph-management tests (DROP ALL, the update-silent/* cases) express "expected: empty store" as a bare mf:result [] . Distinguish a present-but- empty result node (legitimate empty expectation) from an entirely absent mf:result (a real manifest mis-parse) via a new Test.result_present flag, and fire the guard only on the latter.
…P/CREATE/COPY/MOVE/ADD)
Add grammar/AST/parse + execution for the SPARQL 1.1 Update graph-management
operations, plus SILENT/INTO. The verbs were already lexed; this is AST +
recursive-descent parse (parse/query/{mod,update}.rs, ast/update.rs) lowering
to a new Txn graph-management directive (transact/ir.rs) executed by a
whole-graph scan primitive at staging time (transact/stage.rs::stage_graph_mgmt):
- CLEAR/DROP GRAPH <g> | DEFAULT scan the g_id and emit retractions; CLEAR/DROP
ALL|NAMED iterate GraphRegistry::iter_entries. DROP is semantically CLEAR
(D-6): the registry is additive-only and an emptied graph is
harness-indistinguishable from a dropped one.
- COPY/MOVE/ADD scan the source graph and re-home its flakes into the
destination by rewriting only the flake's g (datatype/lang/list-index ride
along), composed over the CLEAR primitive (COPY/MOVE clear the destination
first, MOVE clears the source afterward). Set-difference against the
destination avoids assert+retract of the same fact.
- CREATE and SILENT LOAD lower to an empty no-op transaction (Fluree cannot
represent an empty named graph, D-6). Non-SILENT remote LOAD is a documented
divergence (D-5): the embedded transact path has no HTTP client. No W3C eval
test requires a real fetch.
The dispatch is a single Option check at the top of stage(); the ordinary
insert/upsert/update path and pr-u2's multi-op staging stay byte-identical.
Query-surface parity (compliance case 2): expose the new capability on the
non-SPARQL transact surface via Txn::clear_graph/drop_graph/copy_graph and
GraphTransactBuilder::clear_graph/drop_graph/copy_graph, with a parity test in
it_named_graphs.rs.
Also correct pr-u2's cross-operation blank-node scope check: it over-collected
from Modify (INSERT/DELETE ... WHERE) templates, wrongly rejecting the approved
positive tests insert-where-same-bnode/-2 (template bnodes are per-operation,
per-solution; reuse across operations yields distinct nodes). The scope rule
applies to ground DATA forms only (INSERT DATA / DELETE DATA), keeping the
syntax-update-54 negative test rejected.
Register: remove the 68 SPARQL11_UPDATE + 23 double-registered
SPARQL11_SYNTAX_UPDATE_1 graph-management entries (91 lines) that green.
Empty-named-graph model (D-6 second half) resolved as documented divergence:
bindings#graph and agg-empty-group-count-graph stay registered with a
divergence comment (Fluree models a graph as existing iff a flake carries it).
`v.as_array().map(|a| a.len())` trips clippy::redundant_closure_for_method_calls, which is deny at the workspace level, so `cargo clippy --all-targets` failed to compile the grp_graphsource test target. `as_array()` yields `Option<&Vec<Value>>`, so neither `<[_]>::len` (expects &[_]; E0631) nor `Vec::len` (no inherent len) works as a method reference — use an explicit match instead.
A SPARQL FROM / FROM NAMED clause naming graphs within the queried ledger (the ledger alias -> the default graph; registered named-graph IRIs -> named graphs) now builds a within-ledger DataSet over the one snapshot and runs through the shared dataset execution path, reusing DatasetOperator, policy and reasoning. Cross-ledger clause IRIs are rejected (use the connection path). Greens the 12 dawg-dataset tests + constructwhere04; the harness pre-loads clause-referenced files as named graphs. The delegation created a mutual async recursion between query_with_options and query_dataset_with_options (whose single-ledger fast path delegates back), which made the fluree-db-api type-check pathologically slow (30+ min) and defeated Send inference. Broken by giving query_dataset_with_options a concrete boxed return (-> Pin<Box<dyn Future + Send>>), which resolves the opaque-recursive auto-trait cycle. R2RML + within-ledger FROM is a documented unsupported combination (the R2RML dataset path is non-Send and runs from Send-required spawn contexts) and is rejected rather than silently mis-executed. Actions decision D-3 (within-ledger datasets, Option A engine construction).
`DELETE {..} USING <g> WHERE { GRAPH <h> {..} }` over-deleted: the explicit
`GRAPH <h>` block reached graph h even though `USING <g>` (no `USING NAMED <h>`)
scopes the WHERE dataset to default=g with an empty named-graph set. Per SPARQL
1.1 §3.1.3 the GRAPH block must then match nothing ("the GRAPH clause does not
override the USING clause").
`stream_where_into_accumulator` treated an empty `USING NAMED` set as "no
restriction" and registered every named graph in the runtime dataset, so the
`GRAPH <h>` probe found h and generated retractions against the default-graph
DELETE template. Restrict the WHERE-visible named graphs to exactly the
`USING NAMED` set whenever any `USING`/`USING NAMED` clause is present; keep the
ambient all-named-graphs dataset only when no USING clause is present at all, so
a plain `DELETE WHERE { GRAPH <g> {..} }` stays byte-identical.
Greens W3C dawg-delete-using-02a/06a and adds a JSON-LD `fromNamed` graph-scoped
delete-where parity test.
…egates Predicate-grouped COUNT fast paths (StatsCountByPredicateOperator, PredicateObjectCountFirsts) tagged the count xsd:long, but SPARQL COUNT is xsd:integer (§18.5.1.6) — matching the materialized and streaming finalize paths. Greens agg02. SUM/AVG silently skipped bound non-numeric group members and averaged over the numeric subset; per §18.5 a non-numeric member is a type error that unbinds the whole aggregate. Poison both the materialized (aggregate.rs) and streaming (group_aggregate.rs) paths; the streaming numeric hot path is kept first so it stays byte-identical. Greens agg-err-01.
…lang match CONCAT silently skipped a non-string (or unbound) argument, coercing e.g. an xsd:integer away; per §17.4.3.5 a non-string argument is a type error, so the whole result is unbound (concat02). Language tags are case-insensitive (RDF 1.1 §3.3), and a language-tagged literal's datatype is rdf:langString even though the SPARQL results format writes only xml:lang. Normalize both in the harness result comparison so a produced en-US / no-datatype literal matches an expected en-us / rdf:langString fixture (strlang03-rdf11, dawg-langMatches-1/2/3/basic).
Numeric promotion collapsed xsd:float into xsd:double and produced xsd:decimal
for double+decimal. Add a first-class ComparableValue::Float(f32) so the XPath
lattice integer<decimal<float<double holds: float o {integer,decimal,float} =
float, float o double = double, double o decimal = double (op:numeric-*).
xsd:float is stored as an f64 tagged only by its datatype (coerce.rs), so
eval_to_comparable recovers the float type with a single datatype check on the
xsd:double literal path; the Long/String fast paths stay byte-identical.
Greens type-promotion-03/04/05/21/29/30 and expr-ops
{add,subtract,multiply,divide}-numbers-cast, unplus-2, unminus-2.
FILTER(?v) routed a bare variable through a bool-returning EBV where every literal except xsd:boolean false was truthy and unbound was false. Per §17.2.2 the EBV of numeric-zero / empty-string is false, and a language-tagged or foreign-datatype literal, an IRI, an ill-typed literal, or an unbound variable is a type error (a demotable Comparison error, so a FILTER excludes the row and a BIND leaves the variable unbound). Route the bare-variable path through a fallible binding_effective_bool; the lenient From<&Binding>/From<ComparableValue> EBVs stay for the Cypher structural surfaces (lists/maps/paths/rels). Greens dawg-bev-1..6. not-not stays registered: its "z"^^xsd:boolean VALUES row is stored coerced to Boolean(false), losing its ill-typedness before EBV — blocked on ill-typed-literal preservation (D6 / PR-X3).
`=`/`!=` dropped datatypes: a foreign-datatype literal ("zzz"^^:t) compared
equal to a plain "zzz", and incomparable operands returned false/true rather
than a type error. Carry a foreign string datatype into the ComparableValue at
the eval boundary (the Long / xsd:double / xsd:string / lang fast paths stay
byte-identical; language tags are deliberately NOT carried, to keep the string
builtins working) and route `=`/`!=` through a three-valued RDFterm-equal
(§17.4.1.7): value equality with numeric promotion and plain = xsd:string; a
resource is never equal to a literal; two differently-typed recognized values
are known-unequal; an unrecognized / ill-typed datatype that might still denote
the same value is a type error excluding the row for both `=` and `!=`.
Also correct AND/OR to SPARQL three-valued logic (§17.2): false / true dominate
over an operand error instead of aborting on the first one (open-cmp-02).
Greens eq-2-1, eq-2-2, open-eq-04. The rest of the equality cluster stays
registered on separate blockers (typed-literal constant datatypes, blank-node
output identity, temporal timezone semantics, scan-path constraints).
… deferral notes JSON-LD-surface regressions alongside the SPARQL ones (the W3C submodule guards only the SPARQL surface): numeric-promotion result datatype (D4), datatype-aware EBV (D-EBV), RDFterm-equal value equality vs known-unequal (D5), CONCAT non-string type error (D11), AVG non-numeric poison (agg-err-01), plus wave-2-failing discriminating regressions a revert would fail: foreign / ill-typed datatype vs plain string is a type error under both = and != , and three-valued OR (true dominates an operand error). The predicate-grouped COUNT xsd:integer tests (agg02) are confirmatory-only, not discriminating: a memory ledger routes through the general group_aggregate finalize (already xsd:integer on wave-2) and bypasses the indexed StatsCountByPredicateOperator fast path where the xsd:long bug lived; the fast path is detected only for the bare `GROUP BY ?p COUNT(?o)` shape (no post-aggregation bind), and xsd:long/xsd:integer both render as bare JSON numbers, so the datatype can't be observed without dropping off the fast path. The revert-catching coverage is the greened W3C agg02 test against indexed storage (reviewer finding #4b). Realigns two pre-existing float-division tests (grp_query / grp_query_sparql) to the typed-literal rendering now that D4 keeps the result xsd:float instead of widening it to xsd:double. Also updates register comments to name PR-X2 as decision-owner for the deferred cluster with the real root cause (scan-path BGP object-match for eq-graph/open-eq-02/quotes; temporal for date-1/eq-dateTime; blank-node output identity for open-eq-07..12; typed-constant lowering for open-eq-05/06). fmt normalization of the eval files.
# Conflicts: # testsuite-sparql/tests/registers/mod.rs
# Conflicts: # fluree-db-transact/src/lower_sparql_update.rs
# Conflicts: # testsuite-sparql/tests/registers/mod.rs
…agement ops
stage_graph_mgmt's whole-graph scan/re-home for SPARQL 1.1 Update / builder
graph management (CLEAR/DROP/COPY/MOVE/ADD) had two gaps.
B2 — reserved graphs. The single-graph CLEAR/DROP path and the COPY/MOVE/ADD
source and destination resolved a graph by IRI with no reserved-graph guard,
while the Named/All scope already excluded g_id < FIRST_USER_GRAPH_ID. So
DROP GRAPH <urn:fluree:{ledger}#config> retracted SHACL/uniqueness governance,
CLEAR GRAPH <...#txn-meta> shredded commit metadata, and COPY/MOVE/ADD into them
injected flakes. Reject g_id < FIRST_USER_GRAPH_ID at all three sites via a new
TransactError::ReservedGraphTarget, mirroring the cross-ledger resolver's
reserved-graph guard.
O5 — edge annotations. Re-homing rewrites only each flake's g, but an edge
annotation encodes its edge's graph in the f:reifiesGraph object and anchors the
bundle by flake-level g; a COPY/MOVE/ADD desynced the two, so
EdgeKey::from_reifies_facts returned GraphMismatch and both readers (JSON-LD
hydration, attachment indexer) silently dropped the annotation. Fail loud with
TransactError::UnsupportedFeature when the source contains any f:reifies* flake;
full reification-aware re-homing is a follow-up.
Also document three existing behaviors inline: O3 (a missing/typo'd COPY/MOVE
source empties the destination under roadmap D-6), N3 (CLEAR ALL/DEFAULT retracts
default-graph schema — spec-correct), and O4 (graph management deletes the
modifiable set, not viewable-intersect-modifiable, unlike conditional
DELETE-WHERE — by design, CLEAR is unconditional per SPARQL 3.2).
Tests (it_named_graphs.rs): reserved-graph rejection for CLEAR/DROP/COPY/MOVE/ADD
against #config/#txn-meta and annotation-bearing transfer rejection (both fail on
wave-3, where the op silently succeeds, and pass here), plus a
graph-management-under-modify-policy regression-lock pinning the
policy-not-bypassed invariant (enforce_modify_policies still runs on the
whole-graph path).
The numeric builtins matched only Long/Double/Decimal/BigInt, so a stored xsd:float (routed to ComparableValue::Float by lit_to_comparable) fell to the Some(_) => Ok(None) catch-all and returned unbound; isNumeric likewise reported false. Add a type-preserving Float arm to eval_abs/round/ceil/floor (fn:abs/ceiling/floor are type-preserving; ROUND uses W3C half toward positive infinity, mirroring the Double arm) and Float to eval_is_numeric. The arms sit before the catch-all, so the Long/Double/Decimal/BigInt common path is byte-identical.
eval_in/eval_not_in compared with the derived variant-exact PartialEq, so 1 IN (1.0) was false while 1 = 1.0 was true. Route the membership test through rdf_term_equal (now pub(crate)) so IN uses the same value equality as =. The error-in-IN three-valued type-error propagation (SS 17.4.1.9) is deferred to a follow-up. The no-IN common path is unchanged.
eval_concat took its string via ComparableValue::as_str(), which also exposes IRIs and foreign-datatype literals, so CONCAT of an IRI or a foreign-typed literal silently concatenated instead of raising a type error (SS 17.4.3.5). Reject those locally in eval_concat, accepting only plain, xsd:string, and language-tagged strings. as_str() is left unchanged, since STR and the other string builtins rely on it accepting IRIs.
COPY/MOVE clear the destination before copying the source in, so a typo'd
or never-registered source graph — which scans as empty — silently empties
the destination: `COPY <typo> TO <important>` destroys `<important>`. SPARQL
1.1 Update §3.2 requires ADD/COPY/MOVE to raise an error when the source
graph does not exist, unless SILENT.
The additive-only graph registry (roadmap D-6) makes this fixable: a
never-registered source resolves to `None`, while an emptied-but-registered
graph resolves to `Some(g_id)` with zero flakes. The two are distinguishable,
so staging errors on a `None` source (unless SILENT) without breaking the
legitimate empty-source case.
- ir.rs: add `silent` to `GraphMgmtOp::Transfer`; the `copy_graph` builder
sets it false (errors on a missing source, like non-SILENT SPARQL)
- lower_sparql_update.rs: thread the parsed SILENT flag into the Transfer IR
- stage.rs: at source resolution, a never-registered `GraphSel::Graph` source
without SILENT returns `SourceGraphNotFound` before the clear-dest logic;
refresh the stale O3 comment
- error.rs: add `SourceGraphNotFound { graph_iri }`
- it_named_graphs.rs: regression test — non-SILENT COPY/MOVE/ADD from a missing
source error and preserve the destination; SILENT suppresses; a
registered-but-emptied source still proceeds
bplatz
left a comment
There was a problem hiding this comment.
Inline notes from a review pass. Two look like must-fix (system-graph exposure via FROM, and a reachable transactor panic on triple-term syntax); the rest are correctness gaps plus a few worth-a-glance items.
|
|
||
| // Registered named graph in this ledger (registry, with the same | ||
| // binary-store fallback `select_graph` uses). | ||
| let known = db.snapshot.graph_registry.graph_id_for_iri(iri).is_some() |
There was a problem hiding this comment.
known is also true for the reserved system graphs — the registry seeds #txn-meta (g_id 1) and #config (g_id 2). So FROM <urn:fluree:{ledger}#config> resolves and exposes the config graph, which the plain GRAPH <iri> path blocks via single_db_user_graph_id (>= FIRST_USER_GRAPH_ID). Suggest gating known on g_id >= FIRST_USER_GRAPH_ID.
There was a problem hiding this comment.
Confirmed — the registry legs of resolve_within_ledger_graph lacked the >= FIRST_USER_GRAPH_ID filter the GRAPH <iri> path applies via single_db_user_graph_id. Fixed in 8d8870ba1: both legs (registry + binary-store fallback) now gate on user graphs, so FROM <…#config> / FROM NAMED <…#txn-meta> fall through to the standard "not in this ledger" rejection; test covers all four clause/graph combinations plus the user-graph positives.
| SubjectTerm::QuotedTriple(_) => { | ||
| unreachable!("RDF-star quoted triples are rejected before annotation expansion") | ||
| } | ||
| SubjectTerm::TripleTerm(_) => { |
There was a problem hiding this comment.
This is reachable. expand_annotated_triples guards QuotedTriple but not TripleTerm, so an annotated triple-term subject parses and panics here instead of returning the accept-then-defer error, e.g. INSERT DATA { <<( :a :b :c )>> :p :o {| :m :n |} }. Add a matching TripleTerm guard next to the QuotedTriple one.
There was a problem hiding this comment.
Confirmed and empirically reproduced — this landed as 881a8e20c (pushed shortly after your review was cut): expand_annotated_triples now guards SubjectTerm::TripleTerm beside the QuotedTriple guard, returning the same accept-then-defer UnsupportedFeature as every other deferred triple-term shape, with negative tests for the INSERT DATA and DELETE-template paths (plus the QuotedTriple twin).
| // registered named graph) applies — the case a plain | ||
| // `DELETE WHERE { GRAPH <g> { .. } }` (no USING) relies on. That | ||
| // no-USING path stays byte-identical to before. | ||
| if w.using_default_graph_iris.is_empty() && w.using_named_graph_iris.is_empty() { |
There was a problem hiding this comment.
This fixes the named-graph half; the default-graph half of §3.1.3 still looks off. With USING NAMED <g2> and no plain USING, desired_where_default_graph_iris is empty and base_db falls back to g_id 0 (the real default) rather than the empty graph, so DELETE {..} USING NAMED <g2> WHERE {..} can still read/delete the default graph. Worth verifying.
There was a problem hiding this comment.
Confirmed — same conclusion independently: USING NAMED <g2> with no plain USING fell back to g_id 0, so a default-scoped WHERE read (and deleted from) the real default graph. Fixed as 5b20e07ae (also just before your review posted): any USING NAMED with no plain USING gives the WHERE dataset an EMPTY default-graph member list (§13.2.1 via §3.1.3; WITH is likewise ignored for the WHERE when any USING form is present). The test proves fail-without-fix — the pre-fix code deletes the whole default graph.
| FlakeValue::BigInt(n) => Ok(!n.is_zero()), | ||
| FlakeValue::Decimal(d) => Ok(!d.is_zero()), | ||
| FlakeValue::String(s) if is_xsd_string(dtc) => Ok(!s.is_empty()), | ||
| _ => Err(ebv_type_error()), |
There was a problem hiding this comment.
A cast/computed xsd:float is a FlakeValue::String tagged xsd:float, which isn't is_xsd_string, so it falls to ebv_type_error() → false in FILTER. lit_to_comparable coerces such floats for comparison but EBV doesn't, so FILTER(?f) drops a truthy float. Coerce numeric-XSD string datatypes before the error fallthrough.
There was a problem hiding this comment.
Confirmed, and it went one level deeper: besides lit_effective_bool (the BIND'd-binding path), the function-call path used the lenient Into<bool> conversion, so FILTER(xsd:float("0")) was TRUE. Fixed in f52d43847: string-backed numeric/boolean-tagged literals parse their lexical form on both paths — well-formed values follow the numeric/boolean rule, ill-formed forms are EBV FALSE per §17.2.2 rule 1 (not a type error). The dispatcher routes only TypedLiteral results through the strict path; everything else keeps lenient truthiness, which the shared Cypher surface's structural semantics rely on (D-12 carve-out — a Cypher CALL test locks this). Fixing the tests exposed a second bug: STRDT's datatype arg arrives as a Sid, which as_str() doesn't expose, so STRDT(?x, xsd:integer) silently produced a PLAIN literal — same commit decodes Sid datatype args.
| UpdateOperation::Clear(gm) | UpdateOperation::Drop(gm) => { | ||
| lower_clear_drop(gm, prologue, opts)? | ||
| } | ||
| UpdateOperation::Create(_) => { |
There was a problem hiding this comment.
CREATE no-ops without registering the graph, so a subsequent non-SILENT COPY <g> TO <h> / ADD / MOVE hits the new O3 "source does not exist" error even though CREATE reported success — the two behaviors contradict. (Also: the CLEAR/DROP silent flag is parsed but never read.)
There was a problem hiding this comment.
Confirmed — fixed in bf4b626a0: CREATE now registers the graph IRI in the additive registry (the same graph_delta mechanism as a transfer destination), so CREATE GRAPH <g> followed by non-SILENT COPY <g> TO <h> is a legitimate empty source. This needed two plumbing exceptions for registration-only (zero-flake) commits — the tx-builder no-op shortcut and build_commit's EmptyTransaction gate — both scoped to deltas containing an unregistered IRI, so ordinary no-op updates still elide. CREATE-of-existing stays a no-op rather than the §3.2.2 error (documented D-6 divergence), and the CLEAR/DROP silent flag now carries a doc note explaining why it is never consulted (a CLEAR/DROP of an unregistered graph is a D-6 no-op, so there is no error to suppress).
| // retract the WHOLE default graph, including schema flakes | ||
| // (rdfs:Class, rdfs:subClassOf, …). That is spec-correct — CLEAR | ||
| // is an unconditional whole-graph retraction — but a one-line | ||
| // `CLEAR ALL` strips the ontology, and because `is_schema_flake` |
There was a problem hiding this comment.
Worth a glance / a pinning test: this N3 bypass lets a narrow-grant identity CLEAR ALL (or MOVE DEFAULT TO <g>) and wipe/re-home the ontology unblockably. test_graph_mgmt_honors_modify_policy uses a non-schema flake, so it doesn't cover this case. Might be the intended contract, but a test pinning it would help.
There was a problem hiding this comment.
Agreed it's the intended contract, and agreed it deserved a pin — added in 2205252c5: a view-only default-deny identity CAN CLEAR DEFAULT a schema-only graph (the is_schema_flake exemption), sitting beside the companion test proving the same identity is REJECTED for non-schema flakes. Narrowing the exemption later now flips a test instead of moving silently.
| let graph = self | ||
| .resolve_within_ledger_graph(db, iri)? | ||
| .ok_or_else(cross_ledger_dataset_error)?; | ||
| dataset = dataset.with_default(graph); |
There was a problem hiding this comment.
Two minor things: repeated FROM <g> pushes the same graph twice → bag union, so solutions duplicate (spec wants RDF merge/dedup). And an unresolved within-ledger IRI (e.g. a typo) returns cross_ledger_dataset_error, telling the user to use the cross-ledger path — misleading for a local typo.
There was a problem hiding this comment.
Both fixed in dd86e74b3: the within-ledger dataset build dedups default-graph members by resolved graph id (FROM <g> FROM <g> contributes one member — §13.2 set semantics; regression test compares row-for-row against single-FROM), and the rejection message now says to check the IRI against the ledger's registered graphs before reaching for the cross-ledger API. (The stacked #1483 additionally set-dedups triples across DISTINCT default members at execution time.)
| ledger: &LedgerState, | ||
| g_id: GraphId, | ||
| tracker: Option<&Tracker>, | ||
| ) -> Result<Vec<Flake>> { |
There was a problem hiding this comment.
Not a correctness issue, just noting: CLEAR ALL / large COPY/MOVE materialize every flake into a Vec and only check at_max_novelty at entry, so one op can roughly double novelty in a single commit — a scale cliff for exactly the ops most likely to touch the whole store.
There was a problem hiding this comment.
Agreed — documented on scan_graph_flakes in 2205252c5: whole-graph ops materialize every flake and only check backpressure at commit entry, so one CLEAR ALL/large COPY can roughly double novelty in one commit; chunked staging for whole-graph ops is the named follow-up if the cliff is hit in practice. Left as a doc note rather than code in this PR.
| // here mapped to the same var, merging two intended-distinct | ||
| // template blanks; matches the `#bnpl…`/`#coll…` labeling the | ||
| // parser already uses for property lists / collections.) | ||
| let label = format!("_:#anon{}", self.anon_counter); |
There was a problem hiding this comment.
Worth a quick glance: the unforgeable #anon counter added here isn't used by lower_reifier_id in lower/annotation.rs, which still mints _:b{vars.len()} — so a user-written _:bN could collide with an anonymous reifier blank. Looks like this path was missed by the hardening pass.
There was a problem hiding this comment.
Confirmed for the path you named — and it's resolved by the origin/main merge now on this branch: main's 5c7cde84b (wave-2 review response) had independently hardened query-side anon labels — including lower_reifier_id — with the _:[]{n} scheme, which the merge reconciliation adopted branch-wide (the wave-3 #anon counter was dropped in its favor). Sweeping for stragglers found one more genuinely-forgeable instance neither pass caught: the transact-side BlankNodeCounter minted INSERT-template anon blanks as _:b{n}, so INSERT { [] ex:q ?h . _:b0 ex:p ?h } fused both into one node — fixed with the same _:[] scheme + regression test in dd86e74b3.
| /// part of the W3C dataset, so they must never be a graph-management target. | ||
| /// Mirrors the cross-ledger resolver's reserved-graph guard. | ||
| #[error("graph-management operation targets reserved system graph <{graph_iri}>; refusing")] | ||
| ReservedGraphTarget { |
There was a problem hiding this comment.
Worth confirming ReservedGraphTarget / SourceGraphNotFound map to a 4xx client error upstream, not a 500.
There was a problem hiding this comment.
Verified, no change needed: fluree-db-server/src/error.rs maps ApiError::Transact(_) to 400 via the catch-all (status_code()), with 409 reserved for the retryable conflict variants (CommitConflict/PublishLostRace/NamespaceConflict) — so ReservedGraphTarget and SourceGraphNotFound both surface as 400 Bad Request with the stable INVALID_TRANSACTION error type.
…anicking
The annotation-tail expansion pre-pass (expand_annotated_triples) runs
before the quad-pattern lowering whose TripleTerm arms defer cleanly, and
it guarded a QuotedTriple subject but not an RDF 1.2 triple-term subject —
so parse-accepted input like
INSERT DATA { <<( :s :p :o )>> :q :o2 {| :a :b |} }
reached subject_to_object's unreachable!() and panicked the transactor
(reachable from INSERT DATA, DELETE DATA, and Modify templates). Mirror
the QuotedTriple guard for SubjectTerm::TripleTerm, returning the same
kind of UnsupportedFeature the other deferred triple-term shapes produce
(accept-then-defer, D-1).
Adds parse-then-lower negative tests for the INSERT DATA and DELETE
template shapes, plus the pre-existing QuotedTriple twin, so a future
reordering of the pre-pass cannot silently reintroduce the panic.
XPath op:numeric-equal (F&O §4.2.3) defines NaN as unequal to every value including NaN, so `"NaN"^^xsd:double = "NaN"^^xsd:double` must be false and `!=` true. rdf_term_equal's numeric fast path bottoms out in numeric_cmp's bit-level total order — kept deliberately for ORDER BY stability — which reports two identical-bit NaNs as Equal, so `=` said true. Catch NaN (Double or Float) first and return Ne, scoped to numeric-vs-numeric operands so NaN vs an unrecognized-datatype literal still reaches the TypeError arm. The ORDER BY total order is unchanged (identical-bit NaNs still sort as equal), and NaN inside IN/NOT IN follows `=` for free. Pre-existing behavior surfaced by the wave-3 X2 equality review; no register test pins it, so the W3C suite is unaffected. Unit tests pin the equality outcomes and the equality-only scope; integration tests cover the constant and stored forms on both the SPARQL and JSON-LD surfaces (shared-IR rule).
…lt graph SPARQL 1.1 §13.2.1 (via Update §3.1.3): a dataset described only by FROM NAMED / USING NAMED clauses "includes an empty graph for the default graph", and a WITH clause is ignored for the WHERE clause whenever any USING/USING NAMED is present. The runtime-dataset construction only handled the named-graph side (PR-U6's #1441 fix); default-graph selection fell through to the ledger's real default graph (g_id 0), so DELETE { ?s ?p ?o } USING NAMED <h> WHERE { ?s ?p ?o } matched — and deleted — the entire default graph. The same over-reach class as #1441, one clause over; not covered by the W3C delete-using tests (all six use a plain USING). When any USING NAMED is present with no plain USING, build the runtime dataset with an empty default-graph list: default-scope scans then iterate zero members (ActiveGraphs::Many([])) and bind nothing, while the USING NAMED set stays visible to GRAPH blocks. The no-USING ambient path and the plain-USING/multi-USING paths are byte-identical. The new integration test fails without the fix (the default graph is emptied) and pins the named-set-still-visible and ambient-control cases. The symmetric JSON-LD fromNamed-only restriction remains the documented scoped follow-up from PR-U6.
…g tags; ROUND/CEIL/FLOOR pass BigInt through
Three evaluator edges from the wave-3 review, each with SPARQL + JSON-LD
tests (shared-IR rule):
- eval_negate never called coerce_numeric_operand, unlike binary
arithmetic and comparison, so a cast result (a string-backed
`TypedLiteral{Explicit(xsd:float)}`) hit the non-numeric arm and
`-xsd:float("1.5")` came back unbound while `xsd:float("1.5") - 0`
worked. Coerce the operand first.
- eval_concat read the result language tag only off a Var BINDING
(extract_lang_tag), so constant and computed args — `"a"@en`,
STRLANG(...) — lost their tag: `CONCAT("a"@en, "b"@en)` returned plain
"ab" where §17.4.3.5 requires "ab"@en. Prefer the tag carried on the
value itself, fall back to the binding, and compare tags
case-insensitively per RDF 1.1 (first spelling wins).
- eval_round/eval_ceil/eval_floor had no BigInt arm (ABS does), so an
xsd:integer beyond i64 fell into the catch-all and returned unbound;
integers are already integral, so pass them through.
…n same-graph transfers The transact-builder surface exposed clear/drop/copy_graph but not the other two SPARQL transfer verbs, though the GraphMgmtOp::Transfer IR already supports every mode — add Txn::move_graph/add_graph and the GraphTransactBuilder twins, with a builder↔SPARQL parity test. Two review follow-ups on the Transfer arm: - The `from == to` spec no-op short-circuited BEFORE the reserved-graph guard, so `COPY <…#config> TO <…#config>` was accepted as a no-op rather than refused; check reserved-ness first (and document that SILENT deliberately does not suppress the reserved-graph guards). - New tests pin the same-graph no-op for all three verbs on both surfaces — the guard is the only thing between a refactor and MOVE's clear_dest destroying the graph — plus the reserved same-graph rejection. Also document two model facts the review surfaced: a SILENT transfer from a missing source still CLEARS the destination (the spec's own DROP-SILENT shortcut equivalence, not a no-op), and O3 source-existence deliberately uses registry semantics (survives CLEAR) as distinct from the query surface's flake-carried D-6 model — only the registry can tell a typo from a cleared graph.
…le-term arity domain Two review nits: instantiate_row now short-circuits the per-term bnode_vars probes when the CONSTRUCT template has no blank nodes (the common case; the row map itself never allocated), and row_blank's doc states the cst counter's per-execution scope (merging two CONSTRUCT outputs needs standardizing apart — standard RDF blank scoping). triple_term_fn_arity's silent `_ => 1` catch-all becomes a debug_assert so a future caller routing a non-triple-term builtin through it fails loudly instead of mis-validating arity.
…twin; record CONSTRUCT bnode parity exemption Review coverage gaps: commit 0752012 changed AND and OR but only OR was pinned — add both dominance directions (F && err = F survives a negated conjunction; err && T = err drops the row) on both surfaces. Commit 45d6009 says "aggregates" plural but only AVG-poison was tested — add the SUM pair. And the memory-backed poison twins exercise only the general/streaming aggregate, so add a reindexed-ledger twin (seed_indexed hard-asserts range_provider.is_some(), defeating the memory-fallback fixture trap) pinning SUM/AVG poison where production ledgers run. Also record the CONSTRUCT blank-node-template parity exemption in sparql-compliance.md: the FQL construct template is a JSON-LD node map with no syntax for per-solution-row fresh blanks (auto-subjects are variables; an explicit _: @id is one fixed node), so the cst{n} minting is SPARQL-surface-only by design, not a parity gap.
…d correlation, per-row CONSTRUCT minting
Four review "verified by inspection, untested" scenarios, now pinned:
- A multi-operation request mixing a data op with a graph-management op
(`INSERT DATA { GRAPH <g> … } ; COPY <g> TO <h>` and the
insert-then-CLEAR shape): each op must observe its predecessor's
effects — including a g_id registered earlier in the SAME request —
through the sequential-staging novelty view.
- CLEAR of an annotation-bearing graph retracts the whole f:reifies*
bundle cleanly: graph reads empty, and re-inserting the identical
annotated edge succeeds (a leftover anchor would corrupt the bundle).
- Family B reconciliation composes through TWO nesting levels of
sub-SELECTs, and a correlation var used as a correlated sub-SELECT's
GROUP BY key joins per-group (john's count of 2, not a pinned or
leaked grouping).
- §16.2 CONSTRUCT: two solution rows with IDENTICAL bindings still mint
two distinct template blanks — per-row, not per-distinct-binding
(Jena/oxigraph-consistent).
# Conflicts: # fluree-db-sparql/src/lower/term.rs # fluree-db-sparql/src/parse/query/update.rs # fluree-db-transact/src/lower_sparql_update.rs # testsuite-sparql/tests/registers/mod.rs
The graph registry seeds the reserved #txn-meta (g_id 1) and #config
(g_id 2) system graphs, so the within-ledger dataset path's "is this a
registered graph" check resolved them and FROM <urn:fluree:{ledger}#config>
exposed the governance/config graph to any query — while the plain
GRAPH <iri> path blocks them via single_db_user_graph_id's
>= FIRST_USER_GRAPH_ID filter. Gate both resolution legs (registry and
binary-store fallback) on the same filter; a reserved IRI now falls
through to the standard "not in this ledger" rejection.
Review finding (bplatz) — the query-side twin of the wave-3 transact-side
reserved-graph guards. Test covers FROM and FROM NAMED over both reserved
graphs plus the existing user-graph positive cases.
…Sid datatype args
Review finding (bplatz): a cast/computed numeric literal — xsd:float("1.5")
is a String-backed TypedLiteral tagged xsd:float — had no EBV, so
FILTER(?f) dropped a truthy float on the Binding::Lit path, and the
function-call path's lenient bool conversion read EVERY typed literal as
truthy (FILTER(xsd:float("0")) passed). Per §17.2.2: parse the lexical
form of a numeric/boolean-tagged string — well-formed values follow the
numeric/boolean rule, and an ILL-FORMED lexical form is EBV FALSE
(rule 1), not a type error. Applied to lit_effective_bool (BIND'd casts)
and comparable_effective_bool (direct expression results); the function
dispatcher routes only TypedLiteral results through the strict path —
everything else keeps the lenient conversion, which the shared Cypher
surface's structural truthiness deliberately relies on (D-12 carve-out).
Fixing the tests exposed a second gap: STRDT's datatype argument arrives
as a Sid (constant IRIs in expression position lower through the IRI
encoder), which as_str() deliberately does not expose — so
STRDT(?x, xsd:integer) silently produced a PLAIN literal
(DATATYPE(STRDT(…)) came back unbound-ish and EBV saw a bare string).
Decode Sid datatype args back to the full IRI.
Tests cover the direct and BIND'd EBV paths, well-formed and ill-formed
lexicals (discriminated from demoted errors via negation), booleans, and
the JSON-LD twin.
…query operator
Merge fallout (wave-3 x main): openCypher's `CALL (p) { … }` defines the
import as a PER-ROW binding, but the shared SubqueryOperator's Family-B
rule (W1, SPARQL §18.4) classifies a correlation var the body binds only
via OPTIONAL as a reconcile var — evaluated unseeded and reconciled at
merge. `CALL (p) { OPTIONAL MATCH (p)-[:KNOWS]->(f) RETURN count(f) }`
therefore dropped a zero-match parent instead of retaining it with 0
(main's newly-wired it_query_cypher::cypher_call_subquery_correlated_
aggregate caught it; the failure reproduces at the merge commit with no
wave-3-review changes applied).
SubqueryPattern gains pinned_vars — explicit lateral imports, set only by
the Cypher CALL lowering. A pinned var is always seeded in per-row mode
(never reconciled), and a pinned var the body does not itself produce
forces per-row evaluation (evaluate-once + hash-join cannot retain
zero-match parents). SPARQL/JSON-LD subqueries leave it empty, so W1's
Family-B semantics are untouched (join-scope-1 tests unchanged); the
plain-MATCH join-mode promotion keeps its evaluate-once path (imports all
self-produced).
Review finding (bplatz): CREATE lowered to a pure no-op, so `CREATE GRAPH <g>` reported success yet a subsequent non-SILENT `COPY <g> TO <h>` raised O3's "source graph does not exist" — the two behaviors contradicted. CREATE now records the IRI in graph_delta (the same mechanism as a transfer destination): the graph is registered, empty, and non-enumerable (D-6), and O3's source-existence check — which consults exactly that registration — treats it as a legitimate empty source. CREATE of an already-registered graph stays a no-op rather than the spec's §3.2.2 error (documented D-6 divergence; `silent` has nothing to suppress), and lower_clear_drop documents the symmetric reason the CLEAR/DROP silent flag is never consulted. Persisting a registration with zero staged flakes needs two plumbing exceptions: the tx-builder's no-op-update shortcut and build_commit's EmptyTransaction gate both now let a commit through when its graph_delta contains an IRI the registry doesn't know yet (already-registered deltas keep the old no-op elision, so ordinary no-op updates still skip committing). Tests cover the cross-request CREATE→COPY flow, the never-registered control, and the same-request multi-op CREATE;ADD.
… typo-aware dataset rejection
Three review follow-ups (bplatz):
- The transact-side BlankNodeCounter minted INSERT-template anonymous
blanks as `_:b{n}` — inside the user label namespace, so a template
`[] ex:q ?h . _:b0 ex:p ?h` fused both into ONE node per solution.
Mint `_:[]{n}` instead (`[` is outside PN_CHARS), matching the
query-side scheme main standardized; the annotation-reifier arm's
`__fluree_ann_` reservation is unaffected. (The query-side
lower_reifier_id instance of this class was already fixed by main's
5c7cde8, which this branch now carries via the merge.)
- A repeated `FROM <g>` pushed the same graph into the default union
twice — a bag that doubled every row where §13.2 defines a SET. The
within-ledger dataset build now dedups members by resolved graph id.
- The unresolved-FROM-IRI rejection read as "use the cross-ledger path",
which is misleading for a local typo; the message now says to check
the IRI against the ledger's registered graphs first.
…e whole-graph novelty cliff Review follow-ups (bplatz): a view-only, default-deny identity CAN CLEAR DEFAULT a schema-only graph — is_schema_flake exempts schema flakes from modify policy, so the ontology wipe is deliberately not policy-blockable (N3). The new test pins that boundary next to its companion (same identity REJECTED for non-schema flakes), so narrowing the always-allow exemption later flips a test rather than moving silently. scan_graph_flakes now documents the scale cliff: whole-graph ops materialize every flake and only check backpressure at entry, so one CLEAR ALL / large COPY can roughly double novelty in a single commit — chunked staging is the known follow-up. (Reviewed, no change needed: ReservedGraphTarget / SourceGraphNotFound map to 400 via the server's ApiError::Transact(_) catch-all — 409 is reserved for the retryable conflict variants.)
|
All eleven review comments are addressed on the branch — per-comment replies are threaded inline. Three were fixed by the second-round commits that landed just before the review posted (the triple-term panic, the Two things from this response wave worth calling out beyond the inline threads:
Verified on the tip: full W3C suite 36 passed / 0 failed / 0 stale; 5,173 scoped integration/unit tests passing (query/transact/api/sparql/cypher); |
Wave-3 SPARQL W3C burn-down — within-ledger datasets, UPDATE graph-management, the equality/EBV/promotion lattice, and SPARQL 1.2 triple-term syntax
This is the capstone wave of the W3C SPARQL test-suite burn-down: seven thematically-coherent PRs integrated onto
burndown/wave-2. It greens 146 W3C tests to a suite that is 0-failed / 0-stale in both directions (cd testsuite-sparql && cargo test→36 passed), shrinking the skip register from 376 to 197 entry lines (343 → 197 unique tests — the tip register carries zero duplicate entries after thec91f5556cdedup). The wave is perf-neutral: the integrated A/B vsburndown/wave-2is within the 5% budget on all ten measured scenarios (worst +2.34%). The seven PRs are PR-G2 (within-ledgerFROM/FROM NAMED), PR-W1 (nested-group FILTER scope + sub-SELECT correlation), PR-W2BC (SPARQL 1.2 triple-term syntax), PR-W2 (CONSTRUCT blank-node instantiation), PR-U3 (UPDATE graph-management verbs), PR-U6 (USING + explicit-GRAPH delete scoping), and PR-X2 (the equality/EBV/numeric-promotion lattice).Now based directly on
main— #1454 (Wave 2) has merged, andorigin/main(through the #1450 Iceberg stack) is merged into this branch (d072a95d6), so the PR is conflict-free againstmain.Reviewer quick reference
End-user API / contract changes. SPARQL
SELECT … FROM <g> FROM NAMED <n>against a single ledger's graphs now returns results instead of being rejected (PR-G2). Seven SPARQL UPDATE graph-management verbs —LOAD/CLEAR/DROP/CREATE/COPY/MOVE/ADD(+SILENT/INTO) — now parse and execute, and the capability is exposed on the transact-builder surface (Txn::clear_graph/drop_graph/copy_graph/clear_default_graph,GraphTransactBuilder::clear_graph/drop_graph/copy_graph) (PR-U3). SPARQL/JSON-LD=/!=, effective-boolean-value,AND/OR, numeric promotion,CONCAT, andSUM/AVGare now spec-strict value semantics (PR-X2 — DELIBERATE SEMANTICS CHANGE (D-12), 2-reviewer sign-off requested) — this can return fewer rows for queries that relied on the old permissive behaviour; see the migration notes.DELETE … USING <g> WHERE { GRAPH <h> … }now scopes the explicit GRAPH block to theUSING NAMEDset (deletes fewer rows — a data-loss fix) (PR-U6). ACONSTRUCTwhose template has a blank node now emits triples instead of an empty graph (PR-W2). SPARQL 1.2 triple-term syntax (TRIPLE/SUBJECT/PREDICATE/OBJECT/isTRIPLEand bare<<( s p o )>>values) now parses/validates and defers evaluation with a cleannot_implemented(PR-W2BC). PR-W1 has no API surface change.Performance verdict: WITHIN BUDGET. Integrated A/B vs
burndown/wave-2,FLUREE_BENCH_PROFILE=full(n=100): all ten scenarios within the 5% budget, worst +2.34% (queryq5). The whole wave — including the u3/u6 transact/staging path — is perf-neutral. Full table below.Production bugs found & fixed (beyond W3C compliance). (1)
USING-scoped delete with an explicitGRAPHblock over-deleted from the default/other graph — a real data-loss bug, Closes #1441 (PR-U6). (2) Predicate-groupedCOUNTmis-taggedxsd:long;SUM/AVGsilently averaged over the numeric subset of a mixed group;AND/ORaborted the whole expression on the first operand error;CONCATsilently coerced non-strings — Closes #1447 (PR-X2). (3) A JSON-LD correlated sub-SELECT returned an extra non-conformant row when a correlation variable was bound only via an inner OPTIONAL (PR-W1). (4) PR-U2's cross-operation blank-node scope check over-rejected valid multi-op requests, and the W3C harness rejected a legitimate baremf:result []empty-store expectation (both fixed in PR-U3).What this wave does (per PR)
PR-G2 — within-ledger
FROM/FROM NAMED(D-3, Option A). Replaces the single-ledger dataset-clause rejection on the buffered query paths with within-ledger dataset construction: each clause IRI is resolved against the ledger's own graph registry (alias → default graph, registered named-graph IRI → that g_id), assembled into a single-snapshotDataSetDb, and executed through the existingDatasetOperator/dataset_query.rspath. A clause IRI that is not a graph in this ledger is still rejected (cross-ledger goes through the connection path). R2RML + within-ledgerFROMis a documented unsupported combination (the R2RML dataset path is non-Send). Also fixes a mutual-async-recursion compile trap the delegation created —query_dataset_with_optionsgets a concrete boxed return (Pin<Box<dyn Future … + Send>>), droppingcargo checkon the crate from 30+ min to ~7s.PR-W1 — nested-group FILTER scope + sub-SELECT correlation. Two independent evaluation fixes. Family A: a single-child
{ }group whose child is scope-sensitive (FILTER/BIND/nestedGroup) stays wrapped as an uncorrelated subquery rather than being flattened into the enclosing scope (so a nested FILTER no longer sees an enclosing-scope variable). Family B: a sub-SELECT correlation variable bound only via OPTIONAL/UNION is reconciled at merge (excluded from the per-row seed so it binds independently, then dropped on a conflicting double-binding) — the compatible-mapping join SPARQL §18.4 requires. Common self-produced-correlation and BGP/OPTIONAL paths (incl. BSBM) stay byte-identical.PR-W2BC — SPARQL 1.2 triple-term syntax (D-1, accept-then-defer). The lexer emits a single contextual
TripleTermFntoken for the five function names (not reserved keywords), the expression parser resolves + arity-validates them, and a newparse_triple_term_valueaccepts<<( s p o )>>in the subject/object/VALUES/BINDpositions RDF 1.2 allows; lowering rejects all deferred forms withnot_implemented. The §2 negative-suite guardrails (no path/collection/reified-triple inside a term; value-context vs pattern-context subject rules; bare<<( )>> .is not a statement) keep every negative test rejected. No new evaluable capability — this is syntax acceptance only.PR-W2 — CONSTRUCT per-solution blank-node instantiation. A CONSTRUCT template blank node (
[ … ],_:a, or a PR-1 collection cell) previously lowered to a never-bound variable and produced an empty graph. The fix records template blank-node variables at lowering (ConstructTemplate.bnode_vars) and mints a fresh blank node per solution row in the CONSTRUCT output path — shared within a row, distinct across rows — under a reservedcst{n}label prefix provably disjoint from stored (fdb-),BNODE()(b{hex}), and SERVICE blanks (a soundness hardening the isomorphism-based suite cannot catch).PR-U3 — SPARQL UPDATE graph-management verbs (D-5, D-6). Grammar + AST + parse + lowering to a new
GraphMgmtOpTransaction-IR directive + a whole-graph-scan staging primitive (stage_graph_mgmt). A named graph is a reservedGraphId; the execution primitive is a scan of a graph's flakes — clone-as-retraction for CLEAR/DROP, clone-with-rewritten-gfor COPY/MOVE/ADD (datatype/lang/list-index ride along verbatim). CLEAR/DROP ALL|NAMED resolve their target set from the registry; COPY/MOVE compose over CLEAR with a set-difference so overlapping triples are never simultaneously asserted and retracted. The dispatch is a singleif txn.graph_mgmt.is_some()check at the top ofstage(), before any hot-path setup, so ordinary insert/upsert/update staging is byte-identical.PR-U6 — USING + explicit-GRAPH dataset scoping (class F, #1441). SPARQL 1.1 §3.1.3: when
USING/USING NAMEDis present it defines the WHERE dataset exactly — the WHERE-visible named graphs are precisely theUSING NAMEDset (empty under a plainUSING <g>). The bug treated an emptyUSING NAMEDset as "no restriction" and registered every named graph, soDELETE { … } USING <g3> WHERE { GRAPH <g2> { … } }matchedg2and over-deleted. The fix restricts the runtime dataset's named graphs to exactly theUSING NAMEDset whenever anyUSING/USING NAMEDclause is present; the no-USINGpath (plainDELETE WHERE { GRAPH <g> … }) stays byte-identical. Prepare-time only; SELECT queries never reach it.PR-X2 — equality / EBV / numeric-promotion lattice (D-12). DELIBERATE SEMANTICS CHANGE — 2-reviewer sign-off requested. Five IR/engine value-semantics fixes on the shared
fluree-db-queryevaluator: D4 numeric promotion (ComparableValuegains first-classFloat(f32)sointeger < decimal < float < doubleholds andxsd:floatno longer widens toxsd:double); D-EBV (a fallible, datatype-aware bare-variable effective-boolean-value — numeric-zero/empty-string falsy; lang-tagged/foreign-datatype/IRI/ill-typed/unbound → a type error excluding the row); D5/D7 (datatype-aware three-valuedrdf_term_equalfor=/!=, with §17.2 three-valuedAND/OR); D11 (CONCATtype-errors on a non-string argument); and aggregates (predicate-groupedCOUNT→xsd:integer;SUM/AVGpoison-to-unbound on a non-numeric member). The common int-int/iri-iri fast paths are kept first; the added per-row cost is one well-known-Sid tag-check on the xsd:double and xsd:string literal paths plus theEqOutcomewrapping on=/!=, measured neutral (query_hot_bsbm_bi/f2−0.51% on X2's own standalone branch-vs-wave-2 n=100 A/B; the whole-wave integrated f2 is +1.80% in the perf table below — both well inside the 5% budget, and the difference between the two is measurement, not an X2 cost, since no other wave-3 PR touches the FILTER/eval hot path f2 exercises).Decisions actioned in this wave
Per the standing transparency rule (ROADMAP §4), each decision point, the options weighed, and why the adopted option was chosen.
D-1 — SPARQL 1.2 triple-term syntax: accept-then-defer (PR-W2BC). Options: (4) accept + arity-validate the syntax, lower to
not_implemented[adopted]; (3) reject-on-principle as a documented divergence; (2) desugar<<( )>>to the reifier model (dominated — a triple term is a value, a reifier is a resource, soisTRIPLE/SUBJECT/term-equality would silently diverge). Chosen Option 4: it makes Fluree parse standard SPARQL 1.2 today at zero engine/perf risk — exactly what a syntax-conformance suite rewards — while honestly deferring evaluation to the Option-1 first-class-value epic. The trade-off (a runtimenot_implementedwhere there used to be a parse error) is named explicitly; failing fast at parse time is Option 3, a genuine either/or the team owns.D-3 — within-ledger
FROM/FROM NAMEDdatasets (PR-G2). Options: (A) engine within-ledgerDataSetreusing the runtime dataset path [adopted]; (B) harness ledger-per-graph withquery_connection_sparql(zero engine change). Both green the same 13 tests, so the tie-breakers are product value, reuse, and perf — all favour A. A fixes the actual user-facing gap (a real user can query one ledger's named graphs), extends #1279's within-ledger registry the natural way (a named graph is a g_id), and keeps a single snapshot (no cross-ledger provenance stamping, binary store stays enabled); B only satisfies the harness and spins up N ledgers per query.D-5 — remote (non-SILENT) LOAD: documented divergence (PR-U3). Options: (a) parse LOAD, execute
SILENTas a swallowed no-op, error on non-SILENT [adopted]; (b) add an opt-in HTTP fetch hook into the transact path. Chosen (a): it costs zero W3C coverage (the two eval tests LOAD a non-resolvable source under SILENT and expect the store unchanged; the syntax tests are parse-only), whereas (b) would drag an HTTP client and its failure/security surface into the embedded write path for no test benefit (ingesting a known document already has a first-class path — the insert API).D-6 — DROP ≡ CLEAR + the empty-named-graph model (PR-U3). First half — DROP is implemented as CLEAR semantics now (the
GraphRegistryis additive-only, and the two are indistinguishable to the harness, which compares/lists only non-empty graphs). Second half (a spotlighted product call) — whether a CLEAR'd/CREATE'd zero-triple named graph is enumerable byGRAPH ?g/FROM NAMED: Option A (persist a graph-existence record decoupled from flakes across storage/commit/query, greensbindings#graph+agg-empty-group-count-graph, −2) vs Option B (documented permanent divergence — a graph exists iff a flake carries its g_id) [adopted]. Chosen B: the capability is a query/dataset-model feature orthogonal to the UPDATE verbs, it is the same model fact that makes DROP≡CLEAR correct, and Option A's blast radius (three subsystems, a durable "empty graph" concept Fluree deliberately lacks) is disproportionate to −2 tests. Both tests stay registered with the full rationale.D-12 — SPARQL strict-type-error mode vs unified strictness (PR-X2). Options: (a) a per-surface strictness flag threaded through evaluation; (b) one unified strict value-semantics path shared by all surfaces [adopted for equality/EBV/promotion]; (c) leave lenient. Chosen (b): equality/EBV/promotion are value semantics, correct on both the SPARQL and JSON-LD surfaces, and the parity tests assert the strict behaviour on both. Deliberate JSON-LD extensions are preserved by reading the binding directly rather than the surface (the lenient
From<&Binding>EBVs stay for Cypher structural truthiness; PR-X1 already actioned the D2bDATATYPE/LANG@idcarve-out).D-11 (lexical-form preservation) is NOT actioned this wave — its tests (
group01, thesameTerm/distinct/dawg-strlexical set) remain registered and are owned by the future PR-X3.Second-round review fixes (after the first-round response commits)
A second adversarial pre-review pass over the whole PR produced eight more commits, all included here:
fix(sparql)— triple-term subject + annotation tail no longer panics. Parse-accepted input likeINSERT DATA { <<( :s :p :o )>> :q :o2 {| :a :b |} }reached anunreachable!in the annotation-expansion pre-pass (it guarded QuotedTriple subjects but not TripleTerm); it now defers with the same cleanUnsupportedFeatureas every other deferred triple-term shape, with negative tests for the INSERT DATA and Modify-template paths.fix(query)— NaN is never RDFterm-equal."NaN"^^xsd:double = "NaN"^^xsd:doublewas true vianumeric_cmp's bit-level total order (kept for ORDER BY);=now special-cases NaN per XPath op:numeric-equal (false;!=true), scoped to numeric operands, ORDER BY unchanged. Pre-existing bug surfaced by the X2 equality review.fix(sparql)—USING NAMEDwithoutUSINGgives the WHERE an empty default graph (§13.2.1 via §3.1.3). Previously the WHERE default fell through to the ledger's real default graph, soDELETE { ?s ?p ?o } USING NAMED <h> WHERE { ?s ?p ?o }deleted the whole default graph — the UPDATE USING combined with explicit GRAPH over-deletes from the default graph #1441 over-reach class one clause over. The no-USING and plain-USING paths are byte-identical; test proves fail-without-fix.fix(query)— three evaluator edges: unary minus now coerces cast results (-xsd:float("1.5")was unbound); CONCAT keeps a language tag carried on the VALUE (constants/STRLANG results —CONCAT("a"@en, "b"@en)was plain "ab") with case-insensitive tag comparison; ROUND/CEIL/FLOOR passxsd:integerbeyond i64 through instead of unbinding.feat(transact)— buildermove_graph/add_graphcomplete the transfer-verb parity, and the reserved-graph guard now also fires on same-graph transfers (COPY <…#config> TO <…#config>is refused, not a blessed no-op).chore(query)— blank-free CONSTRUCT templates skip the per-row bnode probes;triple_term_fn_arity's catch-all is now adebug_assert.test— coverage adds: three-valued AND dominance (both surfaces), SUM-poison pair, an indexed (reindexed,range_provider-asserted) SUM/AVG-poison twin, same-graph no-op transfers for all three verbs on both surfaces, multi-op requests mixing data + graph-management ops, CLEAR of an annotation-bearing graph, two-level nested sub-SELECT correlation + GROUP BY-keyed correlation, and per-row (not per-distinct-binding) CONSTRUCT blank minting.docs— recorded the CONSTRUCT blank-node-template parity exemption insparql-compliance.md(the FQL construct template is a JSON-LD node map with no per-row fresh-blank syntax), plus stage-side notes: SILENT-with-missing-source still clears the destination, and O3 source-existence deliberately uses registry semantics vs the query surface's flake-carried D-6 model.Third-round: bplatz review response + the origin/main merge
All eleven review comments are resolved on the branch (per-comment replies inline). Three were already fixed by the second-round commits that landed just before the review posted (the triple-term panic guard, the
USING NAMEDempty-default fix, NaN equality). The six new commits (8d8870ba1..2205252c5, after the merge):fix(sparql)— FROM/FROM NAMED reject the reserved system graphs. The registry legs of within-ledger FROM resolution resolved#config/#txn-meta(the registry seeds them), exposing the governance graph where theGRAPH <iri>path blocks it; both legs now apply the same>= FIRST_USER_GRAPH_IDfilter, with a 4-case rejection test.fix(query)— strict typed-literal EBV on both eval paths; STRDT keeps Sid datatype args. Cast/computed numerics (xsd:float("1.5")is a String tagged xsd:float) now have their §17.2.2 EBV on the BIND'd-binding path AND the function-call path (whose lenient conversion read every typed literal as truthy); ill-formed numeric/boolean lexicals are EBV false per rule 1. Fixing the tests exposed that STRDT's datatype argument (a Sid — constant IRIs lower through the encoder) was silently dropped, producing plain literals; Sid args are now decoded.fix(cypher)— pin CALL imports as lateral seeds. Merge fallout: main's newly-wired Cypher suite caught W1's Family-B rule breakingCALL (p) { OPTIONAL MATCH … RETURN count(…) }zero-match retention (openCypher imports are per-row bindings, not inferred correlations).SubqueryPattern.pinned_varscarries the explicit imports — set only by Cypher CALL lowering, so SPARQL/JSON-LD Family-B semantics (join-scope-1) are untouched.feat(sparql)— CREATE GRAPH registers the graph. CREATE recorded nothing, soCREATE GRAPH <g>then non-SILENTCOPY <g> TO <h>hit O3's "source does not exist" error; CREATE now registers the IRI (empty, non-enumerable — D-6), with scoped exceptions so a registration-only zero-flake commit persists while ordinary no-op updates still elide. CREATE-of-existing stays a no-op (documented D-6 divergence), and the CLEAR/DROPsilentflag carries a doc note on why it is never consulted.fix(sparql)— unforgeable INSERT-template anon labels; FROM build-time set-dedup; typo-aware dataset rejection. The transact-side counter minted template[]blanks as_:b{n}(a user's_:b0fused with them); now_:[]{n}per main's scheme. RepeatedFROM <g>contributes one member (§13.2 set). The unresolved-IRI message now points at typos before the cross-ledger API.test(transact)— pin the schema-flake CLEAR policy bypass; document the whole-graph novelty cliff. A view-only default-deny identity CAN clear a schema-only graph (theis_schema_flakeexemption) — pinned beside its rejected non-schema companion;scan_graph_flakesdocuments the one-commit novelty-doubling cliff for whole-graph ops. (Also verified, no change:ReservedGraphTarget/SourceGraphNotFoundmap to 400, not 500.)The merge itself (
d072a95d6) reconciled two same-problem-solved-twice collisions with wave-2's post-branch review commits now on main: the anonymous-blank hardening (main's_:[]{N}scheme adopted branch-wide; the wave-3#anoncounter dropped) and the eval-triple-terms register dedup (each side had removed the other's kept copy of six double-registered entries — restored once each; the suite's both-direction enforcement caught it). Register content is unchanged by the merge: same unique set, 0-failed / 0-stale.Register entries removed (146 unique / 179 entry lines; suite-driven, both directions)
SPARQL10_QUERY_EVALdawg-dataset-01..12b(12) +SPARQL11_CONSTRUCTconstructwhere04.SPARQL10_QUERY_EVALfilter-nested-2,dawg-optional-filter-005-not-simplified,join-scope-1.SPARQL12_SYNTAX_TRIPLE_TERMS_POSITIVE→ empty (basic/bnode/compound/expr/inside/nested/subject/update-tripleterm +compound-all).SPARQL10_QUERY_EVALconstruct-3,construct-4+SPARQL11_CONSTRUCTconstructlist.SPARQL11_UPDATEgraph-management eval tests (add/clear/copy/create/drop/load/move+-silentvariants, the 4 same-bnode joint tests) and the 23 double-registeredSPARQL11_SYNTAX_UPDATE_1test_*(both copies deleted per the standing rule, so 23 tests remove 46 lines; that register is now empty). Per-PR unique counts sum to 145 (13 + 3 + 27 + 3 + 68 + 2 + 29); with theSPARQL12_EVAL_TRIPLE_TERMSintra-const dedup (c91f5556c) the wave removes 146 unique tests / 179 raw register lines in total (independently recomputed from the git diff at tip; zero entries added).SPARQL11_UPDATEdawg-delete-using-02a,dawg-delete-using-06a(empties the last class-F cluster).agg02,agg-err-01,concat02,strlang03-rdf11,dawg-langMatches-1/2/3/basic,type-promotion-03/04/05/21/29/30,{add,subtract,multiply,divide}-numbers-cast,unplus-2,unminus-2,dawg-bev-1..6,eq-2-1,eq-2-2,open-eq-04.Query-surface parity
Per
docs/contributing/sparql-compliance.md§"Query Surface Parity" (JSON-LD only; Cypher excluded — we do not own the openCypher grammar): every IR/engine-level fix carries a JSON-LD regression test alongside the SPARQL one, since the W3C submodule guards only the SPARQL surface. PR-X2 (value semantics), PR-W1 (scope/correlation — incl. the JSON-LD-only correlated-subselect bug fix), and PR-G2 (within-ledger dataset, a single construction over both surfaces) are IR/engine (case a) with JSON-LD tests init_query_expression_semantics.rs,it_query_filter_scope.rs, andit_query_dataset.rs. PR-U3 exposes the new graph-management capability on the transact-builder surface in the same PR (it_named_graphs.rs::test_graph_mgmt_transact_builder_parity); a JSON-LD document key syntax is a deliberately-deferred surface-design decision. PR-U6 is a SPARQLUSINGsurface fix with a JSON-LDfromNamedguard test on the shared machinery (the symmetric JSON-LDfrom-without-fromNamedrestriction is a scoped follow-up). PR-W2 (CONSTRUCT bnode) and PR-W2BC (parse-only) are SPARQL-only surface (case c) — the FQL construct form has no blank-node template syntax, and accept-then-defer introduces nothing newly executable.Performance (integrated A/B vs
burndown/wave-2@ 3040d8e)FLUREE_BENCH_PROFILE=full FLUREE_BENCH_SCALE=small(real n=100;query_hot_bsbm.rs:166setssample_sizefrom the profile, so the env var is the real knob). Query benches whole-bench interleaved; insert/import benches per-scenario fine-interleaved (wave-2↔wave-3 back-to-back) to kill cross-run drift; self-gated on rustc=0; contended runs discarded. Δ% = (wave-3 − wave-2)/wave-2 (positive = wave-3 slower); budget 5% atsmall.All within budget. An initial coarse-interleaved pass showed two apparent breaches (jsonld/100nodes +7.33%, import single_threaded +12.31%) that were foreign-build contention landing in the longer wave-3 windows, not a code regression: no wave-3 change touches the data-insert/bulk-import hot path (fluree-db-core + fluree-graph-turtle untouched; u3's shared-
stage()change is a single per-transaction guard, byte-identical for inserts; u6's is behindsparql_where), and the clean fine-interleaved re-run collapsed both to −1.28% / −0.72% with tight CIs.Deferrals (staying registered, with per-entry pointer comments)
The equality cluster's remaining tests are blocked outside the equality/EBV/promotion lattice and stay registered with X2-owned notes: the D5b scan-path
EncodedLitdatatype-carry family (eq-4,open-eq-02/05/06,dawg-lang-3,quotes-3/4,eq-graph-1/2/4— bare-BGP object term-match, deliberately not risking the binary-index hot path), blank-node OUTPUT-identity isomorphism (open-eq-07/08/10/11/12), temporal timezone semantics (eq-dateTime,date-1),dawg-langMatches-4(LANG-of-IRI error propagation),not-not(ill-typed literal preservation, → PR-X3), andagg-count-rows-distinct(COUNT(DISTINCT *)needs a newCountDistinctAllIR aggregate + whole-row group plumbing — a perf-neutral post-wave-3 follow-up). The D-6 empty-named-graph divergence keepsbindings#graph+agg-empty-group-count-graphregistered (PR-U3). D-11 lexical-form tests (group01,sameTerm/distinct/dawg-str) are PR-X3's.nested-opt-1/2(correlated-OPTIONAL independence) is PR-W1-OPT;join-combo-2+optional-complex-2/3/4are PR-G1 (GRAPH-variable) territory.Post-review scope note (
xsd:floattype-preservation): first-classxsd:floattype-preservation holds on theBinding::Lit/query path but not the indexed/Binding::EncodedLit(binary-store) path — a storedxsd:floatthat materializes via late binary-index decode is currently recovered asxsd:double, so arithmetic on such a binding yieldsxsd:double. This is the same perf-sensitive encoded-path class as the deferred D5b datatype-carry family (and also covers the encoded-path lang-EBV corner), tracked as a separate follow-up.Verification & harness/CI notes
Verified (including the first-round review fixes now merged): full W3C suite
36 passed; 0 failed; 0 ignored(0 stale, both directions);-p fluree-db-query -p fluree-db-api -p fluree-db-transact -p fluree-db-sparql0-failed (fluree-db-query 1229, fluree-db-transact 332, fluree-db-sparql 617, grp_query 360 / grp_query_sparql 287 / grp_misc 244 / grp_graphsource 113);cargo fmt --all --checkclean. Clippy: this PR's diff is clean (--no-depsover the touched crates), and both CI clippy jobs are green (the main job runscargo clippy --all --all-features --all-targetswith no-D warnings; the-D warningsjob is scoped to thetestsuite-sparqlworkspace). A newer un-pinned clippy (1.97.0) surfaces a handful of pre-existing pedantic lints in untouched infra crates (bench-support / r2rml / planner / …) under a whole-workspace-D warningsrun — pre-existing baseline lint-debt, not introduced by this wave, tracked separately.Harness notes for reviewers:
testsuite-sparqlis an EXCLUDED separate cargo workspace — run the W3C suite withcd testsuite-sparql && cargo test, not top-levelcargo test. A freshgit worktree adddoes NOT populate thetestsuite-sparql/rdf-testssubmodule — rungit submodule update --initbefore the suite or every manifest spuriously fails.fluree-db-apiuses grouped test bins (grp_query,grp_query_sparql,grp_misc, …), so scope with the full-p fluree-db-api; a per-file--test <name>matches nothing. The pre-existingresolve_shapes_source_g_idsdead-code warning influree-db-api/src/tx.rs:506is base noise (untouched by this wave; does not fire under clippy--all-targets).Migration / changelog
AND/ORapply three-valued dominance;CONCATandSUM/AVGraise type errors rather than coercing; numeric promotion preservesxsd:float(soxsd:float(10)/4serializes as a typedxsd:floatliteral, not a bare number). Queries relying on the old permissive behaviour may return fewer rows.ADD/COPY/MOVEerror when the source graph does not exist (§3.2) rather than treating it as empty — which, for COPY/MOVE, had silently emptied the destination on a typo'd source;SILENTopts into proceeding with the missing source treated as empty — which, per the spec's own shortcut equivalence (DROP SILENT dest; INSERT … WHERE source), still clears the destination; a SILENT transfer from a missing source is NOT a no-op. Graph-management verbs also reject the reserved#config/#txn-metasystem graphs as target/source. New builder API:GraphTransactBuilder/Txnclear_graph/drop_graph/copy_graph/move_graph/add_graph(full parity with the SPARQL transfer verbs).DELETE … USING <g> WHERE { GRAPH <h> … }now follows §3.1.3 — theGRAPH <h>block matches only when<h>is inUSING NAMED; updates relying on the old over-reach should addUSING NAMED <h>.CONSTRUCTtemplates with blank nodes now emit triples (a bug fix aligning with §16.2; no migration).<<( )>>values now parse/validate and produce a lower-timenot_implementedwhere they previously produced a parse error.["query"]sub-SELECT no longer pins a correlation variable that is bound only via an innerOPTIONAL/UNION— it is produced independently inside the subquery and reconciled against the parent at join time (SPARQL §18.4 compatible-mappings; fixes W3Cjoin-scope-1). Self-produced join-key correlations are unchanged. Existing JSON-LD queries of that exact shape may return different rows (standard natural-join semantics rather than forced equality); this is a correctness fix and no query rewrite is required.Issues closed on merge
Closes #1441 (PR-U6 — the USING + explicit-GRAPH delete over-delete data-loss bug; PR-U6 greens
dawg-delete-using-02a/06a, fully resolving it — the issue has no JSON-LD component). Closes #1447 (PR-X2 — aggregate / equality / EBV conformance). The symmetric JSON-LDfrom-without-fromNamedrestriction PR-U6 identified is a separate scoped follow-up (file a new issue if it needs tracking), not part of #1441.Mechanics: the
Closeskeywords fire only when this PR merges into the repository's DEFAULT branch. In the stacked burn-down flow (Wave 3 →burndown/wave-2→burndown/wave-1→main), merging this PR intoburndown/wave-2does NOT close #1441 or #1447 — GitHub auto-closes them only once the stack collapses tomainand this PR is retargeted onto it. Until then the issue references stay linked but open.