Skip to content

fix: accept time-travel from in explain endpoints#1236

Open
bplatz wants to merge 1 commit into
mainfrom
fix/explain-time-travel
Open

fix: accept time-travel from in explain endpoints#1236
bplatz wants to merge 1 commit into
mainfrom
fix/explain-time-travel

Conversation

@bplatz
Copy link
Copy Markdown
Contributor

@bplatz bplatz commented May 11, 2026

Two bugs in the explain handlers, plus a small consistency cleanup. The fix is about not rejecting valid requests and not getting auth wrong.

Bug 1: scoped read tokens fail on --at explain (connection-level /explain)

The bearer scope check used the raw body.from value as the ledger id. When from carried a time-travel suffix ("mydb:main@t:5"), the check effectively asked "does this token have read on mydb:main@t:5?" — which never matches a token scoped to mydb:main. Only unscoped/root tokens could explain at a historical t.

Bug 2: SPARQL FROM/FROM NAMED rejected outright on ledger-scoped /explain/{ledger}

explain_ledger returned 400 "SPARQL FROM/FROM NAMED is not supported for explain on the ledger-scoped endpoint" whenever any FROM was present. That meant SELECT … FROM <mydb:main@t:5> WHERE { … } — the natural way to ask for a time-travel explain over SPARQL — was rejected before it could be planned. The JSON-LD side already accepts same-ledger from via normalize_ledger_scoped_from; SPARQL didn't.

Cleanup: time-travel from was silently dropped

Both explain handlers loaded the path/header ledger at HEAD and ran explain there, regardless of any time-travel suffix in the body. Plans don't materially differ across t (see "What this PR is not" below), so this wasn't producing wrong-shape plans — but the server was quietly ignoring an explicit request parameter. Routing through a dataset-aware path keeps the contract honest and aligns explain's plumbing with the query path (which already takes from through query_connection).

What changed

fluree-db-api

Two new public methods on Fluree, mirroring query_connection / query_connection_sparql but emitting the plan instead of executing:

  • Fluree::explain_connection(query_json) — parses dataset spec, builds a view via prepare_single_view_for_connection, calls existing Fluree::explain against it.
  • Fluree::explain_connection_sparql(sparql) — SPARQL counterpart; requires exactly one FROM (with optional time-travel suffix).

Multi-ledger / FROM NAMED explain is rejected — Fluree::explain takes a single-ledger GraphDb, so it can't honestly explain across multiple datasets.

fluree-db-server

routes/query.rs::explain_ledger and routes/query.rs::explain:

  1. Auth uses base ledger id. When body's from or SPARQL FROM carries a @t:N / @iso: / @commit: suffix, the bearer scope check now compares against the base ledger id (sans suffix). Fixes Bug 1.

  2. SPARQL same-ledger FROM accepted on ledger-scoped explain. The blanket rejection is gone. Cross-ledger FROM returns the same 400 Ledger mismatch shape normalize_ledger_scoped_from produces for JSON-LD. Fixes Bug 2.

  3. Time-travel routing. When the body has dataset features (requires_dataset_features(query_json) or a time-suffixed SPARQL FROM), handlers route through the new explain_connection* methods so the request is processed via the same plumbing the query path uses. HEAD requests still use the simple fast path.

fluree-db-core (drive-by)

#[allow(clippy::len_without_is_empty)] on NonEmpty::len. NonEmpty can never be empty by construction, so the suggested is_empty method would always return false. This was failing cargo clippy -D warnings workspace-wide on origin/main.

Tests

Four new integration tests in fluree-db-server/tests/integration.rs:

Test Pins down
explain_ledger_scoped_jsonld_with_time_travel_returns_plan POST /explain/{ledger} with body { "from": "tt:main@t:1", ... } returns 200
explain_ledger_scoped_sparql_with_same_ledger_from_no_longer_rejected SELECT … FROM <tt:main@t:1> … against /explain/tt:main returns 200 (was 400 before — Bug 2)
explain_ledger_scoped_sparql_with_cross_ledger_from_rejected Cross-ledger FROM <otherdb:main> returns 400 "Ledger mismatch" (JSON-LD parity)
explain_connection_jsonld_with_time_travel_returns_plan POST /explain with body's from carrying @t:N returns 200

