Repository navigation
feat(server): accept a time pin in the /query ledger path - #1970
Conversation
aaj3f
left a comment
There was a problem hiding this comment.
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/
TimeSpecmachinery rather than adding a pin-specific path, and collapses five duplicated blocks into one helper. ThePathLedgerraw-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_graphis gated to pin-entered lanes;GraphDbclone isArc-only; no new cache lock, and a pinned read can't evict or poison the HEAD entry. - Testing — ✔
query_path_time_pin.rsis genuinely wired (autotests = false,[[test]] grp_query,tests/grp_query.rs:16-17declares 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_cachedits first IO, which parses and rejects before aTransactionRequestexists.)
| impl PathLedger { | ||
| pub(crate) fn parse(raw: &str) -> Result<Self> { | ||
| let parsed = fluree_db_api::LedgerRef::parse(raw)?; | ||
| let Some(at) = parsed.at else { |
There was a problem hiding this comment.
🟠 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.)
There was a problem hiding this comment.
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: | |||
| } | |||
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
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(), |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
⚪ 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.
There was a problem hiding this comment.
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"]; |
There was a problem hiding this comment.
⚪ 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.
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
⚪ 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.
There was a problem hiding this comment.
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.
`/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.
e30fb7a to
fb9ca13
Compare
Problem
/v1/fluree/query/{ledger}@t:Nreturned 500. The same happened with@time:,@commit:and the other time pins.query_ledgernever parsed the path segment. It passed the raw"mydb@t:5"toledger_cached. ThereLedgerId::parserejected the@withInvalidLedgerId, which had no arm in the server's status mapping and so fell through to 500. (#1958 has since mappedInvalidLedgerIdto 400, somainnow 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:Nand@t:latest@time:<ISO>and its alias@iso:@recorded:<ISO>@commit:<prefix>@snapshot:Parsing, once at the edge.
PathLedger::parserunsLedgerRef::parse, then reads the pin withTimeSpec::parse_address_suffix. That is the grammar a bodyfrom: "ledger@…"already uses. Several things key on the base ledger and never on the pinned string:Fluree-Min-Twait;Every surface reuses a load path that already handles time:
FROM <ledger@t:N>.FROM, it builds a one-graph dataset for the ledger at the pin. That runs like a plain ledger query, soGRAPH ?gstill lists the ledger's named graphs.FROM/FROM NAMED,ledger_scoped_sparql_dataset_specapplies the pin to every source that names no time of its own.ledger_dataset_view. That is where?default-context=trueis honoured on a pinned query.pin_jsonld_datasetruns afternormalize_ledger_scoped_from.from/ledger/fromNamed/from-namedsource that names this ledger, at top level or inopts.from.db_at_with_default_context.FROMplans againstdb_at.db_at_with_default_context.GET/HEAD: these route through/query, so they honour the pin with no extra code.@t:Nwaits 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:1and{"t": 1}agree.@time:Xand@iso:Xagree.@t:1and a@time:that resolves to t1 do not agree, since only the parsed values are compared.Other requests that now return 400 (or 406)
@t:abc,@bogus:1, a bare@,@commit:abc.#fragmentin the path, in either order.LedgerRefsplits at#first, so<ledger>#g@t:1arrives as a fragment carrying the pin; the path parser refuses it as it refuses<ledger>@t:1#g.to) on a pinned path.FROMon a pinned path, unless theFROMcarries the same pin. Explain plans the snapshot theFROMnames, so an unpinnedFROMwould silently plan head./stream/querywith a pin. On the streaming path, a body-pinnedfromalready returns no rows forGRAPH ?g, where/querylists the named graphs. So the stream refuses the pin rather than silently dropping named graphs.FROMtoday.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 everyFROMis rejected.docs/api/graph-store.md,docs/concepts/time-travel.mdanddocs/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.rsis ingrp_query. Its fixture makes two commits. The second:g1;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 ?gseesg1's t1 value and notg2.GRAPH <g1>reads the old value, andGRAPH <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_tmalformed_path_pin_is_a_400a_body_pin_must_agree_with_the_path_pinthe_path_pin_applies_to_graphs_the_query_selectssparql_output_formats_read_at_the_pinpinned_sparql_honours_the_default_contexta_path_pin_on_t_waits_for_the_ledger_to_reach_itexplain_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:ffffffmust fail with "No commit found", which only happens if the pin reaches the loader.graph_store_get_reads_at_the_path_pinstreaming_refuses_a_path_pinpath_pin_tests:urn:prefix;opts.fromalso forces the dataset path;fromNamedarray and map form, only this ledger's entries take the pin;t/atis 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):
sparql_path_pin_…,time_and_commit_pins_…,sparql_output_formats_…,pinned_sparql_honours_…,graph_store_get_…jsonld_path_pin_…,a_body_pin_must_agree_…,the_path_pin_applies_…,explain_plans_…,a_path_pin_on_t_waits_…,time_and_commit_pins_…FROMsources don't take the pinthe_path_pin_applies_…a_body_pin_must_agree_…,explain_plans_…explain_plans_…FROMcheck is a no-opexplain_plans_…cypher_reads_at_the_path_pin(each on its own)pinned_sparql_honours_the_default_context@t:Nwait skippeda_path_pin_on_t_waits_…streaming_refuses_a_path_pinto) check / "dataset never reads the ledger" check removeda_body_pin_must_agree_…(each on its own)malformed_path_pin_is_a_400#g@t:1check removedmalformed_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_testscase red.Not verified
Tests run:
fluree-db-server, all targets (one failure, below).main(with fix: scope MCP and the ledger listing by token claims; MCP follows data auth #1963 merged):--lib,grp_query,grp_httpandgrp_policyonfluree-db-server, a workspacecargo check --all-targets, clippy onfluree-db-server, andmdbook build docs.Workspace-wide runs and
testsuite-sparqlwere left to CI.grp_http'sencryption_rotation_http::rotation_runs_and_verifies_over_httpfails some of the time when the full binary runs, on the unmodified base as well. It passes when run alone.Proxy-mode peers: no harness queries through a proxy peer. A pinned request takes the same paths a body pin already takes in proxy mode.
Found along the way, not changed
PUT/POST/DELETE) are refused with 400 "Invalid ledger id" (a 500 before fix: parse ledger ids once at the edge and key everything by a typed id #1958 mappedInvalidLedgerId). Nothing is written; head was checked unchanged.normalize_ledger_scoped_fromandledger_scoped_sparql_dataset_speccompare ledger ids as raw strings. So/query/pinwithFROM <pin:main>orfrom: "pin:main"returns "Ledger mismatch", although explain, which compares typed ids, accepts it.@commit:ffffff) is a 500 from the resolver, the same as a body pin. A time before the first commit becomes a 400 with feat(api): branch from a point in time with at: "time:<ISO>" #1969.#fragmentin an unpinned path, and aurn:fluree:prefix in the path, pinned or not, are 400 "Invalid ledger id" (500s before fix: parse ledger ids once at the edge and key everything by a typed id #1958). That is unchanged by this PR: the path keeps its raw spelling downstream, and the loader's parser is stricter than the scope check's.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).