Skip to content

feat(api): branch from a point in time with at: "time:<ISO>" - #1969

Merged
bplatz merged 8 commits into
mainfrom
feat/branch-at-time
Sep 29, 2026
Merged

bplatz merged 8 commits into
mainfrom
feat/branch-at-time

Conversation

@bplatz

@bplatz bplatz commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Problem

POST /branch with at: "time:2026-01-01T00:00:00Z" returned 404. Branch creation parsed at with CommitRef::parse, which recognises only:

  • t:
  • commit:
  • bare integers
  • CIDs

Anything else fell through to a commit-prefix lookup, so the literal string time:2026-… was searched as a hash prefix. Queries and export already accept time:, which is how people think about a branch point ("the data as of last quarter").

What this does

POST /branch, Fluree::create_branch and fluree branch create --at (local and remote) now accept the same at spellings as queries and export. A branch at time:X is headed by exactly the commit a query at @time:X reads.

  • One grammar. A branch point is "the source as of X", which is what TimeSpec describes, so at uses that grammar and its resolver. There is no second parser kept in step by hand.
    • Commit spellings (commit:, bare hex prefix, CID) still resolve directly. A CID stays exact, so a commit that isn't on the source's line of commits still gets verify_ancestor's explanation.
    • Every other form goes through time_resolve::resolve_time_spec, the resolver queries and export use, and then to the commit at that t.
  • Parsing. POST /branch reuses export's parse_time_spec, a thin wrapper around TimeSpec::parse_at. Local CLI mode reuses query --at's parser. Remote CLI mode passes the string through to the server, so all three paths accept the same spellings. They report the same underlying message, inside different wrappers: invalid --at value: … in the CLI, invalid 'at' time spec: … from the server.
  • CommitRef::parse is unchanged. Its remaining callers (branch revert and its preview, show, and the server's revert routes) name a commit to act on. There, a timestamp would select a commit nobody identified.

Rust API change

Fluree::create_branch(ledger, branch, source, at: Option<TimeSpec>) replaces source_commit: Option<CommitRef>.

  • Callers that pass None compile unchanged.
  • A caller holding a commit id writes TimeSpec::at_commit(cid.to_string()).

Accepted forms and statuses

Accepted:

  • t:N and bare integers
  • time: / iso: and bare ISO-8601
  • recorded:
  • commit:<prefix> and bare hex prefixes of 6 or more characters, with or without sha256: / fluree:commit:
  • full CIDs
  • latest / t:latest

Every value accepted before resolves to the same commit as before.

Case Result
time: between two commits 201; the head is the last commit at or before that instant
time: after the head the head (same as omitting at, and the index is copied)
recorded: 201, resolved on the recorded-time axis
time before the first commit (either axis) 400 InvalidBranch
malformed timestamp 400 InvalidBranch
snapshot:<id> 400 InvalidBranch (a graph-source table snapshot, never a commit on a ledger)
t:0, 0, a negative t 400 InvalidBranch (was a 500)
time: with no value, t:abc 400 at parse time
bare prefix under 6 characters 400 at parse time (was a 500 from the resolver)
commit not on the source's line, or unknown prefix 404, unchanged

Also changes queries: a time before the first commit is a 400

datetime_to_t and recorded_to_t reported a time before the first commit as ApiError::Internal. So @time: / @recorded: there reached HTTP clients as a 500 err:system/InternalError. Branching must use the same resolver and must return a 400, so both now return invalid_query, the class the resolver already uses for a malformed timestamp. The message is unchanged. This also reaches JSON-LD from, SPARQL FROM and export. It is a separate commit.

Also: other time and t refusals that were 500s

Added after review, one commit each:

  • A t below 1. Resolving t:0, 0 or -3 to a commit raised ApiError::query, an internal error. It now returns invalid_query. Branch creation's commit lookup also moved inside the InvalidBranch relabel, so it answers like the other points that name no commit. branch revert and its preview resolve commits the same way, so they now return a 400 for the same input.

  • Multi-query asOf. From<EnvelopeSnapshotError> for ApiError made every snapshot failure internal. Now:

    • an ISO asOf before a referenced ledger's first commit, or a malformed one, is a 400 err:db/InvalidQuery naming the ledger;
    • a referenced ledger that does not exist is a 404, with or without asOf. It was a 500 even with no asOf.

    Other load and resolve failures stay 500. The status table in docs/api/multi-query.md lists the new cases.

Docs

  • docs/cli/branch.md, docs/api/endpoints.md (POST /branch) and docs/guides/cookbook-branching.md list the accepted forms and show a point-in-time example. The CLI help text and the CreateBranchRequest doc match.
  • docs/cli/show.md and the revert paragraph of branch.md now say they take commit spellings only. Before, they cross-referenced "the same forms as branch create --at".
  • docs/cli/server-integration.md lists the at field the CLI already sends.
  • The Rust example in docs/concepts/ledgers-and-nameservice.md gains its fourth argument.
  • Removed the claim that t: and hex-prefix resolution need an indexed source. Both resolvers scan novelty, and the HTTP suite branches at t:2 with indexing disabled.

Tests

For each change below, the test(s) listed after it were run with that change reverted and failed, then the change was restored.

  • API (it_branch):
    • create_branch_at_time_matches_query_at_time covers four instants. For each it checks:

      • the branch's t and head;
      • that the branch's t equals db_at(source, spec).t;
      • that the branch's rows equal a query at @time:.

      Event and recorded times are pinned through CommitOpts.

    • create_branch_at_iso_and_latest

    • create_branch_at_recorded_uses_the_recorded_axis: the same instant gives t=2 on the recorded axis and t=3 on the event axis.

    • create_branch_at_a_point_with_no_commit_is_a_bad_request covers a time before the first commit on both axes, a malformed timestamp, t:0, 0, -3 and snapshot:. Each returns InvalidBranch, and no branch record is created.

  • Queries: time_travel_iso_too_early_errors asserts 400 on JSON-LD from and on a new SPARQL FROM twin.
  • HTTP:
    • create_branch_at_time: time: gives t=1 and iso: gives t=2. A time before the first commit, a malformed timestamp, t:0, 0, -3, snapshot:7 and a bare time: each return 400 with @type err:api/BadRequest.
    • multi_query_asof_naming_no_data_returns_400: an asOf before the first commit and a malformed one.
    • multi_query_unknown_ledger_returns_404: with and without asOf.
  • CLI:
    • branch_create_at_time_matches_query_at_time: the branch reads like query --at, and a time before the first commit fails.
    • help_text_lists_the_accepted_spellings now requires the time spellings for branch create.
Reverted Failed
A tagged time goes to the commit-prefix lookup again (the old behavior) the 4 new API tests, the HTTP test, the CLI test; all older tests stayed green
time: resolved on the recorded axis all 4 new API tests
Off-by-one t - 1, with the test's hard-coded t and head checks disabled the db_at().t check alone; with that also disabled, the row check
Resolver reports a time before the first commit as internal again the API bad-request test, the JSON-LD status check, the SPARQL twin alone, the HTTP test (500 err:system/InternalError)
InvalidQuery → InvalidBranch relabel disabled the API variant check, and the HTTP @type check (err:db/InvalidQuery)
Server parses at as a raw commit the HTTP test (the original 404 No commit found with prefix: time:…) and the existing create_branch_at_historical_t
CLI local mode parses at as a raw commit, with the old help text the CLI time test, the help test and the existing branch_create_at_and_query_at_accept_the_same_spellings
A t below 1 is ApiError::query again the API bad-request test and the HTTP test (500 err:system/InternalError)
Branch's commit lookup outside the relabel the API variant check, and the HTTP @type check (err:db/InvalidQuery)
Multi-query: malformed asOf internal again the asOf test's malformed case
Multi-query: pre-genesis asOf internal again the asOf test's pre-genesis case
Multi-query: not-found passthrough disabled the unknown-ledger test (500)

Adjacent paths, checked and left alone: revert, revert preview, show and the server's parse_commit_ref still use CommitRef::parse. Merge, rebase and merge-preview take no commit refs.

Not verified

  • Tests run:
    • fluree-db-api: grp_ledger, grp_query_history and --lib.
    • fluree-db-server: grp_http, plus grp_query after the review fixes.
    • fluree-db-cli: integration, time_travel_spec and docs_coverage. Not re-run after the review fixes, which change no CLI code.
    • clippy on fluree-db-api, -server, -cli and -consensus.
  • fluree-db-consensus tests were not run; the crate compiles under clippy --all-targets.
  • Remote CLI mode was not run against a live server. It is covered by the server test plus the raw passthrough.
  • The CID-stays-exact branch was not mutation-tested. create_branch_at_merged_in_commit_fails covers the behavior it protects.

Found along the way, not changed

create_branch reported an unborn source as an internal error, and the
server's status mapping had no arm for InvalidBranch, so every
InvalidBranch reaching it through a direct API call was a 500 as well.
That also covers merge-preview's documented 400s (previewing a root
branch, merging a branch into itself).
datetime_to_t and recorded_to_t reported a time before the first commit
as ApiError::Internal, so `@time:` / `@recorded:` there reached HTTP
clients as a 500 err:system/InternalError and the CLI as "Internal
error: ...". It is the caller asking for data before the ledger existed.

Report it as an invalid query, the class the same resolver already uses
for a malformed timestamp. The message is unchanged. Query `from`,
SPARQL `FROM` and export all resolve through here; the test pins the
status on both query surfaces.
Branch creation parsed `at` with CommitRef::parse, which knows t:,
commit:, bare integers and CIDs and treats anything else as a commit
prefix. `at: "time:2026-01-01T00:00:00Z"` therefore reached the prefix
scan and came back 404, while queries and export accept that spelling.

A branch point is the source as of some point, which is what TimeSpec
describes, so `at` now uses that grammar and its resolver rather than a
second parser kept in step by hand:

- Fluree::create_branch takes Option<TimeSpec> in place of
  Option<CommitRef>. Everything but a commit spelling resolves through
  time_resolve::resolve_time_spec, the resolver queries and export use,
  and then to the commit at that t, so a branch at time:X is headed by
  exactly the commit a query at @time:X reads. Commit spellings still
  resolve directly, and a CID stays exact, so every value accepted
  before resolves as before.
- POST /branch and local `fluree branch create --at` parse with
  TimeSpec::parse_at, the function behind export's `at` and query's
  `--at`. Remote CLI mode sends the string through to the same parse, so
  both modes accept the same spellings with the same error text.

Accepted: t:<N> and bare integers, time:/iso: and bare ISO-8601,
recorded:, commit:<prefix> and bare hex prefixes, full CIDs, and
latest / t:latest (the source head). A time before the source's first
commit, a malformed timestamp, and snapshot:<id> (a graph-source table
snapshot, never a commit on a ledger) are InvalidBranch, a 400. A bare
prefix under six characters is now refused at parse time (400) instead
of by the resolver (500).

CommitRef::parse is unchanged. Its remaining callers (branch revert and
its preview, show) name a commit to act on, where a timestamp would
select a commit nobody identified.
List time:/iso:, recorded:, latest and the bare timestamp form for
`fluree branch create --at` and POST /branch `at`, with the 400 cases,
and add a point-in-time example to the CLI reference, the endpoint
reference and the branching cookbook. The CLI-to-server contract now
documents the `at` field the CLI already sends.

Drop the claim that t: and hex-prefix resolution need an indexed
source: both resolvers scan novelty, and the HTTP suite branches at t:2
with indexing disabled. `show` and `branch revert` now say they accept
the commit spellings of `branch create --at`, since they take no
timestamps. The Rust create_branch example gains its fourth argument.
@bplatz bplatz added enhancement New feature or request area:server HTTP surface, routes, error mapping, swagger, timeouts/admission, config graph area:cli fluree CLI UX, rdf toolkit, publish/export/insert flows labels Sep 26, 2026
@bplatz
bplatz requested review from aaj3f and zonotope September 26, 2026 22:00

@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.

This is really nice, @bplatz — deleting the second parser rather than teaching it new words is exactly the right move, and the CommitRef::parse-stays-put reasoning (its remaining callers name a commit to act on, where a timestamp would select a commit nobody identified) is the part I'd have worried about and you'd already settled it. I spent most of my time trying to break the time semantics and couldn't: parse_time_travel_iso is parse_from_rfc3339, so an offset-less time:2026-01-01T00:00:00 is refused rather than silently read as UTC; the boundary is inclusive because probe_timestamp_axis uses an exclusive lower bound and then steps back one, and the parity test pins that instant explicitly; a future time collapses to the head exactly as @time: does on a query, index copy and all; and resolved_t = 0 is unreachable because event time is enforced monotonically non-decreasing at commit (fluree-db-transact/src/commit.rs:487-505), so the .max(0) stays defensive rather than load-bearing. The docs commit's quiet correction — dropping the "requires an indexed source" claim for t:/prefix resolution, because both resolvers pass novelty to range_with_overlay — is the kind of stale-doc fix that usually never happens.

On the security question I went looking for a time-travel policy escape and there isn't one here: /branch is inside v1_admin_protected_writes behind require_admin_token (routes/mod.rs:136), the view branch_point_commit resolves against reads only commit-metadata flakes and returns an id rather than data, and branching at a historical point was already reachable via at: "t:5" — this PR widens spellings, not privileges.

Two optional notes, both of which you already named in "Found along the way": /v1/fluree/multi-query's asOf still reports a pre-genesis time as a 500, because From<EnvelopeSnapshotError> for ApiError flattens everything to internal — and that's the one non-admin route where a caller typo still reads as a server fault, which also makes the new comment at time_resolve.rs:108 ("a 400 on every surface that resolves one") slightly ahead of the code. And at: "t:0" / "0" / "-3" on POST /branch is still a 500 out of resolve_t_to_commit_id. Both look like one-constructor fixes in the same direction as the second commit, so I'd rather see them here than filed; if you'd rather keep the commit tight on the second one, that's fine, but I'd at least narrow that comment so it doesn't read as settled.

Two smaller things for the record rather than for changing: the body says all three paths "report the same error text" — the TimeSpec::parse_at message is identical, but the wrappers differ (invalid --at value: … in the CLI vs invalid 'at' time spec: … from routes/export.rs:210), which is fine, just not literally the same string. And on merge order — 119068e1 is #1945's commit riding along in this diff, so approving this is also approving that server InvalidBranch → 400 mapping. I checked its blast radius: all ~25 InvalidBranch producers across merge / merge_preview / rebase / revert / revert_preview / loading are caller-input validation, and ApiError::status_code() had already said 400 for it (fluree-db-api/src/error.rs:618) while the server's catch-all was quietly turning it into a 500 — so that arm is aligning two tables that had diverged, not loosening anything. Whichever of #1945 / #1969 lands first, the other needs a retarget.

fwiw, for the pin: fluree/solo sits on fluree-db-api rev ffeeec554 (this PR's merge-base) and its only create_branch call is handler.rs:3368 passing None, so the Option<CommitRef> → Option<TimeSpec> break is a no-op for us when the pin next moves.

Adherence to repo commitments:

  • Patterns/abstractions: ✔ Collapses branch creation onto TimeSpec::parse_at + time_resolve::resolve_time_spec — the existing shared grammar and resolver — instead of widening a second parser; the one deliberate exception (AtCommit resolving directly) is documented and I verified the reasoning holds.
  • Performance (speed first, memory second): ✔ No hot path touched. Resolving by time is one or two bounded POST probes at flake_limit(1) plus one at flake_limit(16) — no scan that grows with history; the linear collect_first_parent_cids walk in verify_ancestor is unchanged and already ran for --at t:N. The to_ledger_state() → from_state() round trip is Arc refcount traffic, not a deep copy. No performance-degradation risk.
  • Testing: ✔ Nine new/changed tests, all confirmed running by name in the green test job on f9f6c2946 (run 36274812888). Wiring checked against the autotests = false trap: it_branch.rs → grp_ledger:4-5, it_query_time_travel.rs → grp_query_history:16-17, server integration.rs → grp_http:18-19; fluree-db-cli has no autotests = false. Nothing orphaned. The parity test asserts against the resolver rather than hard-coded values, so it can't drift.
  • Conventions: ✔ Four self-describing subjects with substantial multi-line bodies, no AI-attribution trailers, fmt/clippy green on the exact head, and the docs actually track the behavior change across six files — including the show / branch revert cross-references that no longer point at branch create --at.

Verified locally at branch HEAD f9f6c2946: traced only — no local compile (the workspace's --all-features build is broken on my machine via vector → usearch → cxx, and CI already ran the full --all-features nextest on this exact SHA with every new test present by name, so a narrower local run would have proved less). I pulled the CI log and grepped each new test name, traced the timezone / boundary / future-time / pre-genesis / t=0 paths through time_resolve.rs and ledger_view.rs, enumerated every Fluree::create_branch call site and every InvalidBranch producer, and read the /multi-query error path end to end.

Approving so you can merge when ready — but if you agree on the multi-query one, I'd rather see it in this PR than tracked and forgotten.

Comment thread fluree-db-api/src/time_resolve.rs Outdated

// Check if target is before earliest commit
// A time before the ledger existed is the caller's mistake, not a fault:
// a 400 on every surface that resolves one (query, export, branch).

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.

fluree-db-api/src/time_resolve.rs:108 — optional. This is more of a question than a suggestion, but I think the comment's "every surface" is one surface short, and the gap is on a route ordinary users can reach.

/v1/fluree/multi-query's asOf resolves through this exact function — query/multi/snapshot.rs:161 calls datetime_to_t directly — but impl From<EnvelopeSnapshotError> for ApiError at query/multi/snapshot.rs:63-67 flattens every variant to ApiError::internal(err.to_string()), and fluree-db-server/src/routes/query.rs:4434 hands that straight back as ServerError::Api. So the new invalid_query class is re-typed to Internal one frame later and the caller still gets a 500 err:system/InternalError. /multi-query sits in the plain v1 router (routes/mod.rs:343), not the admin bracket — unlike /branch and /export — so it's the one data-token surface where a caller typo is still reported as a server fault.

POST /v1/fluree/multi-query with {"asOf": "1999-01-01T00:00:00Z", "queries": {...}} on any ledger → 500 err:system/InternalError, where the same instant on POST /v1/fluree/query now correctly returns 400. EnvelopeSnapshotError::InvalidIso gets the same treatment, so a straightforwardly malformed asOf timestamp is a 500 too.

The fix looks like it stays inside that one From:

impl From<EnvelopeSnapshotError> for ApiError {
    fn from(err: EnvelopeSnapshotError) -> Self {
        match err {
            // The caller named an instant we can't use — same class the
            // per-surface resolvers now use.
            e @ EnvelopeSnapshotError::InvalidIso { .. } => ApiError::invalid_query(e.to_string()),
            EnvelopeSnapshotError::PerLedgerResolve { ledger, source } => match source {
                ApiError::Query(_) => ApiError::invalid_query(format!(
                    "failed to resolve asOf for ledger '{ledger}': {source}"
                )),
                other => ApiError::internal(format!(
                    "failed to resolve asOf for ledger '{ledger}': {other}"
                )),
            },
            other => ApiError::internal(other.to_string()),
        }
    }
}

I recognize this is adjacent scope and you flagged it deliberately — but it's the same defect the second commit exists to fix, on the one surface where a normal user hits it, and the comment on this line is currently making a promise the code doesn't quite keep. If you agree it's right, I'd rather see it folded in here than lost in the backlog. If you'd rather keep the commit tight, then minimally we may want to narrow the comment to name the surfaces it actually covers, so the next person doesn't read it as settled. Commenting here because fluree-db-api/src/query/multi/snapshot.rs is not in this diff.

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, and fixed in ad4edee before the merge. The From now keeps the class of the caller's mistake: an asOf that is malformed, or that falls before a referenced ledger's first commit, is a 400 err:db/InvalidQuery naming the ledger. I matched QueryError::InvalidQuery rather than any Query(_), since Query also carries cancellations and storage denials.

Writing the test turned up a third case on the same route. A ledger that doesn't exist was also a 500, with or without asOf, because LedgerLoad was flattened the same way. It's now passed through as the 404 it already was. Other load and resolve failures stay 500.

The new tests multi_query_asof_naming_no_data_returns_400 and multi_query_unknown_ledger_returns_404 each failed with their arm reverted. The comment at time_resolve.rs:108 now names multi-query asOf, and the status table in docs/api/multi-query.md lists the 400 and 404 cases.

other => other,
})?;
LedgerView::from_state(&state)
.resolve_commit(CommitRef::T(t))

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.

fluree-db-api/src/ledger/loading.rs:460 — optional. The other half of the same story: at: "t:0", at: "0" and at: "-3" still come back as 500s from this hop.

parse_time_travel_spec puts no lower bound on t: (fluree-db-core/src/ledger_id.rs:143-152), resolve_time_spec's AtT arm returns the value verbatim, and then resolve_t_to_commit_id raises ApiError::query("Transaction number must be >= 1, got 0") at fluree-db-api/src/ledger_view.rs:463-467 — and ApiError::query builds an ApiError::Internal (error.rs:560-562), so it lands as 500 err:system/InternalError. Because this resolve_commit call sits outside the map_err above it, the InvalidBranch relabel doesn't reach it either.

POST /v1/fluree/branch {"ledger":"mydb","branch":"x","at":"t:0"} → 500, on the same endpoint where {"at":"time:1999-01-01T00:00:00Z"} now correctly returns a 400 err:api/BadRequest. Same for a bare "0" or "-3", since parse_at reads a bare integer as a t.

I don't think this is more than a one-constructor swap — ApiError::query → ApiError::invalid_query at ledger_view.rs:464 — and it would improve @t:0 on the query surfaces in exactly the same direction as the second commit. Worth noting it lands as err:db/InvalidQuery rather than InvalidBranch on the branch path, since it's outside the mapper; if you'd rather it read as a branch error, moving the resolve_commit(CommitRef::T(t)) call inside the same map_err would do it. You did call this one out in the PR body, so this is genuinely "if you agree, now rather than later" — not a disagreement with the scoping.

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 7673457, both halves:

  • resolve_t_to_commit_id now uses invalid_query for a t below 1.
  • The branch path's commit lookup moved inside the same relabel. t:0, 0 and -3 now read as InvalidBranch / err:api/BadRequest, like the other points that name no commit.

branch revert and its preview resolve commits the same way, so they get the 400 too. The API and HTTP bad-request tests now include all three values, and reverting either half failed them.

async fn branch_point_commit(view: LedgerView, source_id: &str, at: TimeSpec) -> Result<CommitId> {
if let TimeSpec::AtCommit(spelling) = at {
// Resolved directly rather than through its `t`: a CID stays exact, so
// an off-line commit reaches `verify_ancestor`'s explanation.

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.

fluree-db-api/src/ledger/loading.rs:443 — praise. This early return is the subtle call in the whole change and the two-line comment undersells it. I traced the counterfactual: if AtCommit went through resolve_time_spec, it'd hit time_resolve::commit_to_t, which for a commit that isn't on the source's line returns ApiError::query("No commit found with prefix: …") — an ApiError::Internal, so a 500 — and create_branch_at_merged_in_commit_fails would have lost the "a commit that arrived through a merge cannot be branched at…" explanation that makes that error useful. Keeping the CID exact is what routes it into verify_ancestor instead. Please leave the comment in; it's the kind of thing that gets "simplified" away in six months.

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.

Thanks. The comment stays, and this thread records the counterfactual in case anyone is tempted to route AtCommit through resolve_time_spec later.

/// A branch at `time:X` is the source as a query at `@time:X` reads it: the
/// same `t` and the same rows, at every position relative to the commits.
#[tokio::test]
async fn create_branch_at_time_matches_query_at_time() {

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.

fluree-db-api/tests/it_branch.rs:532 — praise. Asserting the branch's t against db_at(source, spec).t and its rows against a @time: query — rather than against hard-coded expectations — is the right shape for a parity claim: it can't drift if the resolver changes, which is the whole point of collapsing onto one resolver. The four instants cover the two cases I'd have gone looking for (exactly on a commit's event time, and past the head), and create_branch_at_recorded_uses_the_recorded_axis pinning t=2 on the recorded axis against t=3 on the event axis for the same instant is a genuinely good way to prove the axes aren't silently aliased.

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.

Thanks. That's why the test asserts against db_at and a query rather than fixed numbers: if the resolver changes, the test follows it instead of going stale.

# Conflicts:
#	docs/api/endpoints.md
#	docs/cli/server-integration.md
#	fluree-db-api/src/ledger/loading.rs
#	fluree-db-server/src/error.rs
#	fluree-db-server/src/routes/ledger.rs
Resolving `t:0`, `0` or `-3` to a commit raised `ApiError::query`, an
internal error, so `POST /branch` with such an `at` answered 500
`err:system/InternalError`. The check now returns `invalid_query`, which
also gives `branch revert` and its preview a 400 for the same input.

Branch creation's commit lookup now runs inside the same relabel as the
time resolver, so a `t` below 1 reads as `InvalidBranch` like the other
points that name no commit on the source.
`From<EnvelopeSnapshotError> for ApiError` turned every snapshot failure
into an internal error, so `/multi-query` answered 500 where `/query`
answers 400 or 404:

- an ISO `asOf` before a referenced ledger's first commit, or a malformed
  one, is now a 400 `err:db/InvalidQuery`, naming the ledger;
- a referenced ledger that does not exist is now a 404, with or without
  `asOf`.

Other load and resolve failures stay 500. The HTTP status table in the
multi-query docs lists the new 400 and 404 cases.
@bplatz
bplatz merged commit 31d3940 into main Sep 29, 2026
16 checks passed
@bplatz
bplatz deleted the feat/branch-at-time branch September 29, 2026 13:14
@bplatz

bplatz commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review. For the record, here's what changed after it; the PR is now merged:

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

Labels

area:cli fluree CLI UX, rdf toolkit, publish/export/insert flows 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