Full suite: cargo test -p fluree-db-server (204 tests) and cargo test -p fluree-db-api --lib (425 tests) both pass. cargo clippy -p fluree-db-server -p fluree-db-api --all-targets -- -D warnings is clean.

Performance

The query handler is untouched. Query latency is unchanged.

For explain itself:

  • HEAD explain (no time-travel from): unchanged hot path — same load_ledger_for_query + Fluree::explain calls as before. Zero new work.
  • --at explain: routes through Fluree::explain_connection, which builds a historical view via load_graph_db_at_t. That involves one nameservice lookup, one index-root read from CAS (cacheable via the shared leaflet_cache), and a dict-novelty overlay setup. Only fires for explicit historical requests. Possible future micro-optimization: a metadata-only loader for explain-only callers, since explain doesn't actually consume the binary store / range provider that the loader builds.

Compatibility

  • No request/response shape changes. Existing callers sending from: "ledger@t:N" to /explain will start getting 200s where they got 403/400 before.
  • One behavior change with intent: ledger-scoped /explain no longer 400s SPARQL FROM when it targets the same ledger. Cross-ledger FROM keeps returning 400.

Both `POST /v1/fluree/explain` (connection-level) and
`POST /v1/fluree/explain/{ledger}` (ledger-scoped) used to load the
ledger at HEAD and run explain there, regardless of any time-travel
`from` / SPARQL `FROM <ledger@t:N>` in the body. So `--at`-style
explains silently returned the wrong-snapshot plan — the symmetric
query path has honored time-travel for a while, the explain path
hadn't.

Two related fixes:

1. **Time-travel from for explain.** Added
   `Fluree::explain_connection` and `Fluree::explain_connection_sparql`
   to `fluree-db-api`, mirroring `query_connection`* but emitting the
   plan against a properly time-traveled `GraphDb`. Both server
   handlers now route through these when the body carries dataset
   features (time-travel `from`, FROM with `@t:N`, etc.) and keep the
   simple HEAD fast path otherwise. Auth checks compare against the
   base ledger id (sans `@t:N` suffix) so scoped read tokens authorize
   time-travel explains.

2. **SPARQL FROM/FROM NAMED on ledger-scoped explain.** The previous
   blanket rejection in `explain_ledger` is gone. SPARQL `FROM` is
   accepted when it targets the same ledger as the URL path (with
   optional time-travel suffix) and is routed through the new
   `explain_connection_sparql`. Cross-ledger FROM returns the same
   `400 Ledger mismatch` shape `normalize_ledger_scoped_from` produces
   for JSON-LD.

Tests: four new integration tests in `fluree-db-server/tests/integration.rs`
cover ledger-scoped JSON-LD explain with time-travel, ledger-scoped
SPARQL explain with same-ledger time-travel FROM (was previously 400),
ledger-scoped SPARQL explain with cross-ledger FROM (still 400 but
with the mismatch shape), and connection-level JSON-LD explain with
time-travel.

Drive-by: `#[allow(clippy::len_without_is_empty)]` on `NonEmpty::len`
to unblock `cargo clippy -D warnings` on the workspace — `NonEmpty`
can never be empty so the suggested `is_empty` method would always
return `false`.
// dataset-aware connection-explain path so snapshot selection
// honors `@t:N` / ISO / commit-prefix. Otherwise keep the
// simple HEAD-load fast path.
if ledger_id_raw != ledger_id {
Copy link
Copy Markdown
Contributor

@zonotope zonotope May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check fails when the ledger header is set to foo:main but the body includes FROM <foo:main@t:5>. ledger_id_raw == ledger_id, so HEAD will be loaded, but the user still specified a time travel suffix in the query body. is this intended?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zonotope good catch — but I went a different direction. The premise of the historical-routing arm was wrong: the planner is a current-state planner using cached/indexed stats from the live ledger view, not historical stats at @t, so the time-travel branch was building a separate code path to produce the same plan execution would have used anyway. The reworked PR removes Fluree::explain_connection* and the dataset-aware routing entirely (the case you flagged can't happen because the branch no longer exists), keeps the real fixes (base_ledger_id for auth, same-base SPARQL FROM accepted), and adds header/body ledger-mismatch validation across SPARQL FROM and JSON-LD from string/object/array forms with tests asserting @t:N returns a byte-identical plan to HEAD.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants