Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Implementing Server Support For Fluree CLI

This document is for implementers building a custom server (for example in ../solo3/) that wants to support the Fluree CLI end-to-end.

The CLI supports two broad categories of remote operations:

  • Data API: query / update / insert / upsert / info / exists / show / log / history / context / explain, plus admin operations like create / drop / reindex / branch (create / drop / rebase / merge) / publish / export / import.
  • Replication / sync: clone / pull / fetch (content-addressed replication by CID, via pack + storage proxy), ledger-archive (export --format ledger), and wholesale restore (create --remote --from <archive>.flpack, via POST /import).

Base URL And Discovery

The CLI prefers to be configured with a server origin URL (scheme/host/port) and then uses discovery:

  • GET /.well-known/fluree.json returns api_base_url (usually /v1/fluree)

The CLI stores the discovered base as the remote’s base_url and constructs all other endpoints relative to it.

If you do not implement discovery, users must configure the CLI remote URL to already include the API base (for example http://localhost:8090/v1), and the CLI will append /fluree as needed.

Minimum Endpoints By CLI Feature

fluree remote add, fluree auth login

  • GET /.well-known/fluree.json

fluree fetch (nameservice refs only)

  • GET {api_base_url}/nameservice/snapshot
  • POST {api_base_url}/nameservice/refs/:ledger-id/commit
  • POST {api_base_url}/nameservice/refs/:ledger-id/index
  • POST {api_base_url}/nameservice/refs/:ledger-id/init

fluree clone, fluree pull (pack-first replication)

Required:

  • GET {api_base_url}/info/*ledger (existence + remote t preflight; see /info minimum fields below)
  • GET {api_base_url}/storage/ns/:ledger-id (remote NsRecord, includes commit_head_id, optional index_head_id, and optional config_id)
  • POST {api_base_url}/pack/*ledger (binary fluree-pack-v1 stream)

The CLI sends pack requests with index artifacts by default (include_indexes: true, want_index_root_id from the NsRecord) when the remote advertises an index_head_id. Use --no-indexes on clone/pull to request commits and txns only. Use --no-txns on clone to request commits without original transaction payloads (the commit chain still transfers and remains verifiable). Servers that support pack MUST honor the following request fields:

  • include_indexes: bool — when false, skip index artifact frames.
  • include_txns: bool — when false, skip transaction blob frames. Commits are still streamed; the server must decode each commit’s envelope and simply omit the referenced txn blob from the stream. The emitted PackHeader.capabilities should reflect this (drop "txns" from the list).

Servers that support pack should support all combinations of these flags.

Fallbacks (strongly recommended):

  • GET {api_base_url}/commits/*ledger (paginated export of commit + txn blobs)
  • GET {api_base_url}/storage/objects/:cid?ledger=:ledger-id (per-object fetch by CID)

fluree track add --mode peer (local query execution over served blocks)

Peer mode runs the consumer’s queries locally over index blocks fetched on demand from your server (see Sharing data with downstream consumers). It is read-path only — writes and every other command on a peer-tracked ledger use the normal data/transaction endpoints above.

Required:

  • GET {api_base_url}/storage/ns/:ledger-id — NsRecord JSON. Peer mode reads commit_head_id, index_head_id, commit_t/index_t (head freshness is checked per query), source_branch/branches (branched ledgers), and the optional serving array (["query","blocks"]) that advertises the tiers on offer. Unknown fields are ignored, so additions are safe.
  • GET {api_base_url}/storage/objects/:cid?ledger=:ledger-id — canonical CAS bytes for all replication-relevant kinds including raw index leaves and dictionary blobs. The client verifies every payload against its CID, so bytes must be exact.

Required semantics:

  • All-or-nothing authorization. These endpoints serve a ledger’s full contents with no row-level filtering, so a token must only be honored for ledgers the bearer may read in full (fluree.storage.all / fluree.storage.ledgers claims in the reference implementation). Return 404 for out-of-scope, unknown, or serving-disabled ledgers — never 403 (no existence leak; the CLI treats 404 as not-found).
  • Dict-blob branch resolution. Shared dictionary artifacts live under a name-scoped @shared namespace, so the client cannot know the branch and derives a default-branch alias (e.g. mydb:main) even when the ledger only exists on another branch. For dict-blob CIDs, resolve the ledger parameter to any live branch of the same name before authorizing and reading — otherwise peers tracking non-default branches fail on dictionary fetches.

Strongly recommended:

  • Single-range Range: bytes=start-end support on /storage/objects (206 + Content-Range, 416 past-end), verifying the full object against the CID before slicing. Without it the client falls back to whole-object fetches for leaflet-granular reads.

Optional:

  • GET {api_base_url}/storage/credentials?ledger=:ledger-id — vended S3 credentials so peers read directly from S3 instead of proxying bytes through your server. The CLI probes this once per peer query invocation and falls back to /storage/objects on 404, so serving 404 is a complete, valid implementation. If you do implement it, return the grant shape documented in endpoints (access_key_id, secret_access_key, session_token, expires_at_epoch_secs, bucket, region, optional endpoint / key_prefix) with credentials scoped to the requested ledger’s storage prefix; the client auto-refreshes as grants approach expiry.

fluree remote ledgers (auth-filtered catalog)

  • GET {api_base_url}/nameservice/snapshot{ "ledgers": [NsRecord…], "graph_sources": […] }, filtered to the presented token’s scope (an unauthorized caller sees an empty or partial list, not an error).
  • GET {api_base_url}/storage/ns/:ledger-id — fetched per ledger to fill the SERVING column from the record’s serving array; the column renders - when the field or record is unavailable, so this degrades gracefully.

fluree push (commit ingestion)

  • POST {api_base_url}/push/*ledger

This is not storage-proxy replication; it is a transaction operation and should be authorized like normal transactions.

The CLI sends an Idempotency-Key header derived from the pushed commit bytes so servers can safely replay a successful push result if the client retries after a timeout.

fluree show --remote

  • GET {api_base_url}/show/*ledger?commit=<ref>

The commit query parameter accepts the same identifiers as the local fluree show command: t:<N> for transaction number, hex-digest prefix (min 6 chars), or full CID.

Policy filtering: The returned flakes are filtered by the caller’s data-auth identity (extracted from the Bearer token) and the server’s configured default_policy_class. When neither is present, all flakes are returned (root/admin access). Flakes the caller cannot read are silently omitted — the asserts and retracts counts reflect only the visible flakes. Unlike the query endpoints, show does not accept per-request policy overrides via headers or request body.

Response: A JSON object with fields: id, t, time, size, previous, signer, asserts, retracts, @context, flakes. Each flake is a tuple: [subject, predicate, object, datatype, operation].

Error responses:

  • 400 Bad Request — missing or invalid commit parameter
  • 404 Not Found — ledger or commit not found
  • 501 Not Implemented — proxy storage mode (no local index available for decoding)

fluree create <ledger> --remote <name> (admin-protected)

  • POST {api_base_url}/create with {"ledger": "<ledger>"} (empty ledger), or
  • POST {api_base_url}/import/*ledger with a raw .flpack body (when --from <archive>.flpack is given — see Ledger Import Contract), or
  • the /import-upload handshake when the server is size-capped (see Negotiated Upload Import Contract)

With no --from, creates an empty ledger on the remote server. With --from <archive>.flpack, the CLI restores a full ledger remotely (commits + txns + prebuilt index) under the given name — streaming directly to POST /import by default, or via the negotiated out-of-band upload when the server advertises a body-size cap below the archive. The CLI still rejects --remote together with a non-.flpack --from, or with --memory (those bulk-import paths require local data ingestion); for those, populate locally then run fluree publish <remote> <ledger> (which calls /exists, /create, and /push in sequence), or export to .flpack first.

--remote does not touch local state — neither the active-ledger pointer nor the local storage tree. The CLI does not require a project-local .fluree/ for create --remote; it falls back to global config ($FLUREE_HOME or the platform default) for remote registration lookups. Auto-routing through a local server is not done for create; you must pass --remote <name> explicitly. Without --remote, fluree create is local-only and does require a project .fluree/.

fluree context get|set --remote

  • GET {api_base_url}/context/*ledger (read)
  • PUT {api_base_url}/context/*ledger (write)

Read or replace the default JSON-LD context for a ledger. get returns the context as JSON; the unwrapped object is what the CLI prints. set accepts either a bare object ({"ex": "http://example.org/"}) or a {"@context": {...}} wrapper, and replies with {"status": "updated"} (or 409 Conflict after CAS retries).

get uses normal data-read auth (Bearer required when data_auth.mode == required, gates on can_read(ledger)). set uses normal write auth (can_write(ledger)). Auto-routing behaves the same way as other read/write commands — pass --direct to skip.

fluree history --remote

  • POST {api_base_url}/query/*ledger

Server-side history queries via JSON-LD: the CLI builds the same from/to/select/where body it would send locally and POSTs it to the ledger-scoped query endpoint (/query/{ledger}). The path carries the bare ledger ID (e.g. mydb:main) so the server’s can_read check matches normal scoped read tokens; the body’s from carries the time-travel suffix (mydb:main@t:N) which the query engine uses to build a historical view at that t. Posting to the connection-level /query instead would force auth to read from for the ledger ID and reject any token not scoped to the time-travel form.

Entity and predicate compact IRIs (ex:alicehttp://example.org/alice) are expanded client-side using the project’s stored prefix map before the request leaves the CLI, so the server never has to consult the local prefix table. The query body still ships its @context (also derived from local prefixes) so the server can compact response IRIs back into the user’s preferred form for display.

fluree log --remote

  • GET {api_base_url}/log/*ledger?limit=<N>

Returns lightweight per-commit summaries newest-first by t. Read-auth (same bracket as /show) — does not require storage-replication permissions, unlike /commits. See Commit Log Contract for the response shape and required server semantics.

When --remote is omitted, the CLI auto-routes through a locally running fluree server start if one is detected; pass --direct to skip auto-routing and use the local commit-chain walker.

fluree export --remote (admin-protected)

  • POST {api_base_url}/export/*ledger

Returns ledger data as RDF in the requested format (Turtle, N-Triples, N-Quads, TriG, or JSON-LD). Admin-protected — same bracket as /create, /drop, /reindex. RDF export today reads from the binary index without per-flake policy filtering, which is why it does not live in the data-read bracket alongside /query and /show. See RDF Export Contract for the request body fields and content-type mapping.

When --remote is omitted, the CLI auto-routes through a locally running server when one is detected; pass --direct to bypass routing and use the local binary index. Tracked ledgers (no local data) require --remote.

fluree publish <remote> [ledger] (create + push)

Creates a ledger on the remote and pushes all local commits in a single operation.

Required endpoints:

  • GET {api_base_url}/exists/*ledger (check if ledger already exists)
  • POST {api_base_url}/create (create empty ledger if not exists)
  • GET {api_base_url}/info/*ledger (check remote head when ledger exists)
  • POST {api_base_url}/push/*ledger (push all commits)

Workflow:

  1. CLI calls GET /exists?ledger=mydb:main
  2. If exists: false, CLI calls POST /create with {"ledger": "mydb:main"}
  3. If exists: true, CLI calls GET /info/mydb:main and rejects if t > 0 (remote already has data)
  4. CLI walks the full local commit chain (oldest → newest) and sends all commits via POST /push/mydb:main
  5. CLI configures upstream tracking locally

The --remote-name flag allows publishing under a different name on the remote (e.g., fluree publish origin mydb --remote-name production-db).

fluree drop <name> --remote <name> (admin-protected)

  • POST {api_base_url}/drop with {"ledger": "<name>", "hard": true}

Drops a ledger or graph source on the remote server. The CLI sends hard: true (no soft-drop surface today). The server resolves name as a ledger first, then as a graph source — see the fluree drop graph source fallback section below for the resolution order and response shape.

When --remote is omitted, the CLI auto-routes through a locally running fluree server start if server.meta.json is present and the PID is alive, falling back to direct local execution otherwise. Pass --direct to skip auto-routing. The --force flag is required in all modes to confirm deletion.

Active-ledger handling:

  • --remote <name> (explicit): never touches local state. Remote storage is separate; the local active-ledger pointer and local storage are unaffected.
  • Auto-route (no --remote, server running): same on-disk storage as --direct, so a successful drop also clears the local active-ledger pointer if it matched the dropped name.
  • --direct (no --remote, no server): clears the active-ledger pointer if it matched.

fluree create <name> --from <file>.flpack (native ledger import)

  • Local mode (default): no server endpoint required.
  • Remote mode (--remote <name>): POST {api_base_url}/import/*ledger with the raw .flpack body — see Ledger Import Contract.

Imports a .flpack file (native ledger pack) into a new ledger — locally, or onto a remote server with --remote. The .flpack format uses the same fluree-pack-v1 wire format as POST /pack. See Ledger portability below.

fluree export --format ledger

Exports a full ledger (all commits, txn blobs, and — unless --no-indexes — binary index artifacts) as a .flpack archive. The archive contains a phase: "nameservice" manifest frame so the importer can reconstruct the head pointers. Pass -o <FILE> to write to disk (required when stdout is a TTY).

Local mode (default):

  • No server endpoint required.

Streams from the local ledger via the Fluree::archive_ledger API.

Remote mode (--remote <name>):

  • GET {api_base_url}/storage/ns/:ledger-id (NsRecord lookup)
  • POST {api_base_url}/pack/*ledger (binary fluree-pack-v1 stream)

The CLI fetches the remote NsRecord to learn the head CIDs and t values, then streams the pack response into the user’s writer, swapping the terminal End frame for a synthesized phase: "nameservice" manifest + End. The resulting .flpack is byte-compatible with a locally-generated archive — fluree create --from <file>.flpack doesn’t care which side produced it.

Auth: Both endpoints sit in the replication-grade bracket and require a Bearer token with fluree.storage.* permissions (same auth as fluree clone/pull). Without those permissions the server returns 404 Not Found for /storage/ns/:ledger-id to avoid existence leaks; the CLI surfaces this as not found: ledger '...' not found on remote '...'.

See Ledger portability below for the on-disk format and Replication Auth Contract for the auth semantics.

fluree query, fluree insert, fluree upsert, fluree update, fluree track, fluree info, fluree exists

  • POST {api_base_url}/query/*ledger
  • POST {api_base_url}/insert/*ledger
  • POST {api_base_url}/upsert/*ledger
  • POST {api_base_url}/update/*ledger
  • GET {api_base_url}/info/*ledger
  • GET {api_base_url}/exists/*ledger
  • POST {api_base_url}/stream/query/*ledgeronly when fluree query --format ndjson runs against this server; see Streaming Query Contract.

When the CLI is invoked with policy flags (--as, --policy-class, --policy, --policy-file, --policy-values, --policy-values-file, --default-allow), it carries them on every data API request via the headers listed below and, for JSON-LD bodies, also injects them into opts. To be CLI-compatible, your server must implement the contract in Policy Enforcement Contract.

Remote time travel (--at) routes through the ledger-scoped endpoints (POST /query/{ledger}, etc.): the URL path drives the bearer’s can_read check (so a token scoped to mydb:main matches), and the time-travel suffix rides in the body’s from (mydb:main@t:N for JSON-LD) or in an injected FROM <mydb:main@t:N> clause (for SPARQL). Posting to the connection-level endpoint instead would force auth to derive the ledger ID from from and reject scoped tokens.

Remote --at --explain flows through the same ledger-scoped path. The CLI injects the time-travel suffix into from (JSON-LD) or as a FROM <ledger@t:N> clause (SPARQL), then POSTs to POST /explain/{ledger}. The server’s explain handlers route those requests through a dataset-aware path so the request is processed against a view at the requested t. Note that Fluree maintains one set of index stats (latest), so explain plans for a given query text are largely independent of t — the value of --at --explain is in honoring the contract and consistency with the query path, not in producing materially different plans.

Remote Cypher queries (fluree query --cypher --remote) POST to the same ledger-scoped query endpoint with a Cypher content type:

POST {api_base_url}/query/{ledger}
Content-Type: application/cypher

MATCH (n:Person) RETURN n.name LIMIT 10
  • The server detects Cypher by content type (application/cypher or application/opencypher, FlureeHeaders::is_cypher_query) and, for reads, executes via execute_cypher_ledger. The body is sent verbatim — either raw Cypher text or a {cypher, params} envelope — and the server splits it with fluree_db_api::extract_cypher_envelope, exactly like the write path.
  • The response is a cypher-json document (application/vnd.fluree.cypher+json) — the Neo4j-compatible tabular shape.
  • Read auth and policy ride exactly as for the SPARQL/JSON-LD read paths: the URL path drives the bearer’s can_read(ledger) check, and the Policy Enforcement Contract headers apply. Fluree-Min-T read-your-writes freshness is honored against the path ledger.
  • Limitations over remote (the CLI errors with a pointer to --direct for local execution): the server renders cypher-json only — the other --format shapes (json/typed-json/csv/tsv) are built client-side on the local path and are not negotiated remotely — and the endpoints have no --at time-travel handling for Cypher. --bench and --track* are not supported for Cypher on any transport.

Remote Cypher explain (fluree query --cypher --explain --remote) POSTs to the ledger-scoped explain endpoint with the same Cypher content type:

POST {api_base_url}/explain/{ledger}
Content-Type: application/cypher

MATCH (n:Person {id: 7}) RETURN n
  • The body is sent verbatim — raw Cypher or a {cypher, params} envelope. The server extracts the envelope and substitutes $param references before lowering, so the reported plan matches what /query/{ledger} would execute.
  • The response is a JSON plan document ({"query": ..., "plan": ...}), the same shape as SPARQL/JSON-LD explain.
  • Only the ledger-scoped endpoint accepts Cypher; the connection-scoped POST /explain returns 400 for Cypher (no ledger to resolve).
  • Bearer ledger scope (can_read) and Fluree-Min-T apply as on the query path. --at is rejected for remote Cypher explain (use --direct).

fluree load (CSV → batched upserts), fluree update --format cypher

fluree load streams a local CSV into a ledger as a sequence of batched upserts. No new endpoint is required — every batch is an ordinary write to the existing ledger-scoped update endpoint:

  • POST {api_base_url}/update/{ledger}

The CLI holds the file: it reads and parses the CSV client-side, groups rows into batches (--batch-size, default 1000), and sends one request per batch, each committing independently. The server never receives the CSV, a file path, or a URL — only ordinary parameterized writes. There is no transactionality across batches; a mid-load failure leaves earlier batches committed.

The per-row template is either Cypher or JSON-LD, which selects the request content type:

Cypher template (--cypher, and fluree update --format cypher):

POST {api_base_url}/update/{ledger}
Content-Type: application/cypher

{ "cypher": "UNWIND $batch AS row\nMERGE (n:Person {id: row.id}) SET n.name = row.name",
  "params": { "batch": [ {"id":"1","name":"Alice"}, {"id":"2","name":"Bob"} ] } }
  • The server must detect Cypher by content type — the reference server matches application/cypher or application/opencypher (FlureeHeaders::is_cypher_query).
  • The body is the {cypher, params} envelope (the Neo4j-HTTP shape). A body that isn’t a JSON object with a cypher key is treated as raw Cypher text with no params, so plain-text Cypher also works. The reference server splits it with fluree_db_api::extract_cypher_envelope and executes via execute_cypher_transact.
  • Cypher writes must be ledger-scoped. The connection-scoped POST /update (no {ledger} in the path) rejects Cypher with 400 because it can’t resolve a target ledger — the CLI always targets the ledger-scoped route.
  • The $batch parameter and the UNWIND $batch AS row … wrapper are constructed entirely client-side; the server just substitutes $param references and runs the statement. Empty CSV cells arrive as JSON null.
  • A standalone fluree update --format cypher --remote <name> (a single statement, not a CSV load) POSTs to the same POST /update/{ledger} route. The CLI sends the body verbatim — raw Cypher or a {cypher, params} envelope — and the server splits it the same way. The response is the usual commit receipt, or a cypher-json document when the statement carries a RETURN. Policy flags are rejected for Cypher writes on both transports (--policy* has no enforcement path for Cypher writes yet); other write auth rides normally.

JSON-LD template (--jsonld):

POST {api_base_url}/update/{ledger}
Content-Type: application/json

{ "@context": {"ex": "http://example.org/"},
  "where":  {"@id": "?s", "ex:id": "?id"},
  "insert": {"@id": "?s", "ex:email": "?email"},
  "values": [ ["?id", "?email"], [ ["1","alice@ex.org"], ["2","bob@ex.org"] ] ] }
  • This is an ordinary JSON-LD update — identical to fluree update with a JSON-LD body. The CLI injects the batch as the update’s values clause, one ?<column> variable per CSV column; the template author references those variables in where / insert / delete. Empty CSV cells arrive as "" (the JSON-LD values parser rejects nulls).
  • No special handling beyond the existing JSON-LD update path.

Auth, policy, and tracking ride exactly as for fluree update above: normal write auth (can_write(ledger)), and the Policy Enforcement Contract headers (plus JSON-LD body opts for the JSON-LD template). A server that already implements POST /update/{ledger} for fluree update supports fluree load with no additional work.

fluree multi-query

  • POST {api_base_url}/multi-query

Bundles N JSON-LD and/or SPARQL sub-queries into a single envelope that the server runs in parallel against one resolved snapshot moment. The CLI reads the envelope JSON (file / stdin / -e inline) and POSTs it to the connection-scoped /multi-query endpoint — each sub-query declares its own from, so there is no ledger-scoped variant.

fluree multi-query resolves its transport in the same priority as fluree query:

  • --remote <name> — explicit; routes through the named remote’s configured base_url. OIDC token refresh is persisted back to config.toml after a successful round-trip (same code path fluree query --remote uses via [context::persist_refreshed_tokens]).
  • Auto-route to a locally running fluree server — used when --remote is omitted and server.meta.json reports a live pid; bypassed by --direct. No token persistence on this branch (the local server doesn’t require auth).
  • In-process local — when neither of the above applies (no remote, no running server, or --direct with no remote), the CLI calls Fluree::multi_query() directly against the storage tree configured for this .fluree/ directory. Same code path the server handler ultimately invokes; the only thing that changes is the boundary at which the request enters the api crate. No HTTP, no auth, no impersonation gate — the caller already has direct authority over the local storage.

Authentication uses the same MaybeCredential + MaybeDataBearer extractor stack as /query — Bearer tokens (JWT/JWS) and signed requests (JWS/VC) both work. Bearer ledger-scope is enforced on every distinct ledger referenced in the envelope: any out-of-scope ledger triggers a 404 on the whole envelope (existence-leak avoidance matching /query’s behavior), not a per-alias error.

Envelope-resident knobs replace some single-query CLI flags. Multi-query doesn’t take --at (use envelope-level asOf) or --track-* / --max-fuel (use envelope-level opts.meta and per-sub-query opts.max-fuel). It does accept the full --policy* flag bundle (--as, --policy-class, --policy, --policy-file, --policy-values, --policy-values-file, --default-allow) — the same surface fluree query exposes. The headers ride through the transport identically; each sub-query carries its own from, so policy applies per-ledger via the standard server-side policy path. See Multi-query envelope for the full envelope contract, response shape, merge rules, bounds, and current limitations (history queries rejected, envelope max-fuel rejected, response cap enforced at assembly, SPARQL policy parity gap).

Output formatting uses two independent CLI flags:

  • --format json|typed-json selects the per-alias result shape (server-side formatter applied to each alias’s entry inside results). Mirrors the --format flag on fluree query.
  • --normalize-arrays wraps single-valued JSON-LD properties in arrays. Composes with --format on JSON-LD aliases; on SPARQL aliases it is a no-op (SPARQL Results JSON has its own binding shape).
  • --output json|pretty|aliases controls how the CLI prints the response envelope on the terminal; it doesn’t affect alias results.

On the wire, --format / --normalize-arrays ride as Fluree-Output-Format / Fluree-Normalize-Arrays headers when going through --remote or auto-route; the in-process path wires them straight into the api crate’s MultiQueryBuilder::format(...). The server reads them with precedence Fluree-Output-Format > Fluree-Normalize-Arrays alone > Accept-header content negotiation. Unknown Fluree-Output-Format values return 400 Bad Request; Accept values that produce byte/string payloads (TSV / CSV / SPARQL XML / RDF XML) return 406 Not Acceptable when no explicit Fluree-Output-Format is set. --format typed-json is cross-language (applied to every alias); --format json (the default) keeps SPARQL aliases on SPARQL Results JSON. See Multi-query envelope → Output formatting for the full table.

fluree branch list (read-only)

  • GET {api_base_url}/branch/{ledger} — note singular branch, ledger is a greedy tail segment (*ledger in axum), so mydb and org/mydb both work.

Returns all non-retracted branches for the ledger. Same auth bracket as other read endpoints (GET /branch/*ledger enforces Bearer when data_auth.mode == required and can_read(ledger); returns 404 not 403 when the bearer cannot read it). See Branch List Contract.

fluree branch create --remote <name> (admin-protected)

  • POST {api_base_url}/branch with { ledger, branch, source? }

Same admin auth bracket as /create, /drop, /reindex. See Branch Create Contract.

fluree branch drop --remote <name> (admin-protected)

  • POST {api_base_url}/drop-branch with { ledger, branch }

Same admin auth bracket as /create, /drop, /reindex. See Branch Drop Contract.

fluree graph drop --remote <name> (admin-protected)

  • POST {api_base_url}/drop-graph with { ledger, graph }

Drops a single named graph from one branch of a ledger by transactionally retracting every triple currently asserted in it. History is preserved — queries as-of an earlier t still see the graph populated. The graph IRI remains registered so it can be re-populated by a later insert. Refuses the default graph and the system txn-meta / config graphs. Same admin auth bracket as /create, /drop, /reindex. See Drop Named Graph Contract.

fluree graph list (read-only)

  • GET {api_base_url}/info/*ledger

Lists the user-defined named graphs registered on the targeted branch by parsing the named-graphs section of the standard /info response. No new endpoint is required. The CLI hides the default graph and the system txn-meta / config graphs by default; --include-system surfaces them alongside user graphs. See Graph List Contract.

fluree branch rebase --remote <name> (admin-protected)

  • POST {api_base_url}/rebase with { ledger, branch, strategy? }

Same admin auth bracket as /create, /drop, /reindex. See Rebase Contract.

fluree branch merge --remote <name> (admin-protected)

  • POST {api_base_url}/merge with { ledger, source, target?, strategy? }

Same admin auth bracket as /create, /drop, /reindex. See Merge Contract.

fluree branch diff (read-only merge preview)

  • GET {api_base_url}/merge-preview/*ledger?source=&target=&max_commits=&max_conflict_keys=&include_conflicts=&include_changes=&max_changes=&changes_after_subject=

Returns the rich diff between two branches — ahead/behind commit summaries, common ancestor, conflict keys, fast-forward eligibility, and (opt-in) the aggregate netted change set — without mutating any nameservice or content-store state. See Merge Preview Contract for the full semantic and response-shape spec.

Using the CLI from external apps. Applications that shell out to the CLI (instead of calling the HTTP endpoint directly) get the same diff through fluree branch diff:

# Machine-readable: --json emits the raw preview (identical shape to the
# HTTP response body, including the `changes` object when requested)
fluree branch diff dev --changes --json --remote origin -l mydb

# Cheap stats-only probe (exact net counts, no payload)
fluree branch diff dev --stat --json --remote origin -l mydb

# Page a large diff: read changes.next_cursor from the previous output
fluree branch diff dev --changes --json --changes-after '<subject-iri>' \
  --remote origin -l mydb

The CLI resolves the same three modes everywhere: --remote <name> targets a configured remote server, tracked ledgers route through their tracking remote automatically, and plain local ledgers compute the preview in-process (no server required). In all modes --json output is the MergePreview JSON documented below, so an app can parse one shape regardless of where the ledger lives. Errors surface as a nonzero exit code with a message on stderr. Note the CLI cap convention: --max-changes 0 means unbounded (local mode only; over HTTP the server’s cap still applies) — stats-only mode is spelled --stat, which maps to max_changes=0 on the wire.

Policy Enforcement Contract

CLI policy flags ride on every data API request as both HTTP headers and (for JSON-LD bodies) body-level opts fields. Servers wanting full CLI parity must honor both transports and apply the root-impersonation gate described below.

Headers the CLI may send

HeaderCLI flagTypeNotes
fluree-identity--as <iri>stringIdentity IRI to execute as.
fluree-policy-class--policy-class <iri>string, repeatableSend one header per class, OR a single header with comma-separated IRIs. Both forms must accumulate into a single list.
fluree-policy--policy <json> / --policy-fileJSON stringInline JSON-LD policy document(s). Reject with 400 on parse failure.
fluree-policy-values--policy-values <json> / --policy-values-fileJSON object stringVariable bindings for parameterized policies (keys begin with ?$). Reject with 400 on parse failure or non-object value.
fluree-default-allow--default-allow"true" (presence-truthy)Permit access when no matching policy rules exist.

For JSON-LD requests (POST /query/*, POST /insert/*, POST /upsert/*, POST /update/* with Content-Type: application/json), the CLI also injects each field into the request body’s opts object using the same names (opts.identity, opts.policy-class as a JSON array, opts.policy, opts.policy-values as an object, opts.default-allow as a bool). Servers should treat header values as defaults that body values override.

For SPARQL requests (Content-Type: application/sparql-query, application/sparql-update), headers are the only transport — the SPARQL body has no opts block.

For POST /multi-query, the CLI does not inject policy fields into the envelope body — it sends headers only. The server folds the headers into the envelope’s top-level opts before validation (so envelope-level rejections like max-fuel apply to header-supplied values too), and the standard envelope → sub-query opts merge then carries them into every alias.

Per-language effect:

  • JSON-LD sub-queries consume the merged opts.identity / opts.policy-class / opts.policy / opts.policy-values / opts.default-allow via apply_auth_identity_to_opts and the regular connection-scoped JSON-LD dispatch path — same code path POST /query uses for single queries.
  • SPARQL sub-queries match the single-query connection-scoped SPARQL behaviour of POST /query with Content-Type: application/sparql-query and an inline FROM: bearer-scope reads apply, but identity threading via QueryConnectionOptions (opts.identity, opts.policy-class, etc.) is not currently consumed by that path. The headers still ride through the transport, and the envelope-level fold still happens, but the SPARQL dispatcher (query_from().sparql()) does not act on policy opts. This gap is the same one documented for connection-scoped SPARQL today. See Multi-query envelope → Limitations for the canonical list.

Required server behavior

  1. Build a PolicyContext from the merged opts (header defaults + body overrides) and apply it to every query and transaction execution path. Without policy fields the request runs under root (no enforcement). With any policy field, the policies must be enforced — including for unsigned bearer-only transactions, which historically bypassed enforcement.

  2. Force the bearer’s identity into opts.identity by default (the bearer is the authenticated principal; clients cannot spoof identity by setting opts.identity). The exception is the impersonation gate below.

  3. Implement the impersonation gate for JSON-LD opts.identity, opts.policy-class, opts.policy, and opts.policy-values, plus the fluree-identity header on SPARQL requests:

    • Resolve the bearer’s identity in the target ledger’s policy graph.
    • If the lookup returns “subject exists with no f:policyClass” (the FoundNoPolicies outcome — the bearer is unrestricted on this ledger), respect the client-supplied identity / policy fields.
    • If the lookup returns “subject has f:policyClass assignments” (FoundWithPolicies) or “subject not found” (NotFound), force the bearer identity into opts.identity and ignore the client-supplied policy fields — the request runs under the bearer’s own policies.
    • opts.default-allow is not an impersonation field — it only governs the absence of matching rules and should not trigger the gate’s lookup.
  4. Audit-log impersonations. When the gate honors a client-supplied identity, log at info level with the bearer, target, and ledger:

    policy impersonation: bearer=<bearer-id> target=<as-iri> ledger=<name>
    
  5. Set commit author to the impersonated identity for write operations. The original bearer is captured in the audit log; the commit’s author field tracks who the operation was executed as.

  6. In proxy/forwarding mode, defer the gate to the upstream server: forward the request as-is and let the upstream resolve the gate against its own ledger state.

Reference behavior

The Fluree reference server implements the gate via fluree_db_api::identity_has_no_policies(snapshot, overlay, t, identity_iri), which wraps the three-state IdentityLookupResult enum and returns true only for FoundNoPolicies. Source: fluree-db-api/src/policy_builder.rs. The route-level wiring (header merge, gate, force-override, audit log, PolicyContext construction) lives in fluree-db-server/src/routes/policy_auth.rs — useful as a concrete implementation reference if you’re porting the contract to another server.

Tracking Contract

CLI tracking flags (--track, --track-fuel, --track-time, --track-policy, --max-fuel) ride on every query request as HTTP headers. A server that implements this contract makes the same flags Just Work against the bundled Fluree server, a CLI-auto-routed local server, an explicit --remote, and any custom HTTP implementation.

Request headers

HeaderCLI flag(s)TypeNotes
fluree-track-meta--track"true" (presence-truthy)Shorthand: enable fuel + time + policy.
fluree-track-fuel--track-fuel (also implied by --max-fuel)"true"Report total fuel consumed.
fluree-track-time--track-time"true"Report query execution time.
fluree-track-policy--track-policy"true"Report per-policy executed/allowed counts, and whether policy governed the request.
fluree-max-fuel--max-fuel <N>decimal stringAbort with 400 (or equivalent) when fuel exceeds N. Implies fuel tracking.

The CLI only sends headers that map to enabled flags — a server should treat each header as independent. fluree-track-meta is a shorthand that the server may expand to all three; alternatively, when --track is set the CLI may collapse to the single fluree-track-meta header for cleaner wire format.

For JSON-LD requests, equivalent body opts exist (opts.meta, opts.max-fuel); the CLI prefers headers so a single transport works across JSON-LD and SPARQL. Servers should accept either.

Required server behavior

  1. Inspect the headers (and body opts) and build a tracker before executing the query. Tracker construction is per-request — never reuse one across requests.

  2. Enforce fluree-max-fuel strictly: abort the query as soon as accumulated fuel would exceed the limit and return an error response. The reference server returns 400 Bad Request with a body describing the limit and the amount used.

  3. Return a TrackedQueryResponse-shaped body when any tracking header is present:

    {
      "status": 200,
      "result": <the normal query result body>,
      "time": "12.34ms",
      "fuel": 1234.567,
      "policy": { "<policy-id>": { "executed": 3, "allowed": 2 } },
      "policy_enforcement": { "enforced": true, "denies_all_data": false }
    }
    

    Only include time, fuel, policy for metrics the client actually requested. policy_enforcement accompanies policy and is included only when the request ran under a non-root policy context: its absence is what tells a caller the request was unenforced, which the (then empty) policy map cannot. denies_all_data reports that the request’s policy configuration grants no view of the data — settled before execution and independent of the data the query reads and of the query itself. The result field carries whatever the untracked response body would have been (SPARQL JSON, JSON-LD, agent-json, etc.). For agent-json responses the server SHOULD return the bare agent-json envelope as the response body and surface the tally only via the response headers below, so agents see the same shape they always do.

  4. Echo the tally on response headers so callers that don’t parse the JSON body (e.g. delimited or binary formats) can still read them:

    Response headerSourceFormat
    x-fdb-fueltracker.fueldecimal string
    x-fdb-timetracker.timeduration string, e.g. "12.34ms"
    x-fdb-policytracker.policybase64-encoded JSON object
    x-fdb-policy-enforcementtracker.policy_enforcementJSON object (omitted when unenforced)

Reference behavior

The reference server’s per-route wiring lives in fluree-db-server/src/routes/query.rs (see the has_tracking() branch on the ledger-scoped and connection-scoped query handlers). The tracker implementation, micro-fuel internals (1 fuel = 1000 micro-fuel), and the TrackedQueryResponse / PolicyStats shapes are defined in fluree-db-core/src/tracking.rs. The full fuel cost ladder for queries and transactions is in docs/query/tracking-and-fuel.md.

Streaming Query Contract

fluree query --format ndjson (and --format ndjson --envelope) streams SELECT results incrementally instead of buffering a single JSON body. When the command runs against a server — explicitly with --remote, or via auto-route to a running local server — it issues:

POST {api_base_url}/stream/query/{ledger}
Content-Type: application/sparql-query   (SPARQL)   |   application/json   (JSON-LD)
Accept: application/x-ndjson
?default-context=true

and reads the response body as a newline-delimited stream of self-describing records. This endpoint is only required if you want --format ndjson to work against your server — every other output format (json, typed-json, table, csv, tsv) uses the buffered POST /query/*ledger path above. If you do not implement it, the CLI surfaces the server’s 404/405 as an error; there is no client-side fallback to the buffered endpoint.

The wire protocol (request shapes, the head/row/heartbeat/end/error record types, heartbeats, error codes, auth/policy/dataset behavior, and the connection-scoped POST /stream/query variant) is specified in full in Streaming query (NDJSON) — that document is the canonical contract for both the endpoint and its in-process equivalent. The points below are the CLI-specific obligations on top of it.

What the CLI sends and requires

  1. Ledger-scoped endpoint only. The CLI always targets POST /stream/query/{ledger} (never the connection-scoped POST /stream/query), so the path drives the bearer’s can_read check exactly like the buffered /query/{ledger} path.
  2. Content type by language. SPARQL bodies are sent as application/sparql-query; JSON-LD bodies as application/json. The server must detect the language the same way it does for /query.
  3. Accept: application/x-ndjson and a chunked / unbuffered response body. The server must flush records as they are produced (the whole point is incremental delivery); buffering the entire stream before the first byte defeats it but is not incorrect.
  4. Policy, tracking, and --at ride identically to /query. Policy flags travel as the Policy Enforcement Contract headers (and JSON-LD body opts); --max-fuel travels as the fluree-max-fuel header from the Tracking Contract; --at injects the time-travel suffix into from (JSON-LD) or a FROM <ledger@t:N> clause (SPARQL), just like buffered remote time travel. (On a local in-process ledger the CLI refuses --at / per-request --policy with --format ndjson; over the wire they are supported because the server routes them through its dataset path.)
  5. Terminal record is mandatory. The CLI treats the absence of a terminal end/error record as a truncated stream and exits non-zero. A server MUST emit exactly one terminal record, and never a row after it.
  6. Unsupported shapes return 4xx before the 200 stream commits. ASK, CONSTRUCT/DESCRIBE, selectOne, hydration, and history (to / SPARQL FROM … TO …) must be rejected — the CLI relies on the server to enforce this rather than pre-validating the query text.

How the CLI renders the stream

  • Bare mode (default): the CLI emits each row record’s inner binding object, one per line, and consumes head/heartbeat/terminal internally. The row body must be the same SPARQL-JSON binding-object shape used in the buffered /query SELECT response’s results.bindings entries.
  • Envelope mode (--envelope): the CLI prints every record line verbatim, so the bytes a custom server emits are surfaced directly to the user — keep them byte-identical to the protocol in Streaming query (NDJSON).

Reference implementation

ConcernCanonical location
HTTP routes (/stream/query, /stream/query/*ledger) + heartbeat assemblyfluree-db-server/src/routes/stream_query.rs
NDJSON producer (single-ledger + dataset)fluree-db-api/src/view/stream_query.rs (plan_stream_query / run_stream_query)
Record protocolfluree-db-api/src/format/ndjson_stream.rs
CLI client + consumerfluree-db-cli/src/remote_client.rs::stream_query_sparql / stream_query_jsonld, fluree-db-cli/src/commands/query_stream.rs

Validate compatibility by running fluree query --remote your-remote --format ndjson --envelope -e 'SELECT ?s WHERE { ?s ?p ?o } LIMIT 5' against your server and confirming a head record, one row per result, and a terminal end record arrive in order.

Merge Preview Contract

fluree branch diff issues a single read-only request:

GET {api_base_url}/merge-preview/{ledger}?source={source}&target={target}
   &max_commits={n}&max_conflict_keys={n}&include_conflicts={bool}
   &include_conflict_details={bool}&strategy={strategy}
   &include_changes={bool}&max_changes={n}&changes_after_subject={iri}
ParameterTypeRequiredServer defaultDescription
ledger (path)stringYesLedger name without branch suffix
sourcestringYesSource branch to merge from
targetstringNosource’s parent branchTarget branch to merge into
max_commitsintegerNo500Per-side cap on ahead.commits / behind.commits
max_conflict_keysintegerNo200Cap on conflicts.keys
include_conflictsboolNotrueWhen false, the conflict computation is skipped
include_conflict_detailsboolNofalseWhen true, include source/target flake values for the returned conflict keys
strategystringNotake-bothStrategy used for resolution labels in conflicts.details[].resolution; one of take-both, abort, take-source, take-branch
include_changesboolNofalseWhen true, include the aggregate netted change set as changes
max_changesintegerNo500Cap on changes.entries, counted in flakes, cut at subject boundaries. 0 = stats-only mode
changes_after_subjectstringNoPagination cursor (full subject IRI); requires include_changes=true

Auth follows the same pattern as GET /branch/*ledger (read-only): require a Bearer when data_auth.mode == required; gate on can_read(ledger); return 404 (not 403) when the bearer cannot read it.

Required semantics

These rules are not negotiable; the CLI and other clients depend on them:

  1. Source resolution. source must be a branch — its nameservice record must have source_branch != null. Otherwise respond 400 with a message containing "no source branch" so the CLI’s error matcher works.
  2. Target defaulting. When target is omitted, resolve to source.source_branch.
  3. Self-merge. If source == resolved_target, respond 400 with a message containing "itself".
  4. Cross-branch ancestor lookup. ancestor is the most recent common commit between source HEAD and target HEAD. The walk must be able to load commit envelopes from both branches’ namespaces — sibling branches off main must work. The reference implementation builds a union view that fans out through both BranchedContentStore ancestries; equivalents are fine.
  5. Fast-forward predicate. fast_forward = (ancestor.commit_id == target_head) when both heads exist; true when both heads are absent; false otherwise.
  6. Per-side walks. ahead.count is the total number of commits on source since ancestor.t (uncapped). ahead.commits is the same set, capped at max_commits, strictly newest-first by t. truncated = count > commits.len(). Same shape for behind.
  7. Conflict computation. When include_conflicts == true && !fast_forward and both heads exist:
    • Walk both deltas: (s, p, g) tuples touched on each side since ancestor.t.
    • conflicts.keys is the intersection.
    • Sort the intersection before truncatingHashSet::intersection order is unspecified, and stable ordering matters for paginated UIs. Lexicographic by (s, p, g) is fine; what matters is that two requests against the same state return the same prefix.
    • count is the unbounded intersection size; truncated = count > cap.
  8. Conflict details. When include_conflict_details == true, populate conflicts.details for the keys returned in conflicts.keys after truncation. Each detail includes key, source_values, target_values, and a resolution annotation for the requested strategy. The values are the current asserted values for that key at each branch HEAD; preview must not apply the strategy. Use the same resolved flake tuple shape as /show ([s, p, o, dt, op], optional metadata as a 6th item).
  9. No mutations. Implementations must not write to the nameservice, advance any HEAD, copy commits between namespaces, or update any cache that downstream operations depend on.
  10. Server-side cap is mandatory. Even if a client sends max_commits=10000000, clamp to a defensive limit. The reference server applies two layers: when no query param is present, it falls back to the recommended defaults (500 for commits, 200 for conflict keys); when a param is present, the server clamps the caller’s value with min(value, hard_max) where the reference hard maxes are 5_000 for commits and 5_000 for conflict keys (constants MERGE_PREVIEW_HARD_MAX_COMMITS and MERGE_PREVIEW_HARD_MAX_CONFLICT_KEYS in fluree-db-server/src/routes/ledger.rs). The CLI assumes the server enforces a cap, and unbounded responses must not be reachable over HTTP regardless of what the client requests.

Scope of the cap. This bounds the size of the returned lists and the per-summary load_commit_by_id reads (one full commit blob per summary). It does not bound the underlying divergence walk: count on each side reflects the unbounded divergence and is computed by walking every commit envelope between HEAD and the ancestor. Likewise, conflict computation walks the full per-side delta when include_conflicts=true. If you need to refuse expensive previews, add a separate operational guard before invoking the walk (for example, reject when target.t - ancestor.t exceeds some threshold) or document that clients should pass include_conflicts=false for a cheaper preview. 11. Aggregate change set. When include_changes == true, populate changes with the source side’s ancestor..source_head flakes netted per fact — full fact identity is (subject, predicate, object, datatype, graph, language tag, list index); a fact survives only when its oldest and newest in-range ops agree (net op = newest op), so create-then-delete and delete-then-restore churn never appears. The set is strategy-independent (raw source-vs-ancestor delta, before conflict resolution). assert_count / retract_count / subject_count are exact and unaffected by the cap. entries groups changes by subject, subjects ordered by full IRI (this ordering is the pagination contract); flakes use the same resolved tuple shape as conflict details. The max_changes cap counts flakes but cuts at subject boundaries — never split a subject across pages; a single subject larger than the cap is returned whole. max_changes=0 is stats-only (exact counts, empty entries, truncated=true when changes exist). When truncated by the cap, next_cursor is the last returned subject IRI; changes_after_subject resumes strictly after it. The reference server clamps max_changes with hard max 5_000 (PREVIEW_HARD_MAX_CHANGES). changes_after_subject without include_changes=true is a 400. The source-side commit replay is shared with the conflict walk when both are requested; each pagination page re-pays the replay cost.

Response (200 OK)

{
  "source": "feature-x",
  "target": "main",
  "ancestor": { "commit_id": "bafy...", "t": 5 },
  "ahead": {
    "count": 3,
    "commits": [
      {
        "t": 8,
        "commit_id": "bafy...",
        "time": "2026-04-25T12:00:00Z",
        "asserts": 2,
        "retracts": 0,
        "flake_count": 2,
        "message": null
      }
      // ... newest-first
    ],
    "truncated": false
  },
  "behind": { "count": 1, "commits": [], "truncated": false },
  "fast_forward": false,
  "mergeable": true,
  "conflicts": {
    "count": 1,
    "keys": [{ "s": [100, "alice"], "p": [100, "status"], "g": null }],
    "truncated": false,
    "strategy": "take-source",
    "details": [
      {
        "key": { "s": [100, "alice"], "p": [100, "status"], "g": null },
        "source_values": [["ex:alice", "ex:status", "active", "xsd:string", true]],
        "target_values": [["ex:alice", "ex:status", "archived", "xsd:string", true]],
        "resolution": {
          "source_action": "kept",
          "target_action": "retracted",
          "outcome": "source-wins"
        }
      }
    ]
  },
  // present iff include_changes=true
  "changes": {
    "assert_count": 2,
    "retract_count": 1,
    "subject_count": 1,
    "entries": [
      {
        "subject": "http://example.org/ns/alice",
        "asserts": [["ex:alice", "ex:status", "active", "xsd:string", true]],
        "retracts": [["ex:alice", "ex:status", "archived", "xsd:string", false]]
      }
    ],
    "truncated": false
    // "next_cursor": "<subject IRI>" — only when truncated by the cap
  }
}

ancestor is null only when both heads are absent. Each CommitSummary sets time to null for legacy commits without a timestamp; message is extracted from txn_meta when an entry with predicate f:message (Fluree DB system namespace, local name "message") and a string value is present. Other conventions are not recognized — return null.

ConflictKey encodes a (s, p, g) tuple. The wire shape mirrors fluree_db_core::ConflictKey:

{
  "s": [<namespace_code: u16>, "<local_name>"],
  "p": [<namespace_code: u16>, "<local_name>"],
  "g": [<namespace_code: u16>, "<local_name>"]   // or null for the default graph
}

Sids serialize as [ns_code, name] tuples. Changing the encoding will break the CLI.

When include_conflict_details=false, conflicts.details is omitted. When it is true, source_values and target_values are resolved flake tuples for the current asserted values in the same shape returned by GET /show/*ledger; resolution is a label only. mergeable is false when the chosen strategy would abort (currently strategy=abort with one or more conflicts). It is not full transaction validation for constraints that might fail during the real merge commit. mergeable=true does not guarantee a subsequent POST /merge will succeed; it only reflects the conflict/strategy interaction at preview time.

Error responses

StatusWhen
400Source has no parent (e.g., main); source == target; unknown strategy; unsupported strategy; include_conflict_details=true with include_conflicts=false; strategy=abort with include_conflicts=false. Body must include "no source branch" or "itself" for the first two cases so the CLI’s matcher works.
401Bearer required and absent/invalid.
404Ledger or branch does not exist; or the bearer cannot can_read.
5xxStorage / nameservice errors.

Reference implementation

ConcernCanonical location
HTTP route + authfluree-db-server/src/routes/ledger.rs::merge_preview
Orchestrationfluree-db-api/src/merge_preview.rs::merge_preview_with
Per-commit summary + DAG walkfluree-db-core/src/commit.rs::walk_commit_summaries
Common ancestor (dual-frontier BFS)fluree-db-core/src/commit.rs::find_common_ancestor
Delta-key computationfluree-db-novelty/src/delta.rs::compute_delta_keys

Validate compatibility by running fluree branch diff dev --target feature --remote your-remote --json against your server and diffing the response against output from the reference server on the same ledger state.

Branch List Contract

fluree branch list <ledger> --remote <name> issues:

GET {api_base_url}/branch/{ledger}

The path segment is singular branch (not branches) and uses axum’s greedy *ledger tail capture, so a ledger named org/mydb is matched by /branch/org/mydb. The endpoint takes no query parameters and no body.

Auth

Read-only. Requires a Bearer when data_auth.mode == required; gates on can_read(ledger); returns 404 (not 403) when the bearer cannot read it to avoid existence leaks. Admin tokens are NOT required.

Response (200 OK)

A JSON array of BranchInfo. Empty array when the ledger has no non-retracted branches.

[
  {
    "branch": "main",
    "ledger_id": "mydb:main",
    "t": 12,
    "source": null
  },
  {
    "branch": "feature-x",
    "ledger_id": "mydb:feature-x",
    "t": 15,
    "source": "main"
  }
]
FieldTypeNotes
branchstringBranch name.
ledger_idstringFull ledger:branch identifier.
tintegerCurrent commit t on this branch.
sourcestring | nullParent branch, or null for root branches like main. Omitted via skip_serializing_if = "Option::is_none" when null.

Error responses

StatusWhen
401Bearer required and absent/invalid.
404Ledger does not exist; or the bearer cannot can_read.
5xxStorage / nameservice errors.

Reference implementation

ConcernCanonical location
HTTP route + authfluree-db-server/src/routes/ledger.rs::list_branches
Response shapefluree-db-server/src/routes/ledger.rs::BranchInfo
Underlying APIfluree_db_api::Fluree::list_branches

Branch Create Contract

fluree branch create <name> --remote <name> issues:

POST {api_base_url}/branch
Content-Type: application/json

{
  "ledger": "mydb",
  "branch": "feature-x",
  "source": "main"
}

The body type mirrors fluree-db-server::routes::ledger::CreateBranchRequest.

FieldTypeRequiredDefaultDescription
ledgerstringYesLedger name without branch suffix.
branchstringYesNew branch name. Must pass validate_branch_name.
sourcestringNo"main"Parent branch to fork from. The source must already exist and have at least one commit.

Auth

Admin-protected. Same middleware as POST /create, POST /drop, POST /reindex, and POST /iceberg/map — registered through v1_admin_protected_routes in fluree-db-server/src/routes/mod.rs.

Response (201 Created)

{
  "ledger_id": "mydb:feature-x",
  "branch": "feature-x",
  "source": "main",
  "t": 12
}
FieldTypeNotes
ledger_idstringFull ledger:branch identifier of the new branch.
branchstringNew branch name (echoed).
sourcestringResolved parent branch. Empty string if the new record’s source_branch is unexpectedly null.
tintegerCommit t at the branch point (inherited from the source’s HEAD).

The CLI’s pretty-printer (print_branch_created in fluree-db-cli/src/commands/branch.rs) reads branch, source, t, and ledger_id from the response — keep all four populated.

Error responses

StatusWhen
400Invalid branch name (per validate_branch_name); malformed JSON body.
401 / 403Admin token required and absent/invalid (see admin-auth middleware).
404Source branch does not exist.
409A branch with this name already exists (ApiError::LedgerExists → 409).
5xxNameservice / storage / index-copy errors.

Reference implementation

ConcernCanonical location
HTTP route + authfluree-db-server/src/routes/ledger.rs::create_branch
Request / response shapesCreateBranchRequest, CreateBranchResponse (same file)
Underlying APIfluree_db_api::Fluree::create_branch (fluree-db-api/src/ledger/loading.rs)

Branch Drop Contract

fluree branch drop <name> --remote <name> issues:

POST {api_base_url}/drop-branch
Content-Type: application/json

{
  "ledger": "mydb",
  "branch": "feature-x"
}

Note the endpoint is /drop-branch (hyphenated) — separate from the ledger-level POST /drop endpoint.

FieldTypeRequiredDescription
ledgerstringYesLedger name without branch suffix.
branchstringYesBranch to drop. Cannot be the root branch (any branch with source_branch.is_none()). "main" carries no special meaning — it’s the default and so the root on most ledgers, but a ledger created with a different initial branch will refuse that branch instead, and a non-root branch named "main" is droppable. Use POST /drop to remove the whole ledger including its root.

Auth

Admin-protected (same bracket as /branch, /rebase, /merge, /create, /drop, /reindex).

Behavior

The reference server’s Fluree::drop_branch:

  1. Refuses the root branch with 400 (structural check on source_branch.is_none(), not a name comparison).
  2. If the branch is retracted already → returns status already_retracted.
  3. If the branch has children (branches > 0) → soft-retracts it (preserves storage so children can still resolve), returns deferred: true.
  4. If the branch is a leaf → cancels indexing, deletes all storage artifacts (commits, txns, index roots, leaves, branches, dicts, garbage records, config, context), purges the nameservice record, and cascades upward to any retracted ancestors that now have zero children.

Response (200 OK)

{
  "ledger_id": "mydb:feature-x",
  "status": "dropped",
  "deferred": false,
  "files_deleted": 14,
  "cascaded": ["mydb:retired-parent"],
  "warnings": []
}
FieldTypeNotes
ledger_idstringFull ledger:branch identifier of the dropped branch.
statusstring"dropped", "already_retracted", or "not_found".
deferredbooltrue when the branch was retracted but storage preserved (had children).
files_deletedintegerOmitted when 0.
cascadedstring[]Ancestor ledger_ids that were cascade-dropped because they were retracted with zero remaining children. Omitted when empty.
warningsstring[]Non-fatal warnings (e.g. partial artifact deletion). Omitted when empty.

The CLI’s print_branch_dropped reads ledger_id, deferred, files_deleted, cascaded, and warnings — populate them all.

Error responses

StatusWhen
400Attempting to drop the root branch (source_branch.is_none()); malformed JSON body.
401 / 403Admin token required and absent/invalid.
404Branch not found (the underlying lookup miss surfaces as ApiError::NotFound → 404).
5xxStorage / nameservice errors during purge.

Reference implementation

ConcernCanonical location
HTTP route + authfluree-db-server/src/routes/ledger.rs::drop_branch
Request / response shapesDropBranchRequest, DropBranchResponse (same file)
Underlying APIfluree_db_api::Fluree::drop_branch (fluree-db-api/src/admin.rs)
Report structfluree_db_api::BranchDropReport

Graph List Contract

fluree graph list --ledger <ledger> [--remote <name>] [--include-system] [--json] does not call a dedicated endpoint. It reuses:

GET {api_base_url}/info/*ledger

and parses the named-graphs array out of the response. Servers that already implement the /info Response Contract below are compatible with fluree graph list as long as they populate that array.

Auth

Read-only. Same auth bracket as GET /info/*ledger.

Required response field

The CLI requires info.ledger.named-graphs (or top-level named-graphs for older response shapes) to be a JSON array. Each entry must be a JSON object with at least:

FieldTypeNotes
iristringFull IRI of the named graph. Use "urn:default" for the default graph slot (g-id = 0) so the CLI can recognize it and filter it under --include-system.
g-idintegerStable graph identifier in the registry. 0 = default, 1 = urn:fluree:{ledger_id}#txn-meta, 2 = urn:fluree:{ledger_id}#config, >= 3 = user-defined graphs.
flakesintegerNumber of currently-asserted flakes in this graph at the response’s t. May be 0.
sizeintegerOn-disk size in bytes attributed to this graph in the binary index. May be 0.

Additional fields are allowed and ignored by the CLI. Returning a stable order is recommended for paginated UIs but not required by the CLI.

CLI filtering

The CLI’s table and --json output both apply the same filter:

  • By default, entries with g-id ∈ {0, 1, 2} or iri == "urn:default" or iri equal to the ledger’s txn-meta / config IRI are omitted.
  • --include-system shows all four kinds (default, txn-meta, config, user).

The table view computes a Kind column from g-id plus the well-known IRI helpers in fluree_db_core::graph_registry.

Error responses

StatusWhen
401Bearer required and absent/invalid (same as /info).
404Ledger does not exist.
5xxStorage / nameservice errors.

A response that omits named-graphs is treated by the CLI as an outdated server; it prints a clear “info response is missing named-graphs” usage error rather than silently showing an empty list.

Reference implementation

ConcernCanonical location
HTTP routefluree-db-server/src/routes/ledger.rs::info_ledger_tail
Payload assemblyfluree-db-api/src/ledger_info.rs::build_ledger_info (populates named-graphs)
CLI dispatcherfluree-db-cli/src/commands/graph.rs::run_list
System-graph constantsfluree_db_core::graph_registry::{DEFAULT_GRAPH_ID, TXN_META_GRAPH_ID, CONFIG_GRAPH_ID, txn_meta_graph_iri, config_graph_iri}

Drop Named Graph Contract

fluree graph drop <graph-iri> --ledger <ledger> --remote <name> issues:

POST {api_base_url}/drop-graph
Content-Type: application/json

{
  "ledger": "mydb:main",
  "graph": "urn:example:org/payroll"
}

Note the endpoint is /drop-graph (hyphenated) — separate from the ledger-level POST /drop and branch-level POST /drop-branch endpoints.

FieldTypeRequiredDescription
ledgerstringYesFull ledger identifier. A bare ledger name ("mydb") is normalized to "mydb:main". To target a branch other than the root, use the explicit "mydb:feature-x" form.
graphstringYesFull absolute IRI of the named graph to drop. Must have a valid <scheme>:<rest> head per RFC 3986 §3.1 and contain none of the characters RFC 3987 excludes from an IRI (whitespace, <, >, ", {, }, |, \, ^, `). Relative references (e.g. payroll) and leading/trailing whitespace are rejected — not trimmed.

Auth

Admin-protected (same bracket as /create, /drop, /drop-branch, /branch, /rebase, /merge, /reindex).

Behavior

The reference server’s Fluree::drop_named_graph:

  1. Normalizes the ledger id (:main default).
  2. Validates the graph IRI as an absolute IRI (<scheme>:<rest> with no whitespace or RFC 3987-excluded characters), then rejects the system graphs:
    • the default graph (empty / g_id == 0),
    • the txn-meta graph (urn:fluree:{ledger_id}#txn-meta, g_id == 1),
    • the config graph (urn:fluree:{ledger_id}#config, g_id == 2).
  3. Resolves graph against the snapshot’s GraphRegistry. An unknown IRI returns 404 rather than silently registering a new graph slot.
  4. Stages and commits a SPARQL UPDATE equivalent to DELETE { GRAPH <iri> { ?s ?p ?o } } WHERE { GRAPH <iri> { ?s ?p ?o } } through the same pipeline used by user updates. This produces one new commit at t = current_t + 1 whose flakes are retractions only.
  5. Reports a no-op (committed: false, retracted: 0) when the graph was already empty — no new commit is created.

Key properties:

  • History preserving. A query as-of an earlier t still sees every triple that was previously asserted in the graph.
  • Per-branch scope. Drops only affect the branch in ledger. Sibling branches that share the same graph IRI are not touched.
  • Registry stable. The graph IRI keeps its g_id; a subsequent insert into the same IRI lands in the same logical graph rather than a new slot.

Response (200 OK)

{
  "ledger_id": "mydb:main",
  "graph_iri": "urn:example:org/payroll",
  "retracted": 42,
  "committed": true,
  "t": 18
}
FieldTypeNotes
ledger_idstringNormalized ledger:branch identifier the drop targeted.
graph_iristringGraph IRI that was dropped (echoed).
retractedintegerNumber of flakes retracted by the drop commit. 0 when the graph was already empty.
committedbooltrue when a new commit was produced; false for a no-op drop on an empty graph.
tintegerCurrent commit t for the branch after the drop. Equal to the pre-drop t when committed is false.

The CLI’s fluree graph drop printer (commands/graph.rs) reads ledger_id, graph_iri, retracted, committed, and t — populate them all.

Error responses

StatusWhen
400graph is empty, has whitespace or any IRI-excluded character, lacks a <scheme>:<rest> head (relative reference), or names a system graph (default, txn-meta, config); malformed JSON body.
401 / 403Admin token required and absent/invalid.
404Ledger does not exist; or graph is not registered in the ledger’s graph registry.
5xxStorage / nameservice / commit-write errors during the retract commit.

Reference implementation

ConcernCanonical location
HTTP route + authfluree-db-server/src/routes/ledger.rs::drop_named_graph
Request / response shapesDropNamedGraphRequest, DropNamedGraphResponse (same file)
Underlying APIfluree_db_api::Fluree::drop_named_graph (fluree-db-api/src/admin.rs)
Absolute-IRI validatorvalidate_absolute_iri (same file)
Report structfluree_db_api::DropNamedGraphReport
Graph registryfluree_db_core::graph_registry (system graph constants and IRI helpers)

Rebase Contract

fluree branch rebase <branch> --remote <name> issues:

POST {api_base_url}/rebase
Content-Type: application/json

{
  "ledger": "mydb",
  "branch": "feature-x",
  "strategy": "take-both"
}
FieldTypeRequiredServer defaultDescription
ledgerstringYesLedger name without branch suffix.
branchstringYesBranch to rebase. Cannot be the root branch (source_branch.is_none()) — it has no parent to rebase onto. The check is structural, not name-based.
strategystringNo"take-both"One of take-both, abort, take-source, take-branch, skip. Parsed by ConflictStrategy::from_str_name; unknown values respond 400.

Auth

Admin-protected (same bracket as /branch, /drop-branch, /merge, /create, /drop, /reindex).

Behavior

Replays the branch’s unique commits on top of its source branch’s current HEAD, detecting and resolving conflicts according to strategy. The branch’s own source_branch (from its nameservice record) is the rebase target — there is no target field in the request.

  • If the branch is already up-to-date with its source (branch_head == ancestor), the operation is a fast-forward: the branch’s HEAD is advanced to the source HEAD with no replay, and fast_forward: true is returned.
  • If strategy == "abort" and any branch commit conflicts with the source delta, the rebase aborts up-front with 409 BranchConflict. No commits are written.
  • Otherwise, the branch’s commits are replayed sequentially on top of the source HEAD using the chosen strategy for conflict resolution.

Response (200 OK)

{
  "ledger_id": "mydb:feature-x",
  "branch": "feature-x",
  "fast_forward": false,
  "replayed": 3,
  "skipped": 0,
  "conflicts": 1,
  "failures": 0,
  "total_commits": 3,
  "source_head_t": 18
}
FieldTypeNotes
ledger_idstringFull ledger:branch identifier of the rebased branch.
branchstringBranch name (echoed).
fast_forwardbooltrue when the branch had no unique commits and was just advanced.
replayedintegerCommits successfully replayed onto source HEAD.
skippedintegerCommits skipped (e.g. via skip strategy on conflicts).
conflictsintegerTotal commits that contained conflicts. Note this is a count, not a list — the underlying RebaseReport carries Vec<RebaseConflict> and Vec<RebaseFailure>, but the HTTP response surfaces only the lengths.
failuresintegerCommits that failed to replay (transactional / validation errors).
total_commitsintegerTotal branch commits considered for replay.
source_head_tintegerSource branch HEAD t after rebase.

The CLI’s print_rebase_result reads fast_forward, branch, source_head_t, replayed, skipped, conflicts, and failures.

Error responses

StatusWhen
400Rebasing the root branch (no source_branch — surfaced as InvalidBranch); unknown / unsupported strategy; malformed JSON body.
401 / 403Admin token required and absent/invalid.
404Branch or its source not found.
409BranchConflict — currently raised when strategy=abort and any commit conflicts with the source delta.
5xxStorage / nameservice / index-build errors during replay.

Reference implementation

ConcernCanonical location
HTTP route + authfluree-db-server/src/routes/ledger.rs::rebase
Request / response shapesRebaseBranchRequest, RebaseBranchResponse (same file)
Underlying APIfluree_db_api::Fluree::rebase_branch (fluree-db-api/src/rebase.rs)
Report structfluree_db_api::RebaseReport
Strategy enumfluree_db_api::ConflictStrategy

Merge Contract

fluree branch merge <source> --remote <name> issues:

POST {api_base_url}/merge
Content-Type: application/json

{
  "ledger": "mydb",
  "source": "feature-x",
  "target": "main",
  "strategy": "take-both"
}
FieldTypeRequiredServer defaultDescription
ledgerstringYesLedger name without branch suffix.
sourcestringYesBranch to merge from. Must have at least one commit and a source_branch.
targetstringNosource.source_branchBranch to merge into. Defaults to the source’s parent branch. Must not equal source.
strategystringNo"take-both"One of take-both, abort, take-source, take-branch. Parsed by ConflictStrategy::from_str_name.

Auth

Admin-protected (same bracket as /branch, /drop-branch, /rebase, /create, /drop, /reindex).

Behavior

  • Computes the common ancestor between source HEAD and target HEAD using a BranchedContentStore so sibling branches off main work.
  • If target HEAD == ancestor, performs a fast-forward merge: copies the source’s unique commit blobs into the target’s namespace and advances the target HEAD. No conflict resolution runs. fast_forward: true is reported.
  • Otherwise, performs a general merge: stages the union of source and target deltas, resolves overlapping (s, p, g) keys via strategy, and writes a single new commit on the target. fast_forward: false is reported. If strategy == "abort" and conflicts exist, the merge fails with 409 BranchConflict and the target is rolled back to its pre-merge nameservice snapshot.

Response (200 OK)

{
  "ledger_id": "mydb:main",
  "target": "main",
  "source": "feature-x",
  "fast_forward": false,
  "new_head_t": 22,
  "commits_copied": 4,
  "conflict_count": 1,
  "strategy": "take-both"
}
FieldTypeNotes
ledger_idstringFull ledger:branch identifier of the target after merge.
targetstringResolved target branch (echoed; reflects the default if the request omitted it).
sourcestringSource branch name (echoed).
fast_forwardbooltrue for a fast-forward merge.
new_head_tintegerNew commit t of the target after merge.
commits_copiedintegerNumber of commit blobs copied into the target’s namespace. For fast-forward this equals the source’s unique commits; for general merge this includes the synthesized merge commit.
conflict_countintegerNumber of conflicts resolved. 0 for fast-forward.
strategystring | omittedStrategy used. Omitted (via skip_serializing_if) for fast-forward merges where strategy doesn’t apply.

The CLI’s print_merge_result reads source, target, new_head_t, commits_copied, fast_forward, and conflict_count.

Error responses

StatusWhen
400Source has no source_branch (a root branch like main cannot be the source); source == resolved_target; source has no commits; unknown / unsupported strategy; malformed JSON body.
401 / 403Admin token required and absent/invalid.
404Source or target branch not found.
409BranchConflict — currently raised when strategy=abort and conflicts exist.
5xxStorage / nameservice / commit-write errors.

Reference implementation

ConcernCanonical location
HTTP route + authfluree-db-server/src/routes/ledger.rs::merge
Request / response shapesMergeBranchRequest, MergeBranchResponse (same file)
Underlying APIfluree_db_api::Fluree::merge_branch (fluree-db-api/src/merge.rs)
Report structfluree_db_api::MergeReport
Strategy enumfluree_db_api::ConflictStrategy

Replication Auth Contract

Replication endpoints are intentionally protected more strictly than data reads:

  • Pack + commit export + storage proxy endpoints require a Bearer token with fluree.storage.* permissions.
  • Unauthorized requests should return 404 Not Found (no existence leak) for these endpoints.

Data API endpoints use normal read/transaction auth (fluree.ledger.read.*, fluree.ledger.write.*) and should return 401/403/404 as appropriate for your product.

Pack Protocol Contract

  • Endpoint: POST {api_base_url}/pack/*ledger
  • Request: JSON PackRequest with "protocol":"fluree-pack-v1". Includes include_indexes: bool (default true for clone/pull; false with --no-indexes), include_txns: bool (default true; false with --no-txns on clone), and optional want_index_root_id / have_index_root_id when the CLI requests index data.
  • Response: Content-Type: application/x-fluree-pack, streaming frames:
    • Preamble FPK1 + version byte
    • Header frame (mandatory, first)
    • Data frames: CID binary + raw object bytes
    • Optional Manifest frames (phase transitions)
    • End frame (mandatory termination)

Clients verify integrity:

  • Commit-v2 blobs (FCV2 magic): sub-range hash verification.
  • All other objects: full-bytes hash verification by CID.

Graceful fallback: If you do not implement pack yet, return 404 Not Found, 405 Method Not Allowed, 406 Not Acceptable, or 501 Not Implemented. The CLI treats those as “pack not supported” and falls back to GET /commits plus GET /storage/objects/:cid.

Ledger Import Contract

The inbound counterpart of the pack export endpoint. fluree create <ledger> --remote <name> --from <archive>.flpack streams a local .flpack archive to:

POST {api_base_url}/import/*ledger
Content-Type: application/x-fluree-pack
Authorization: Bearer <token>

<body = raw .flpack byte stream>

The server creates a new ledger named by the path tail and restores it wholesale from the archive — it does not require, and must not expect, the ledger to already exist. The path name is independent of whatever ledger the archive was exported from, so the same archive can be restored under any name.

Required server behavior

  1. Stream the body, do not buffer it whole — production archives can be many gigabytes. Decode fluree-pack-v1 frames incrementally (preamble → mandatory Header → Data/Manifest frames → mandatory End).
  2. Verify every object’s integrity before writing it to storage, using the same rules as the pack client: commit-v2 blobs (FCV2 magic) hash over their canonical sub-range; all other objects are full-bytes SHA-256 verified against the CID. Reject the archive on any mismatch.
  3. Finalize the heads from the embedded phase: "nameservice" manifest — set the commit head (commit_head_id / commit_t) and, when the archive carries index artifacts, the index head (index_head_id / index_t). Verify those head CIDs were actually present in the archive before pointing the nameservice at them, so a truncated or mismatched archive cannot produce a dangling head.
  4. Trust the archive byte-for-byte — unlike POST /push, do not replay or re-validate (no sequencing/policy/SHACL re-checks) and do not reindex; the prebuilt index rides along so the restored ledger is immediately queryable.
  5. Roll back on any failure — a partially-ingested ledger must not be left live. (The reference server soft-drops the just-created ledger.)
  6. Normalize a bare name (mydbmydb:main) consistently across ingest, head finalization, and the response.

Auth

Admin-protected — same bracket as /create, /drop, /reindex, /export. The body carries prebuilt index artifacts the server did not produce, so this is an admin-grade operation (not the fluree.storage.* replication bracket used by /pack).

Response (201 Created)

{
  "ledger_id": "restored-db:main",
  "commits": 12,
  "txn_blobs": 12,
  "index_artifacts": 34,   // 0 for a commits-only (--no-indexes) archive
  "commit_t": 12,
  "index_t": 12            // omitted when the archive carried no index
}

The CLI reads ledger_id, commits, txn_blobs, and index_artifacts for its success line.

Error responses

StatusWhen
201Ledger restored.
400Malformed archive: bad preamble/frame, missing Header/End, missing nameservice manifest, or a manifest head CID not present in the archive.
409A ledger with that name already exists.
401 / 403Admin token required and absent/invalid.
5xxStorage / nameservice errors during ingest or head finalization.

Graceful fallback: A server that does not implement import returns 404 / 405 / 501; the CLI surfaces this as a remote error. There is no automatic client-side fallback (unlike pack → commits), since wholesale restore has no per-object equivalent over the data API.

Size-capped servers: if your gateway caps request bodies below a typical archive, implement the Negotiated Upload Import Contract instead of (or in addition to) this direct endpoint, and advertise it in discovery — the CLI will upload out-of-band automatically.

Reference implementation

ConcernCanonical location
HTTP route + authfluree-db-server/src/routes/import.rs::import_ledger_tail
Streaming restore + head finalization + rollbackfluree-db-api/src/commit_transfer.rs::restore_ledger
CLI dispatch + streaming uploadfluree-db-cli/src/commands/create.rs::run_remote_flpack_import, fluree-db-cli/src/remote_client.rs::import_ledger
Archive export (producing the body)fluree-db-api/src/lib.rs::archive_ledger

Negotiated Upload Import Contract

For servers that cannot accept a large body on POST /import — e.g. an app on AWS Lambda behind API Gateway, where request payloads are capped (~6–10 MB) — the CLI supports an out-of-band upload handshake: the client uploads the .flpack directly to object storage (bypassing the size-capped request path), then notifies the server to restore from it. This is optional; implement it only if your gateway caps body size.

Negotiation (discovery)

Advertise import capabilities in the discovery document (GET /.well-known/fluree.json):

"import": {
  "modes": ["direct", "presigned-put", "multipart-put"],
  "direct_max_bytes": 6291456,
  "multipart_threshold_bytes": 5368709120,
  "multipart_part_size_bytes": 268435456
}
  • modes"direct" (streaming POST /import), "presigned-put" (single out-of-band PUT), and/or "multipart-put" (chunked out-of-band upload, below).
  • direct_max_bytes — the largest body you accept on POST /import. Only gates the direct-vs-negotiated choice.
  • multipart_threshold_bytes (informational) — the archive size at or above which you mint a multipart plan instead of a single PUT. A single S3 PUT caps at 5 GiB, so this should be ≤ 5 GiB.
  • multipart_part_size_bytes (informational) — the part size you intend to hand out. Must be ≥ 5 MiB for S3 (its minimum part size, except the last part).

CLI selection rule: use the negotiated flow when presigned-put or multipart-put is offered and either direct is not offered or the archive size exceeds direct_max_bytes; otherwise stream directly. A server omitting the import block is treated as direct-only (back-compatible).

Single-PUT vs multipart is the server’s call, not the client’s. The CLI sends its archive size in the mint request and reacts to whichever shape you return (upload for single PUT, multipart for parts). The threshold/part-size hints exist only so operators can see what the server will do; the client does not pre-decide.

The handshake

StepCallServer responsibility
1. MintPOST {api_base}/import-upload { "ledger": "<name>", "size"?: <bytes> }Allocate an upload slot. Return { import_id, upload: { method:"PUT", url, headers, expires_at_unix } } where url is a presigned object-store PUT (absolute) or a relative path the client resolves against the server origin. For size at or above your multipart threshold, return a multipart block instead — see Multipart upload.
2. UploadPUT <upload.url> (raw .flpack, streamed)Receive the bytes into staging (object storage). The URL is the capability — do not require the API bearer token on this request; authorize via the presigned signature (or a token embedded in the URL).
3. CompletePOST {api_base}/import-upload/{import_id}/completeVerify the upload exists, begin restore_ledger from it asynchronously, return { import_id, status:"running" } (202).
4. PollGET {api_base}/import-upload/{import_id}Return { status, result?, error? }, status ∈ {awaiting-upload, running, succeeded, failed}. On succeeded, result is the same summary as the direct import endpoint.

The CLI carries the bearer token on steps 1, 3, 4 (mint/complete/status are admin-grade) but not step 2.

Slot lifecycle / expiry. expires_at_unix is a contract, not a hint: an upload or complete against a slot past its expiry MUST be rejected, and the backend SHOULD reclaim the slot and any staged bytes once expired so abandoned uploads do not accumulate. The reference server uses a 1-hour TTL, enforces it on the upload/part/complete calls, and sweeps expired jobs (and their staged files) when the next slot is minted — except a slot whose restore is still running, which is spared until it finishes. A production backend typically delegates this to an object-store lifecycle rule on the staging prefix.

Multipart upload (archives over 5 GB)

A single S3 PUT rejects bodies over 5 GiB (EntityTooLarge). For larger archives, mint a multipart plan instead of a single PUT. Everything else (auth, async restore, polling) is identical; only steps 1–3 change shape.

Step 1 — multipart mint. When the declared size is at or above your multipart_threshold_bytes, return a multipart block instead of upload:

{
  "import_id": "imp_…",
  "ledger": "<name>",
  "multipart": {
    "upload_id": "…",            // your object-store UploadId (opaque to the client)
    "part_size_bytes": 268435456, // every part but the last is exactly this many bytes
    "parts": [
      { "part_number": 1, "url": "https://…UploadPart…partNumber=1…", "headers": { … } },
      { "part_number": 2, "url": "https://…UploadPart…partNumber=2…", "headers": { … } }
      // … one entry per part, part_number 1..=N, contiguous
    ],
    "expires_at_unix": 1750000000
  }
}

To produce this, the backend MUST:

  1. Call CreateMultipartUpload on your bucket/key and keep the returned UploadId.
  2. Choose a part_size such that ceil(size / part_size) ≤ 10000 (S3’s hard part-count ceiling) and part_size ≥ 5 MiB. The reference server starts from multipart_part_size_bytes and raises it if the count would exceed 10,000.
  3. Presign one UploadPart URL per part (partNumber = 1..=N, same UploadId), and return them in parts. You may presign all upfront (≈84 URLs for a 21 GB archive at 256 MiB parts) or fewer at a time — but the client expects the full list in the mint response.

Step 2 — part PUTs. The client PUTs each part’s byte range to its url with a fixed Content-Length (never chunked), no bearer token. It reads the ETag response header from each part PUT (S3 returns it) and remembers (part_number, etag). Parts upload with bounded concurrency and may arrive out of order.

Step 3 — complete with the part list. The client POSTs the assembled ETags to …/complete:

{ "parts": [ { "part_number": 1, "etag": "\"…\"" }, { "part_number": 2, "etag": "\"…\"" } ] }

On receipt the backend MUST:

  1. Verify the body lists exactly parts 1..=N (reject otherwise → 400).
  2. Call CompleteMultipartUpload with the UploadId and the { PartNumber, ETag } list (S3 validates the ETags and stitches the object).
  3. Then begin restore_ledger over the now-complete object asynchronously, exactly as the single-PUT path does.

The client sends the part list in complete; you own CompleteMultipartUpload. Don’t expect the client to call S3’s complete API — it only knows the presigned part URLs and reports back the ETags they returned. (The reference server doesn’t run S3 at all: it stages each part to a local file and concatenates them in part_number order on complete. Per-part ETag re-verification is unnecessary because restore_ledger SHA-256-verifies every archive frame regardless.)

A single-PUT complete carries an empty body; a multipart complete carries parts. Branch on the job’s recorded upload mode, not on body presence.

Required server behavior

  1. import_id and every upload URL must be unguessable — step 2 (single PUT or any part PUT) is authorized by possession of the URL alone.
  2. Stream step 2 to storage, never buffer the archive (or a whole part) in memory.
  3. Restore asynchronously in step 3 and report progress via step 4 — the restore can outlast a single request (and your function timeout). Apply the same trust/verify/rollback semantics as the Ledger Import Contract (every frame SHA-256 verified; roll back on failure).
  4. complete before any upload400. Unknown import_id404 on every step.
  5. Run the actual restore wherever it can take the time it needs (a worker, not necessarily the request handler).
  6. Multipart specifics: mint multipart when size ≥ multipart_threshold_bytes; pick a part size with ceil(size / part_size) ≤ 10000; on complete, require exactly parts 1..=N (else 400) and call CompleteMultipartUpload before restoring. Assemble/complete (for a 21 GB archive, ~84 parts) can itself outlast the request — do it in the same async worker as the restore, not the complete handler.

Auth

import-upload (mint), …/complete, and …/{import_id} (status) are admin-protected (same bracket as /create, /import). The blob/part PUTs (…/{import_id}/blob and …/{import_id}/part/{n} in the reference impl) are token-authorized via the URL, not admin auth.

Reference implementation

The Fluree server ships a reference backend (enable with FLUREE_IMPORT_PRESIGN_ENABLED=true) that stages uploads to local disk and points the upload URLs back at its own blob/part endpoints — so the full handshake (single-PUT and multipart) is exercised end-to-end without object storage. A production server mints real presigned object-store URLs instead and drives CreateMultipartUpload/CompleteMultipartUpload; the client flow is identical. Multipart knobs: FLUREE_IMPORT_MULTIPART_THRESHOLD_BYTES (default 5 GiB) and FLUREE_IMPORT_MULTIPART_PART_SIZE_BYTES (default 256 MiB).

ConcernCanonical location
Discovery import blockfluree-db-server/src/routes/admin.rs::discovery
Mint / blob / part / complete / status routesfluree-db-server/src/routes/import.rs
In-memory job registry (incl. MultipartPlan)fluree-db-server/src/import_jobs.rs
CLI negotiation + single/multipart upload + pollfluree-db-cli/src/commands/create.rs::run_remote_flpack_negotiated, fluree-db-cli/src/remote_client.rs

A production server fronting real object storage persists job state externally (DB / object tags) rather than in process, and mints presigned URLs against its bucket. The contract above is what the CLI depends on; the staging mechanism is the server’s choice.

Source uploads (server-side bulk import)

The same handshake optionally accepts raw source data — the formats fluree create --from ingests locally — and runs the chunked bulk-import pipeline on complete instead of a .flpack restore. This backs fluree create <ledger> --remote <name> --from data.ttl|dump.cypher|… (single files only; the CLI refuses directories with a pointer to publish/.flpack).

  • Discovery: add "source-upload" to import.modes and advertise import.source_formats (the reference server lists ttl/nt/nq/trig/jsonld/json/jsonl/ndjson — each optionally .gz/.zst — plus csv/cypher/cyp/cql). The CLI checks source-upload before attempting; absent it, --remote --from <non-flpack> errors with a .flpack fallback hint.
  • Mint: "source_kind": "source" (default "flpack") plus "filename" (its extension drives format detection; the staged object must keep it). CSV/Cypher uploads may also send "edge_properties": "annotated"|"plain"|"nary" and "base_iri"; the CLI forwards its corresponding create flags. Reject unsupported extensions, option values, and missing filenames with 400.
  • Complete: run the bulk-import pipeline over the staged file (converting CSV/Cypher to JSON-LD shards first) in the same async worker slot as a restore. On success, result is { kind: "bulk-import", ledger_id, t, flake_count, commit_head_id, root_id, index_t, has_annotations }.
  • Raft caveat: the pipeline publishes nameservice heads directly and repeatedly, outside any replicated write log — do not offer source-upload on consensus-replicated deployments (the reference server omits the mode and 400s the mint).

Canonical locations: conversion helpers fluree-db-api/src/import_source.rs; the complete branch fluree-db-server/src/routes/import.rs::run_source_import; CLI side fluree-db-cli/src/commands/create.rs::run_remote_source_import.

Storage Proxy Contract

These endpoints exist so a client can fetch bytes by CID without knowing storage layout:

  • GET {api_base_url}/storage/ns/:ledger-id returns NsRecord JSON with CID identity fields:
    • commit_head_id, commit_t, index_head_id, index_t, optional config_id
  • GET {api_base_url}/storage/objects/:cid?ledger=:ledger-id returns raw bytes for the CID after verifying integrity.

/storage/block is only required for query peers that need server-mediated index-leaf access.

Commit Log Contract

fluree log --remote issues a single read-only request:

GET {api_base_url}/log/{ledger}?limit={n}
ParameterTypeRequiredServer defaultDescription
ledger (path)stringYesLedger ID, including branch suffix (org/mydb and org/mydb:main both work via the greedy *ledger capture)
limitintegerNo100Number of summaries to return (newest-first by t). Server clamps to a hard maximum (reference: 5000).

Auth

Read-only. Requires a Bearer token when data_auth.mode == required; gates on can_read(ledger); returns 404 (not 403) when the bearer cannot read the ledger so it doesn’t leak existence. Admin tokens are NOT required.

Response (200 OK)

{
  "ledger_id": "mydb:main",
  "commits": [
    {
      "t": 12,
      "commit_id": "bafy...",
      "time": "2026-04-25T12:00:00Z",
      "asserts": 3,
      "retracts": 0,
      "flake_count": 3,
      "message": null
    }
    // ... newest-first by t
  ],
  "count": 12,
  "truncated": false
}
FieldTypeNotes
ledger_idstringLedger ID echoed from the request path.
commitsarrayPer-commit summaries, strictly newest-first by t, capped at the resolved limit.
countintegerTotal commits in the chain (uncapped). truncated == count > commits.len().
truncatedbooltrue when the chain is longer than the returned page.

Each commits[i] mirrors fluree_db_core::CommitSummary:

FieldTypeNotes
tintegerTransaction number.
commit_idstringContent ID (CID) of the commit blob.
timestring | nullISO-8601 commit time, or null for legacy commits without a timestamp.
assertsintegerAsserted flakes in this commit.
retractsintegerRetracted flakes.
flake_countintegerTotal flakes (asserts + retracts).
messagestring | nullExtracted from txn_meta when an f:message entry with a string value is present. Returns null otherwise.

Required semantics

  1. Branch-aware walk. The walk must load commit envelopes via a branch-aware content store (the reference server uses branched_content_store_for_record). Pre-fork commits live under the source branch’s namespace, so a flat per-branch store cannot reach them and the response would be incomplete.
  2. Newest-first ordering. commits is sorted strictly descending by t. The CLI prints in this order without re-sorting.
  3. Empty ledger. When the ledger exists but has no commits, return 200 OK with commits: [] and count: 0.
  4. Hard cap. Servers MUST enforce a hard maximum independent of the client’s limit (reference: 5000). The CLI assumes the server caps the response, and unbounded responses must not be reachable.

Error responses

StatusWhen
401Bearer required and absent/invalid.
404Ledger does not exist; or the bearer cannot can_read.
5xxStorage / nameservice errors during walk.

Reference implementation

ConcernCanonical location
HTTP route + authfluree-db-server/src/routes/log.rs::log_ledger_tail
Underlying APIfluree_db_api::Fluree::commit_log
Walk + summaryfluree_db_core::commit::walk_commit_summaries

RDF Export Contract

fluree export --remote issues:

POST {api_base_url}/export/{ledger}
Content-Type: application/json

{
  "format": "turtle",
  "all_graphs": false,
  "graph": "http://example.org/people",
  "context": { "ex": "http://example.org/" },
  "at": "t:42"
}
FieldTypeRequiredServer defaultDescription
formatstringNo"turtle"One of: turtle/ttl, ntriples/nt, nquads/n-quads, trig, jsonld/json-ld/json. Case-insensitive.
all_graphsboolNofalseExport every named graph as a dataset. Requires formattrig / nquads. Mutually exclusive with graph.
graphstringNoIRI of a single named graph to export. Mutually exclusive with all_graphs.
contextobjectNoledger defaultPrefix map for Turtle/TriG/JSON-LD output. Either a bare object ({ "ex": "..." }) or { "@context": {...} }. Falls back to the ledger’s stored default context when absent.
atstringNolatestTime spec — integer ("42"), ISO-8601 datetime ("2026-01-15T10:30:00Z"), or commit CID prefix ("bafy…"). Identical to the local --at flag.

An empty body is accepted and treated as all-default (Turtle export at HEAD).

Auth

Admin-protected. Same middleware as /create, /drop, /reindex, and the branch admin endpoints — registered through v1_admin_protected_routes in fluree-db-server/src/routes/mod.rs.

Export today does not apply per-flake policy filtering: it reads straight from the binary index. Putting it in the data-read bracket alongside /query and /show would be a bulk policy bypass for any bearer with can_read(ledger). Adding policy-filtered streaming export would let it move to read-auth in the future.

Response (200 OK)

The body is the raw RDF for the requested format. Content-Type reflects the chosen format:

FormatContent-Type
Turtletext/turtle; charset=utf-8
N-Triplesapplication/n-triples; charset=utf-8
N-Quadsapplication/n-quads; charset=utf-8
TriGapplication/trig; charset=utf-8
JSON-LDapplication/ld+json; charset=utf-8

The reference server today buffers the full export in memory before responding (simple, sufficient for moderate-size ledgers). Implementations are free to stream chunked bodies; clients MUST be prepared to read until EOF.

Required semantics

  1. Format validation. Reject unknown format strings with 400.
  2. Dataset/format coupling. When all_graphs == true, format must be trig or nquads; otherwise return 400 with a message that mentions the dataset format requirement (the local CLI surfaces the same error).
  3. Time spec parsing. Same rules as the merge-preview / show contracts: parse as integer first (t), then as ISO-8601 if it contains both - and :, else as a commit CID prefix.
  4. Graph IRI resolution. When graph is set, resolve via the ledger’s graph registry; an unknown IRI is a 400 (or 5xx if you treat it as a config error — the reference returns 400 via ApiError::Config).
  5. Index requirement. Export reads from the binary index. If the ledger has no index, the reference server surfaces ApiError::Config (“no binary index available for export (is the ledger indexed?)”), which the error mapper returns as 400 Bad Request. Document that shape if you implement equivalently — the CLI surfaces the message verbatim.

Error responses

StatusWhen
400Unknown format; conflicting all_graphs + graph; all_graphs with non-dataset format; unknown graph IRI; malformed JSON; ledger not indexed.
401 / 403Admin token required and absent/invalid.
404Ledger does not exist.
5xxStorage / nameservice / encoding errors during walk.

Reference implementation

ConcernCanonical location
HTTP route + authfluree-db-server/src/routes/export.rs::export_ledger_tail
Builderfluree_db_api::export_builder::ExportBuilder
Format encodersfluree_db_api::export

/create Contract

  • Endpoint: POST {api_base_url}/create
  • Request body: {"ledger": "mydb:main"}
  • Response (201 Created): {"ledger": "mydb:main", "t": 0}
  • Response (409 Conflict): ledger already exists

If no branch suffix is provided (e.g., "mydb"), the server MUST normalize to "mydb:main".

Used by fluree publish (which calls /create after /exists returns false) and by fluree create --remote <name> (empty-ledger creation on a remote server).

/reindex Contract

  • Endpoint: POST {api_base_url}/reindex
  • Auth: admin-protected (same middleware as /create, /drop).
  • Request body:
    {
      "ledger": "mydb:main",
      "opts": { }
    }
    
    opts is optional and reserved for future per-request overrides (e.g. indexer tuning). Servers MUST accept it and MAY ignore it — today the reference server always reindexes using its own configured indexer settings.
  • Response (200 OK):
    {
      "ledger_id": "mydb:main",
      "index_t": 42,
      "root_id": "fluree:index:sha256:...",
      "stats": {
        "flake_count": 0,
        "leaf_count": 0,
        "branch_count": 0,
        "total_bytes": 0
      }
    }
    
  • Response (4xx/5xx): standard ApiError envelope on failure (e.g. ledger not found).

The response shape mirrors fluree_db_api::ReindexResult — implementers should treat that Rust struct as the source of truth and add new fields only additively. Used by fluree reindex --remote <name> and by the CLI’s auto-routing when a local server is running.

/exists Response Contract

  • Endpoint: GET {api_base_url}/exists?ledger=mydb:main (or via fluree-ledger header)
  • Response (200 OK, always): {"ledger": "mydb:main", "exists": true|false}

MUST return 200 regardless of whether the ledger exists (the exists field carries the result). Should query the nameservice only — no ledger data loading.

/info Response Contract (CLI Minimum)

The CLI currently treats GET {api_base_url}/info/*ledger as an opaque JSON object, but it requires these fields:

  • t (integer): required for fluree clone and fluree pull preflight and for fluree push conflict checks.
  • commitId (string CID): required for fluree push when t > 0 so it can detect divergence.
  • ledger.named-graphs (array): required for fluree graph list. See the Graph List Contract for the per-entry schema (iri, g-id, flakes, size). A top-level named-graphs array is also accepted for older response shapes; new implementations should nest it under ledger.

Other fields are optional and may be used only for display.

Origin-Based Replication (LedgerConfig)

The CLI can do origin-based clone --origin and pull fallback without a named remote by fetching objects via:

  • GET {api_base_url}/storage/objects/:cid?ledger=:ledger-id

If your nameservice advertises config_id on the NsRecord, the CLI will attempt to fetch that LedgerConfig blob (by CID) and then use it to try additional origins.

Graph Source Endpoints (Iceberg, R2RML, BM25, etc.)

The CLI routes graph source operations through the server when one is running. This uses the same auto-routing mechanism as query/insert/etc.: the CLI checks for server.meta.json (written by fluree server start), verifies the PID is alive, and routes through http://{listen_addr}/v1/fluree. Users can bypass with --direct.

fluree list (includes graph sources)

  • GET {api_base_url}/ledgers

Returns a JSON array of both ledger records and graph source records. Retracted records are excluded.

Response fields (required for each entry):

FieldTypeDescription
namestringLedger or graph source name
branchstringBranch name (e.g., "main")
typestringOne of: "Ledger", "Iceberg", "R2RML", "BM25", "Vector", "Geo"
tintegercommit_t for ledgers, index_t for graph sources (0 if not indexed)

Example response:

[
  { "name": "mydb", "branch": "main", "type": "Ledger", "t": 5 },
  { "name": "warehouse-orders", "branch": "main", "type": "Iceberg", "t": 0 },
  { "name": "my-search", "branch": "main", "type": "BM25", "t": 5 }
]

The CLI shows a TYPE column only when the response contains non-Ledger entries.

Error responses: 500 on internal failure. Empty array [] when no records exist.

fluree info <name> (graph source fallback)

  • GET {api_base_url}/info/*name

Existing endpoint, extended with graph source fallback. Resolution order:

  1. Look up name as a ledger — if found, return the standard ledger info response (unchanged)
  2. Look up name as a graph source (append :main if no branch suffix) — if found, return the graph source response below
  3. Return 404 Not Found

Graph source response fields:

FieldTypeDescription
namestringGraph source name
branchstringBranch name
typestringSource type (e.g., "Iceberg")
graph_source_idstringCanonical ID (e.g., "warehouse-orders:main")
retractedbooleanWhether retracted
index_tintegerIndex watermark
index_idstring?Index ContentId (omitted if none)
dependenciesstring[]?Source ledger IDs (omitted if empty)
configobject?Parsed configuration JSON (omitted if empty/{})

Example:

{
  "name": "warehouse-orders",
  "branch": "main",
  "type": "Iceberg",
  "graph_source_id": "warehouse-orders:main",
  "retracted": false,
  "index_t": 0,
  "config": {
    "catalog": {
      "type": "rest",
      "uri": "https://polaris.example.com/api/catalog",
      "warehouse": "my-warehouse"
    },
    "table": "sales.orders",
    "io": {
      "vended_credentials": true,
      "s3_region": "us-east-1"
    }
  }
}

CLI detection: The CLI distinguishes graph source responses from ledger responses by checking for the graph_source_id field in the JSON.

fluree drop <name> (graph source fallback)

  • POST {api_base_url}/drop

Existing endpoint, extended with graph source fallback. Request body is unchanged: { "ledger": "<name>", "hard": true }.

Resolution order:

  1. Try dropping name as a ledger — if the drop report has status: "dropped" or status: "already_retracted", return that
  2. If the ledger drop report has status: "not_found", try dropping as a graph source (default branch "main")
  3. If both return not found, return the not-found response

Response: Same schema as ledger drop: { "ledger_id": "name:branch", "status": "dropped"|"already_retracted"|"not_found", "files_deleted": 23, "warnings": [...] }. files_deleted is omitted when zero and is currently omitted for graph-source fallback responses. For graph sources, ledger_id contains the graph source ID (e.g., "warehouse-orders:main").

fluree iceberg map (Iceberg graph source creation)

  • POST {api_base_url}/iceberg/map (admin-protected)

Creates an Iceberg graph source with an R2RML mapping that defines how table rows become RDF triples. This is a write operation and should be admin-protected (same middleware as /create and /drop).

Request body fields:

FieldTypeRequiredDescription
namestringYesGraph source name (no colons)
modestringNo"rest" (default) or "direct"
catalog_uristringREST modeREST catalog URI
tablestringNoTable identifier (namespace.table); required for REST mode if not specified in R2RML mapping
table_locationstringDirect modeS3 URI (s3://bucket/path/to/table)
r2rmlstringYesR2RML mapping source (storage address or path)
r2rml_typestringNoMapping media type (e.g., "text/turtle"); inferred from extension
branchstringNoBranch name (default: "main")
auth_bearerstringNoBearer token for REST catalog auth
oauth2_token_urlstringNoOAuth2 token endpoint
oauth2_client_idstringNoOAuth2 client ID
oauth2_client_secretstringNoOAuth2 client secret
warehousestringNoWarehouse identifier (REST mode)
no_vended_credentialsbooleanNoDisable vended credentials (default: false)
s3_regionstringNoS3 region override
s3_endpointstringNoS3 endpoint override (MinIO, LocalStack)
s3_path_stylebooleanNoUse path-style S3 URLs (default: false)

Validation rules:

  • name must not be empty or contain :
  • r2rml is required (defines how table rows become RDF triples)
  • REST mode requires catalog_uri; requires table unless specified in R2RML mapping’s rr:tableName
  • Direct mode requires table_location (must start with s3:// or s3a://)
  • OAuth2 fields must all be provided together (url + id + secret)

Example — REST catalog with R2RML:

{
  "name": "warehouse-orders",
  "mode": "rest",
  "catalog_uri": "https://polaris.example.com/api/catalog",
  "table": "sales.orders",
  "r2rml": "mappings/orders.ttl",
  "auth_bearer": "my-token",
  "warehouse": "my-warehouse"
}

Example — REST catalog (table inferred from R2RML rr:tableName):

{
  "name": "airlines",
  "mode": "rest",
  "catalog_uri": "https://polaris.example.com/api/catalog",
  "r2rml": "mappings/airlines.ttl",
  "auth_bearer": "my-token"
}

Example — Direct S3 (no catalog):

{
  "name": "execution-log",
  "mode": "direct",
  "table_location": "s3://bucket/warehouse/logs/execution_log",
  "r2rml": "mappings/execution_log.ttl",
  "s3_region": "us-east-1"
}

Response (201 Created):

FieldTypePresentDescription
graph_source_idstringAlwaysCreated ID (e.g., "warehouse-orders:main")
table_identifierstringAlwaysTable identifier or derived from location
catalog_uristringAlwaysCatalog URI or S3 location
connection_testedbooleanAlwaysWhether catalog connection was verified (always false for direct mode)
mapping_sourcestringAlwaysR2RML mapping source
triples_map_countintegerAlwaysNumber of TriplesMap definitions found
mapping_validatedbooleanAlwaysWhether mapping was parsed and compiled successfully

Error responses:

  • 400 Bad Request — validation failures (missing fields, invalid mode, bad table identifier)
  • 409 Conflict — graph source with this name already exists (if your nameservice enforces uniqueness)
  • 500 Internal Server Error — catalog connection failure, mapping load failure, nameservice write failure

Querying graph sources

Graph source queries work through normal query endpoints. No separate endpoint is needed, but the Rust API has an important distinction:

  • Use query_from() when the query body carries the dataset ("from" in JSON-LD, FROM / FROM NAMED in SPARQL), or when you are composing multiple sources.
  • Use graph(alias).query() for a single lazy query target that may be either a native ledger or a mapped graph source.
  • Do not use the raw materialized-snapshot path (fluree.db(&alias)fluree.query(&view, ...)) for graph source aliases.

Important: The unsupported path is specifically the raw GraphDb snapshot flow (fluree.db(&alias)fluree.query(&view, ...)). That API assumes you already loaded a native ledger snapshot. Graph source resolution happens in the lazy builder paths (graph().query() and query_from()), which wire in the R2RML provider and can fall back from “ledger not found” to “mapped graph source”.

Supported query paths:

#![allow(unused)]
fn main() {
// Connection-level — graph sources resolve transparently
// When compiled with the `iceberg` feature, query_from() automatically
// enables R2RML provider support via .with_r2rml().
f.query_from().sparql(sparql).execute_formatted().await
f.query_from().jsonld(&query_json).execute_formatted().await

// Single-target lazy query — works for ledgers and mapped graph sources
f.graph(alias).query().sparql(sparql).execute_formatted().await

// Ledger-scoped query that may reference graph sources in GRAPH patterns
f.graph(ledger_id).query().sparql(sparql).execute_formatted().await
}

Do NOT use:

#![allow(unused)]
fn main() {
// Raw materialized snapshot path — native ledgers only
let view = f.db(&alias).await?;
f.query(&view, query_input).await?  // ❌ No R2RML, no graph source resolution
}

Query patterns that reference graph sources:

Graph sources can be queried directly, just like ledgers:

  • POST {api_base_url}/query/execution-log:main with a SPARQL or JSON-LD query body

Via FROM / FROM NAMED clauses:

SELECT * FROM <execution-log:main> WHERE { ?s ?p ?o } LIMIT 10

Via GRAPH patterns (joining with ledger data):

SELECT ?name ?orderId ?total
FROM <mydb:main>
WHERE {
  ?customer schema:name ?name .
  ?customer ex:customerId ?custId .
  GRAPH <warehouse-orders:main> {
    ?order ex:customerId ?custId .
    ?order ex:orderId ?orderId .
    ?order ex:total ?total .
  }
}

How it works: When the iceberg feature is compiled, query_from() and graph().query() automatically call .with_r2rml(), which constructs a FlureeR2rmlProvider that can resolve graph source names to R2RML mappings and route triple patterns through the Iceberg scan engine. The NameService trait requires GraphSourceLookup (read-only graph source discovery), so graph source resolution is always available at the nameservice layer.

Known limitation: FROM <ledger>, <graph-source> with bare WHERE patterns (no GRAPH wrapper) — the graph source participates in the dataset but bare triple patterns only scan native indexes. Use explicit GRAPH <gs:main> { ... } for the graph source part in mixed-source queries.

Authentication

  • POST /iceberg/map and POST /drop are admin-protected (same middleware as /create)
  • GET /ledgers and GET /info/*name are read-only (same auth as other read endpoints)
  • POST /query/*ledger with graph source GRAPH patterns uses normal query auth

Ledger Portability (.flpack Files)

The CLI supports exporting and importing full native ledgers as .flpack files using the fluree-pack-v1 wire format. This enables ledger portability with or without a running server.

# Export a ledger (all commits + indexes + dictionaries)
fluree export mydb --format ledger -o mydb.flpack

# Import into a new LOCAL instance (can use a different ledger name)
fluree create imported-db --from mydb.flpack

# Restore directly onto a REMOTE server (streams to POST /import)
fluree create imported-db --remote prod --from mydb.flpack

The .flpack format is identical to the binary stream served by POST /pack/{ledger}, with the addition of a nameservice manifest frame that carries the metadata needed to reconstruct the nameservice record on import:

{
  "phase": "nameservice",
  "ledger_id": "original-name:main",
  "name": "original-name",
  "branch": "main",
  "commit_head_id": "bafybeig...commitHead",
  "commit_t": 42,
  "index_head_id": "bafybeig...indexRoot",
  "index_t": 40,
  "default_context_id": "bafybeig...contextBlob"
}

index_head_id / index_t appear only when the archive carries index artifacts; default_context_id appears only when the ledger has a stored default JSON-LD context — its blob is shipped as a data frame and the importer re-points the restored ledger’s config at it (so queries that omit an inline @context keep working).

Aliasing on import: The ledger name provided to fluree create determines the local storage path. The data itself is content-addressed (CIDs), so a ledger can be imported under any name. The ledger_id inside the index root binary is informational and does not affect CAS resolution.

Moving a ledger to a server: restore the archive straight onto the server in one step — no local staging instance:

# On source machine: export
fluree export mydb --format ledger -o mydb.flpack

# Restore directly onto the server (POST /import)
fluree remote add prod https://prod.example.com
fluree auth login --remote prod
fluree create mydb --remote prod --from mydb.flpack

Alternatively, stage locally first and then publish (useful when you also want a local working copy):

fluree create mydb --from mydb.flpack
fluree publish prod mydb

The difference: --remote --from restores a trusted snapshot wholesale (byte-for-byte, index included, no replay), while publish re-validates and re-pushes the local commit chain. Use import to materialize a ledger from an archive; use publish to push ongoing local work.

Quick Validation Script

From a clean project directory:

fluree init
fluree remote add origin http://localhost:8090
fluree auth login --remote origin --token @token.txt

# Ledger operations
fluree fetch origin
fluree clone origin mydb:main
fluree pull mydb:main
fluree push mydb:main

# Publish a local ledger to remote
fluree create local-db
fluree insert local-db -e '{"@id": "ex:test", "ex:val": 1}'
fluree publish origin local-db

# Export / import round-trip
fluree export mydb --format ledger -o mydb.flpack
fluree create imported --from mydb.flpack

# Iceberg operations (requires iceberg feature on server)
fluree iceberg map my-gs \
  --catalog-uri https://polaris.example.com/api/catalog \
  --r2rml mappings/orders.ttl \
  --auth-bearer $POLARIS_TOKEN

fluree list                    # should show mydb (Ledger) + my-gs (Iceberg)
fluree info my-gs              # should show Iceberg config + R2RML mapping
fluree show t:1 --remote origin  # should show decoded commit with resolved IRIs
fluree log mydb --remote origin --oneline  # should print the remote's commit chain newest-first
fluree export mydb --remote origin --format turtle > mydb-remote.ttl  # should write Turtle to disk
fluree context get mydb --remote origin  # should print the remote ledger's default context
fluree context set mydb --remote origin -e '{"ex": "http://example.org/"}'  # admin: replace context
fluree history http://example.org/alice --ledger mydb --remote origin --format json  # remote history
fluree query mydb 'SELECT * WHERE { ?s ?p ?o }' --remote origin --at 1  # time-travel via /query/{ledger}
fluree query mydb 'SELECT * WHERE { ?s ?p ?o }' --remote origin --at 1 --explain --format json  # time-travel explain via /explain/{ledger}
fluree create empty-db --remote origin  # should create an empty ledger on the remote
fluree export mydb --remote origin --format ledger -o mydb-remote.flpack  # archive remote ledger
fluree drop my-gs --force      # should drop the graph source locally
fluree drop local-db --remote origin --force  # should drop the published ledger on the remote