Skip to content

feat(server): accept a time pin in the /query ledger path - #1970

Merged
bplatz merged 3 commits into
mainfrom
feat/query-path-time-pin
Sep 29, 2026
Merged

bplatz merged 3 commits into
mainfrom
feat/query-path-time-pin

Conversation

@bplatz

@bplatz bplatz commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Problem

/v1/fluree/query/{ledger}@t:N returned 500. The same happened with @time:, @commit: and the other time pins.

query_ledger never parsed the path segment. It passed the raw "mydb@t:5" to ledger_cached. There LedgerId::parse rejected the @ with InvalidLedgerId, which had no arm in the server's status mapping and so fell through to 500. (#1958 has since mapped InvalidLedgerId to 400, so main now answers "Invalid ledger id", but it still can't read at a pin.)

A client that pins a read to a point in time had to create and drop a throwaway branch for each pinned read. With the pin in the path, it can read the whole ledger as it was, named graphs included, in one request.

What this does

The ledger path takes a time pin on the query, explain and Graph Store read routes, for JSON-LD, SPARQL and Cypher. The pin reads the whole ledger as of that point, including its named graphs. Accepted pins:

  • @t:N and @t:latest
  • @time:<ISO> and its alias @iso:
  • @recorded:<ISO>
  • @commit:<prefix>
  • @snapshot:

Parsing, once at the edge. PathLedger::parse runs LedgerRef::parse, then reads the pin with TimeSpec::parse_address_suffix. That is the grammar a body from: "ledger@…" already uses. Several things key on the base ledger and never on the pinned string:

  • the bearer scope check;
  • the Fluree-Min-T wait;
  • refresh;
  • the ledger cache.

Every surface reuses a load path that already handles time:

  • SPARQL: a pinned request takes the same dataset path as FROM <ledger@t:N>.
    • With no FROM, it builds a one-graph dataset for the ledger at the pin. That runs like a plain ledger query, so GRAPH ?g still lists the ledger's named graphs.
    • With FROM/FROM NAMED, ledger_scoped_sparql_dataset_spec applies the pin to every source that names no time of its own.
    • The five copies of "build the dataset view, with policy or without" are now one helper, ledger_dataset_view. That is where ?default-context=true is honoured on a pinned query.
  • JSON-LD: pin_jsonld_dataset runs after normalize_ledger_scoped_from.
    • It writes the pin onto every from/ledger/fromNamed/from-named source that names this ledger, at top level or in opts.
    • A body that names no dataset gets a pinned from.
    • The request then runs exactly as a hand-pinned body would.
  • Cypher: db_at_with_default_context.
  • Explain:
    • SPARQL without FROM plans against db_at.
    • JSON-LD uses the same rewrite as queries.
    • Cypher uses db_at_with_default_context.
  • Graph Store GET/HEAD: these route through /query, so they honour the pin with no extra code.
  • Read-after-write: a path @t:N waits for the ledger to reach t=N, the same as a body @t:N.

Path pin vs. body pin

This follows the existing "Ledger mismatch" rule, where a body naming a different ledger is a 400. A body that names a different time for the same ledger is now a 400 "Time pin conflict".

Pins agree when they are equal as parsed values:

  • @t:1 and {"t": 1} agree.
  • @time:X and @iso:X agree.
  • @t:1 and a @time: that resolves to t1 do not agree, since only the parsed values are compared.

Other requests that now return 400 (or 406)

  • A malformed pin: @t:abc, @bogus:1, a bare @, @commit:abc.
  • A pin combined with a #fragment in the path, in either order. LedgerRef splits at # first, so <ledger>#g@t:1 arrives as a fragment carrying the pin; the path parser refuses it as it refuses <ledger>@t:1#g.
  • A history query (to) on a pinned path.
  • A body whose dataset never reads the pinned ledger. Otherwise the pin would be silently dropped.
  • Explain of SPARQL with a FROM on a pinned path, unless the FROM carries the same pin. Explain plans the snapshot the FROM names, so an unpinned FROM would silently plan head.
  • /stream/query with a pin. On the streaming path, a body-pinned from already returns no rows for GRAPH ?g, where /query lists the named graphs. So the stream refuses the pin rather than silently dropping named graphs.
  • CSV/TSV at a pin is a 406. The ledger endpoint's dataset path has no delimited output, the same as for FROM today.

Docs

  • docs/api/endpoints.md: the /query/{ledger} and /explain/{ledger} sections document path pins. The explain section was also out of date; it said every FROM is rejected.
  • docs/api/graph-store.md, docs/concepts/time-travel.md and docs/guides/cookbook-time-travel.md: list the path as a place pins are accepted.
  • docs/concepts/time-travel.md: a read at a past point is governed by the policy as it stood at that point, however the time is given, so tightening policy later does not protect earlier states. This matches the time-travel section of the policy docs; a path pin makes such reads easier to reach, so the operator-facing warning now sits with the pins.

Tests

The new query_path_time_pin.rs is in grp_query. Its fixture makes two commits. The second:

  • changes a default-graph value;
  • adds a subject in a namespace first seen at t2;
  • changes a value in named graph g1;
  • adds a new named graph g2.

Each test also reads head first, so the contrast between the two states is visible.

  • sparql_path_pin_reads_default_and_named_graphs_as_of_the_pin: at @t:1, GRAPH ?g sees g1's t1 value and not g2. GRAPH <g1> reads the old value, and GRAPH <g2> is empty. The SPARQL Protocol GET form behaves the same.
  • jsonld_path_pin_reads_default_and_named_graphs_as_of_the_pin: the JSON-LD twin.
  • time_and_commit_pins_select_the_same_state_as_t
  • malformed_path_pin_is_a_400
  • a_body_pin_must_agree_with_the_path_pin
  • the_path_pin_applies_to_graphs_the_query_selects
  • sparql_output_formats_read_at_the_pin
  • pinned_sparql_honours_the_default_context
  • a_path_pin_on_t_waits_for_the_ledger_to_reach_it
  • explain_plans_against_the_pinned_snapshot: at t1, the physical plan leaves <http://later.example/bob> unencoded because its namespace doesn't exist yet. At head the plan shows it encoded.
  • cypher_reads_at_the_path_pin: includes Cypher explain. @commit:ffffff must fail with "No commit found", which only happens if the pin reaches the loader.
  • graph_store_get_reads_at_the_path_pin
  • streaming_refuses_a_path_pin
  • Unit tests in path_pin_tests:
    • the parser keeps the path's spelling, including a urn: prefix;
    • a pinned opts.from also forces the dataset path;
    • in fromNamed array and map form, only this ledger's entries take the pin;
    • an unreadable t/at is refused.

With the whole fix reverted, all 13 integration tests fail. The table shows what failed when each piece was reverted on its own (then restored):

Reverted Failed
SPARQL ignores the pin sparql_path_pin_…, time_and_commit_pins_…, sparql_output_formats_…, pinned_sparql_honours_…, graph_store_get_…
JSON-LD rewrite is a no-op jsonld_path_pin_…, a_body_pin_must_agree_…, the_path_pin_applies_…, explain_plans_…, a_path_pin_on_t_waits_…, time_and_commit_pins_…
FROM sources don't take the pin the_path_pin_applies_…
Conflict check always passes a_body_pin_must_agree_…, explain_plans_…
Explain view ignores the pin explain_plans_…
Explain FROM check is a no-op explain_plans_…
Cypher query / Cypher explain ignores the pin cypher_reads_at_the_path_pin (each on its own)
Default context not attached pinned_sparql_honours_the_default_context
@t:N wait skipped a_path_pin_on_t_waits_…
Streaming 400 removed streaming_refuses_a_path_pin
History (to) check / "dataset never reads the ledger" check removed a_body_pin_must_agree_… (each on its own)
Fragment check removed malformed_path_pin_is_a_400
Transposed #g@t:1 check removed malformed_path_pin_is_a_400 (it asserts the pin-and-fragment message, since a later parse also refuses the id)

Each of the five unit-test mutations also turns at least one path_pin_tests case red.

Not verified

Found along the way, not changed

Follow-up: #1982 (the route passes the path's raw spelling downstream and compares it as a raw string; that issue covers both this and the "Ledger mismatch" above).

@bplatz bplatz added enhancement New feature or request area:server HTTP surface, routes, error mapping, swagger, timeouts/admission, config graph area:query Query execution, planning, fast paths, overlay, result formatting labels Sep 26, 2026
@bplatz
bplatz requested review from aaj3f and zonotope September 26, 2026 22:01
@bplatz
bplatz added this pull request to stack #1964 September 26, 2026 22:01

@aaj3f aaj3f left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving, @bplatz. The central design call here is the one I'd have most wanted and it's the one you made: the pin reuses the load path a body pin already takes rather than growing a parallel one, so it inherits the existing semantics instead of accumulating its own. ledger_dataset_view folding the five copies of "build the dataset view, with policy or without" is the right cleanup to do while you're in there — the fifth copy is exactly where ?default-context=true would have been quietly dropped on a pinned query. And the mutation table in the description is doing real work; I spot-checked the "conflict check always passes" row and it turns precisely the two tests red that you say it does, with the other eleven green.

I also want to record two things I went looking for and didn't find, because they're the failure modes a pin feature usually ships with. No crafted path authorized ledger A and read ledger B — the scope check and the loader derive from the same string through the same parser, scope_id was already pin-tolerant pre-PR, and the injected pinned from is written before enforce_bearer_dataset_scope runs, so the scope check sees it. And a pinned read cannot poison the HEAD cache entry — load_graph_db_historical takes no ledger-cache lock at all, and load_graph_db_at_t touches the cache only to short-circuit target_t == snap.t. Percent-encoding is clean too: axum decodes before extraction so validation sees the decoded string, and %2F becomes a legal ledger-name separator rather than a path component.

The one thing I'd want folded in is the transposed spelling. LedgerRef::parse splits # before @, so gov:main#g@t:1 parses as fragment g@t:1 with no pin, and PathLedger::parse takes its pin: None early return at query.rs:1899 before the fragment guard at :1906. That request returns 200 OK reading HEAD, while the mirror gov:main@t:1#g correctly 400s. Silently serving HEAD to a client that asked for a snapshot is the exact thing the rest of this PR 400s to prevent, and it looks like one condition in the else arm.

Two smaller ones. PathLedger computes a canonical LedgerId and then hands the raw spelling to every loader and scope check, which is why urn:fluree:gov:main@t:1 authorizes fine and then 500s in the loader (query.rs:1901) — it fails closed, but it's the one seam here where the authorized id and the read id come from different parsers, and ledger: path.id.as_str().to_string() would close it and retire a disclosed 500 at the same time. And on policy under a pin: resolve_and_attach_config resolves the config from the pinned view, so a ledger that was open at t1 and locked at t2 answers @t:1 with t1's permissive policy — I reproduced it, and I also confirmed the same rows come back today through a body {"from":{"t":1}} and through FROM <gov:main@t:1>, so this PR widens the reach rather than introducing the class. I don't think the pin-consistent reading is wrong; I think the new time-travel docs shouldn't be silent about it, because "we locked the ledger down" is what an operator will believe.

Nothing else rises above a nit. Performance verdict: no degradation risk. pin_jsonld_dataset early-returns when unpinned, so the JSON-LD body is never cloned or re-serialised on an ordinary request; the unpinned path gains one LedgerRef::parse and two String allocations against a request that already re-parses that string and then goes to storage. Only pinned requests change lanes, which is the cost a body pin already paid.

Adherence to repo commitments

  • Patterns / abstractions — ✔ Reuses the existing dataset/TimeSpec machinery rather than adding a pin-specific path, and collapses five duplicated blocks into one helper. The PathLedger raw-vs-typed seam (MEDIUM-3) is the one place it's carrying a second parser where one would do.
  • Performance (speed first, memory second) — ✔ No performance-degradation risk. Unpinned requests are untouched apart from one parse and two small allocations on a path that already does both; the added attach_default_context_to_graph is gated to pin-entered lanes; GraphDb clone is Arc-only; no new cache lock, and a pinned read can't evict or poison the HEAD entry.
  • Testing — ✔ query_path_time_pin.rs is genuinely wired (autotests = false, [[test]] grp_query, tests/grp_query.rs:16-17 declares the module) and all 13 integration tests run and pass. The per-piece mutation table in the description reproduces. What's untested is the HIGH-1 spelling and the policy-at-the-pin behaviour, neither of which has a case.
  • Conventions — ✔ Self-describing subject; the description is unusually good — the mutation table, the explicit "Not verified" section, and the "Found along the way, not changed" list are all things that make a reviewer faster rather than slower, and disclosing the pinned-write 500s rather than quietly leaving them is the right call. (I checked the load-bearing claim in that list — "Nothing is written" — across all seven pinned write shapes, and it holds: each makes ledger_cached its first IO, which parses and rejects before a TransactionRequest exists.)

impl PathLedger {
pub(crate) fn parse(raw: &str) -> Result<Self> {
let parsed = fluree_db_api::LedgerRef::parse(raw)?;
let Some(at) = parsed.at else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 HIGH-1 — #fragment@pin slips past the guard added for exactly this ambiguity and silently reads HEAD

(in-diff range 1873-2125).

LedgerRef::parse splits on # before @, so gov:main#g@t:1 comes back as at: None, fragment: Some("g@t:1"). PathLedger::parse then takes the let Some(at) = parsed.at else { … pin: None } early return at :1899 and never reaches the fragment guard at :1906.

POST /v1/fluree/query/gov:main#g@t:1   -> 200 OK, 0 bindings   (HEAD, unknown named graph)
POST /v1/fluree/query/gov:main@t:1#g   -> 400 "combines a time pin with a graph fragment;
                                              select the graph in the query instead"

So one ordering gets the deliberate 400 and the transposed one gets the silent drop — which is the behaviour this PR argues throughout is worth a 400 for ("Otherwise the pin would be silently dropped"). A client that gets 200 OK and zero rows has no way to tell it was served HEAD rather than its snapshot.

It looks like a line in the else arm:

let Some(at) = parsed.at else {
  if parsed.fragment.as_deref().is_some_and(|f| f.contains('@')) {
      return Err(ServerError::bad_request(format!(
          "Ledger path '{raw}' combines a time pin with a graph fragment; \
           select the graph in the query instead"
      )));
  }
  return Ok(Self { ledger: raw.to_string(), id: parsed.id, pin: None });
};

One knock-on for the PR body: "A #fragment in an unpinned path … [is a] 500" isn't accurate for this shape — it's a 200. (I didn't separately test a bare mydb#g, so I can only say the claim doesn't hold for #g@t:1, not that it's wrong in general.)


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.

Fixed in fb9ca13, as you suggested: PathLedger::parse now refuses a fragment containing @ with the same "combines a time pin with a graph fragment" 400 as @t:1#g. /stream/query goes through the same parser, so it gets the check too.

One thing changed underneath this after the rebase onto main: with #1958 merged, the transposed spelling already comes back as a 400 "Invalid ledger id … branch cannot contain '@'", because the loader's parse now maps to 400. So the fix now matters because it refuses the spelling at the path, with the right message, instead of relying on a later parse. Because of that, malformed_path_pin_is_a_400 checks for the pin-and-fragment message on /query and /explain, not just the status. It fails with the check reverted.

Thanks for the correction on the PR body. That line now describes what main does after the rebase.

@@ -100,6 +100,10 @@ Query at a specific commit using `@commit:` with a commit ContentId:
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MEDIUM-2 — a pinned read is governed by the policy as of the pin, and nothing says so

(in-diff range 100-109).

resolve_and_attach_config (fluree-db-api/src/view/fluree_ext.rs:136) resolves the ledger config from the pinned view's snapshot / overlay / t, so f:policyDefaults, f:defaultAllow and f:overrideControl are all read at the pin. A ledger that was open at t1 and locked down at t2 answers a @t:1 read with t1's permissive policy.

On a fixture where t1 is data and t2 adds <urn:cfg:policy> f:defaultAllow false:

request rows
POST /query/gov:main (head, anonymous) 0 — locked
POST /query/gov:main@t:1 SPARQL — new here 1
POST /query/gov:main@t:1 JSON-LD — new here 1
POST /query/gov:main body {"from":{"@id":"gov:main","t":1}} — pre-existing 1
POST /query SPARQL FROM <gov:main@t:1> — pre-existing 1

This PR does not introduce the class — the last two rows are reachable on main today and this diff can't touch them (pin_jsonld_dataset returns early when pin is None, and the connection /query handler isn't in the diff). I don't think that makes it a blocker here.

What it does change is reach. A pin in the URL path is visible to things a JSON body isn't: WAF rules, proxy allowlists, audit-log greps, and the human judgement of "is this link safe to send someone". And there's no server switch to refuse pins at all — I looked for a time_travel / allow_time key in fluree-db-server/src/config.rs and there isn't one.

I'm honestly not sure the pin-consistent reading is wrong — a snapshot read arguably should be self-consistent, and policy here is model-governs-instance data that lives in the ledger. It's the silence I'd fix. One sentence in the new time-travel section — a pinned read is governed by the policy as it stood at the pin; locking a ledger down does not retroactively protect earlier states — closes the operator-surprise gap, and it's the kind of thing that's much cheaper to write now than to discover later.


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.

Agreed the silence was the problem. docs/concepts/time-travel.md now has a short "Policy at a Past Point" section (fb9ca13). It says a read at a past point is governed by the ledger's policy defaults and rules as they stood at that point, however the time is given (a path pin, a body from, or FROM), so tightening policy later doesn't protect earlier states. It suggests limiting the reader's ledger scope instead, and links to the time-travel section of the policy docs, which already describes this behavior.

I wrote it as a description of current behavior. Whether a past read should use the policy as it stood then or as it stands now is a separate question I'd rather not settle in this PR.

let parsed = fluree_db_api::LedgerRef::parse(raw)?;
let Some(at) = parsed.at else {
return Ok(Self {
ledger: raw.to_string(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MEDIUM-3 — PathLedger parses a canonical id and then hands the raw spelling to everything

(in-diff range 1873-2125).

PathLedger.id is the canonical LedgerId, but it's used only by pin_jsonld_source's names_path_ledger. The bearer scope check (:1162, :1191, :1255, :1483, :1570) and every loader (db_at, db_at_with_default_context, load_ledger_for_query, pinned_ledger_spec, ledger_scoped_sparql_dataset_spec) take path.ledger — the raw string.

The observable consequence:

POST /v1/fluree/query/urn:fluree:gov:main@t:1
-> 500 "Invalid ledger id 'urn:fluree:gov:main': branch cannot contain ':'"

The scope check accepted it as gov:main (scope_id is LedgerRef::parse(raw)?.id, which strips the urn); the loader's LedgerId::parse rejected it. Not exploitable — it fails closed, and only because the loader is the stricter of the two. But it's the one seam in this change where the id that was authorized and the id that gets read come from different parsers, and the unit test at :2568 currently pins the unloadable spelling as correct.

ledger: path.id.as_str().to_string() would close the seam and make urn:fluree: paths work, which retires one of the disclosed 500s for free. Worth noting #1961 already tracks the db()/graph() half of this same family — this would be a third parser pair in it, so there may be an argument for doing them together rather than piecemeal.


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.

Agreed on the seam, but I'd rather do it with the #1961 family than here, because the one-liner isn't safe on its own. normalize_ledger_scoped_from and ledger_scoped_sparql_dataset_spec compare ledger strings as written (base_ledger_id strips the time and fragment but adds no default branch). If path.ledger became mydb:main, then /query/mydb with a body from: "mydb" or FROM <mydb>, which works today, would turn into a "Ledger mismatch".

The complete fix is to pass the canonical id downstream and switch those comparisons to typed ids. That also clears the pinned vs pinned:main mismatch listed in the body. It touches the whole route rather than the pin, so I'd like it in its own change.

Status since the rebase: a urn:fluree: path is now a 400 "Invalid ledger id", not a 500. It still fails closed.

@@ -2967,6 +3405,9 @@ async fn execute_sparql_ledger(
let has_dataset_clause = dataset_clause

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚪ NIT-4 — @t:latest forces the dataset lane although it reads HEAD

(in-diff range 3405-3413).

dataset_lane = has_dataset_clause || pin.is_some(), and TimeSpec::Latest is an accepted pin. So /query/mydb@t:latest — semantically identical to /query/mydb — skips the single-graph lane, the proxy-mode lane (:3428) and the connection-policy lane (:3437), and 406s on CSV/TSV. Collapsing Latest to pin = None in PathLedger::parse removes it.

This one is reasoned from the code rather than run, so treat it as a question rather than an established finding.


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.

Leaving it as is. If @t:latest became "no pin", it would also skip the path/body conflict check, so a path saying @t:latest and a body saying @t:5 would stop being a "Time pin conflict". A body from: "mydb@t:latest" takes the dataset path too, so the two spellings stay consistent. The only cost is CSV/TSV returning 406 for a spelling that means head.

));
}

const DATASET_KEYS: [&str; 4] = ["from", "ledger", "fromNamed", "from-named"];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚪ NIT-5 — a body ledger key 400s on a pinned path though the route never reads it

(in-diff range 1873-2125).

DATASET_KEYS includes "ledger", but get_ledger_id (:1281) returns the path ledger and never consults a body ledger on a ledger-scoped route. So /query/gov:main@t:1 with {"ledger":"other:main","select":…} is a 400 "the query's dataset does not read 'gov:main'", while the same body unpinned is accepted and the key ignored. Small inconsistency; dropping "ledger" from DATASET_KEYS on this route would align them.


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.

I'd keep ledger in the list, because the engine does read it on the dataset path. DatasetSpec::from_query_json takes the default graph from opts.from || opts.ledger || query.from || query.ledger. A pinned request goes down the dataset path, and the pinned from we add sits at the top level, so an opts.ledger would outrank it.

If we dropped ledger from DATASET_KEYS, then /query/gov:main@t:1 with {"opts": {"ledger": "other:main"}, ...} would read other:main instead of being refused. The 400 is the safe answer there. The unpinned route ignores the key only because it takes the single-ledger path, which never looks at ledger.


// The streaming dataset path does not enumerate a ledger's named graphs
// under `GRAPH ?g`, so a pinned read here would silently drop them.
if PathLedger::parse(&ledger)?.pin.is_some() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚪ NIT-6 — stream_query builds a whole PathLedger to read one boolean

(in-diff range 263-277).

Two allocations per streaming request, then dropped. LedgerRef::parse(&ledger)?.at.is_some() does the same with one. Genuinely trivial — mentioning it only because it's on a streaming path.


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.

I'd keep PathLedger::parse here. Going through the same parser is what gives /stream/query the same refusals as /query, including the HIGH-1 check from this round. LedgerRef::parse(..)?.at.is_some() would miss #g@t:1, because that pin sits inside the fragment. The two small allocations seem worth it for not having a second parser on this route.

Base automatically changed from fix/mcp-ledgers-auth to main September 29, 2026 01:56
`/query/<ledger>@t:5` (and `@time:`, `@iso:`, `@recorded:`, `@commit:`,
`@snapshot:`) returned 500: the route never parsed its path tail and handed
`mydb@t:5` to the ledger loader, whose id parse failed into the generic 500.

The ledger-scoped query and explain handlers now parse the path once with
`LedgerRef`, carry the base ledger and the pin separately, and key bearer
scope, min-t, refresh and the ledger cache on the base. A pinned read takes
the time-pinned load path a body pin already uses, so the whole ledger is
read as of the pin, named graphs included:

- SPARQL goes through the dataset path with the ledger's default graph at the
  pin, which keeps `GRAPH ?g` enumerating the ledger's own named graphs.
  `FROM` / `FROM NAMED` sources take the pin.
- JSON-LD has the pin written onto every `from` / `fromNamed` source (top
  level or `opts`) that names the path's ledger, or gets a pinned `from` when
  it names no dataset, and runs as a body-pinned query.
- Cypher and explain load the view with `db_at`.
- A `@t:N` pin waits for `t=N` like a body `@t:` pin does.

A time the body names for the same ledger must agree with the path pin, or
the request is a 400; so is a history (`to`) query on a pinned path, a
dataset that never reads the pinned ledger, a pin combined with a `#graph`
fragment, and a malformed pin. On explain, a SPARQL `FROM` must repeat the
path's pin because explain plans the FROM's own snapshot.

The streaming endpoint parses the path only to refuse a pin with a 400: its
dataset path does not enumerate a ledger's named graphs under `GRAPH ?g`, so
a pinned read there would silently drop them. The Graph Store `GET`/`HEAD`
reads through `/query` and so honours a pin as well.
Describe `/query/{ledger}@<pin>` and how it interacts with body pins, the
pinned forms of `/explain`, the Graph Store's pinned reads, and the streaming
endpoint's refusal. Also correct the explain section, which still said a
ledger-scoped SPARQL explain rejects every `FROM`.
`LedgerRef` splits at `#` before `@`, so `/query/<ledger>#g@t:1` reached
`PathLedger::parse` as a fragment carrying the pin, with no pin at all.
It is now refused at the path, as `<ledger>@t:1#g` is, instead of being
left to whatever parses the id next.

Also documents that a read at a past point is governed by the policy as
it stood then, so tightening policy later does not protect earlier
states.
@bplatz
bplatz force-pushed the feat/query-path-time-pin branch from e30fb7a to fb9ca13 Compare September 29, 2026 02:25
@bplatz
bplatz merged commit e2a5d6c into main Sep 29, 2026
16 checks passed
@bplatz
bplatz deleted the feat/query-path-time-pin branch September 29, 2026 02:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:query Query execution, planning, fast paths, overlay, result formatting area:server HTTP surface, routes, error mapping, swagger, timeouts/admission, config graph enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants