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, viaPOST /import).
The CLI prefers to be configured with a server origin URL (scheme/host/port) and then uses discovery:
GET /.well-known/fluree.jsonreturnsapi_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.
GET /.well-known/fluree.json
GET {api_base_url}/nameservice/snapshotPOST {api_base_url}/nameservice/refs/:ledger-id/commitPOST {api_base_url}/nameservice/refs/:ledger-id/indexPOST {api_base_url}/nameservice/refs/:ledger-id/init
Required:
GET {api_base_url}/info/*ledger(existence + remotetpreflight; see/infominimum fields below)GET {api_base_url}/storage/ns/:ledger-id(remote NsRecord, includescommit_head_id, optionalindex_head_id, and optionalconfig_id)POST {api_base_url}/pack/*ledger(binaryfluree-pack-v1stream)
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— whenfalse, skip index artifact frames.include_txns: bool— whenfalse, skip transaction blob frames. Commits are still streamed; the server must decode each commit's envelope and simply omit the referencedtxnblob from the stream. The emittedPackHeader.capabilitiesshould 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)
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 readscommit_head_id,index_head_id,commit_t/index_t(head freshness is checked per query),source_branch/branches(branched ledgers), and the optionalservingarray (["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.ledgersclaims 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
@sharednamespace, 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 theledgerparameter 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-endsupport on/storage/objects(206+Content-Range,416past-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/objectson 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, optionalendpoint/key_prefix) with credentials scoped to the requested ledger's storage prefix; the client auto-refreshes as grants approach expiry.
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 theSERVINGcolumn from the record'sservingarray; the column renders-when the field or record is unavailable, so this degrades gracefully.
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.
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 invalidcommitparameter404 Not Found— ledger or commit not found501 Not Implemented— proxy storage mode (no local index available for decoding)
POST {api_base_url}/createwith{"ledger": "<ledger>"}(empty ledger), orPOST {api_base_url}/import/*ledgerwith a raw.flpackbody (when--from <archive>.flpackis given — see Ledger Import Contract), or- the
/import-uploadhandshake 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/.
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.
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:alice → http://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.
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.
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.
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:
- CLI calls
GET /exists?ledger=mydb:main - If
exists: false, CLI callsPOST /createwith{"ledger": "mydb:main"} - If
exists: true, CLI callsGET /info/mydb:mainand rejects ift > 0(remote already has data) - CLI walks the full local commit chain (oldest → newest) and sends all commits via
POST /push/mydb:main - 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).
POST {api_base_url}/dropwith{"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.
- Local mode (default): no server endpoint required.
- Remote mode (
--remote <name>):POST {api_base_url}/import/*ledgerwith the raw.flpackbody — 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.
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(binaryfluree-pack-v1stream)
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.
POST {api_base_url}/query/*ledgerPOST {api_base_url}/insert/*ledgerPOST {api_base_url}/upsert/*ledgerPOST {api_base_url}/update/*ledgerGET {api_base_url}/info/*ledgerGET {api_base_url}/exists/*ledgerPOST {api_base_url}/stream/query/*ledger— only whenfluree query --format ndjsonruns 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.
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 configuredbase_url. OIDC token refresh is persisted back toconfig.tomlafter a successful round-trip (same code pathfluree query --remoteuses via [context::persist_refreshed_tokens]).- Auto-route to a locally running
fluree server— used when--remoteis omitted andserver.meta.jsonreports 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
--directwith no remote), the CLI callsFluree::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-jsonselects the per-alias result shape (server-side formatter applied to each alias's entry insideresults). Mirrors the--formatflag onfluree query.--normalize-arrayswraps single-valued JSON-LD properties in arrays. Composes with--formaton JSON-LD aliases; on SPARQL aliases it is a no-op (SPARQL Results JSON has its own binding shape).--output json|pretty|aliasescontrols 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.
GET {api_base_url}/branch/{ledger}— note singularbranch, ledger is a greedy tail segment (*ledgerin axum), somydbandorg/mydbboth 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.
POST {api_base_url}/branchwith{ ledger, branch, source? }
Same admin auth bracket as /create, /drop, /reindex. See
Branch Create Contract.
POST {api_base_url}/drop-branchwith{ ledger, branch }
Same admin auth bracket as /create, /drop, /reindex. See
Branch Drop Contract.
POST {api_base_url}/drop-graphwith{ 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.
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.
POST {api_base_url}/rebasewith{ ledger, branch, strategy? }
Same admin auth bracket as /create, /drop, /reindex. See
Rebase Contract.
POST {api_base_url}/mergewith{ ledger, source, target?, strategy? }
Same admin auth bracket as /create, /drop, /reindex. See
Merge Contract.
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 mydbThe 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.
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.
| Header | CLI flag | Type | Notes |
|---|---|---|---|
fluree-identity |
--as <iri> |
string | Identity IRI to execute as. |
fluree-policy-class |
--policy-class <iri> |
string, repeatable | Send 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-file |
JSON string | Inline JSON-LD policy document(s). Reject with 400 on parse failure. |
fluree-policy-values |
--policy-values <json> / --policy-values-file |
JSON object string | Variable 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-allowviaapply_auth_identity_to_optsand the regular connection-scoped JSON-LD dispatch path — same code pathPOST /queryuses for single queries. - SPARQL sub-queries match the single-query connection-scoped SPARQL behaviour of
POST /querywithContent-Type: application/sparql-queryand an inlineFROM: bearer-scope reads apply, but identity threading viaQueryConnectionOptions(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.
-
Build a
PolicyContextfrom 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. -
Force the bearer's identity into
opts.identityby default (the bearer is the authenticated principal; clients cannot spoof identity by settingopts.identity). The exception is the impersonation gate below. -
Implement the impersonation gate for JSON-LD
opts.identity,opts.policy-class,opts.policy, andopts.policy-values, plus thefluree-identityheader 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" (theFoundNoPoliciesoutcome — the bearer is unrestricted on this ledger), respect the client-supplied identity / policy fields. - If the lookup returns "subject has
f:policyClassassignments" (FoundWithPolicies) or "subject not found" (NotFound), force the bearer identity intoopts.identityand ignore the client-supplied policy fields — the request runs under the bearer's own policies. opts.default-allowis not an impersonation field — it only governs the absence of matching rules and should not trigger the gate's lookup.
-
Audit-log impersonations. When the gate honors a client-supplied identity, log at
infolevel with the bearer, target, and ledger:policy impersonation: bearer=<bearer-id> target=<as-iri> ledger=<name> -
Set commit
authorto 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. -
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.
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.
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.
| Header | CLI flag(s) | Type | Notes |
|---|---|---|---|
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. |
fluree-max-fuel |
--max-fuel <N> |
decimal string | Abort 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.
-
Inspect the headers (and body opts) and build a tracker before executing the query. Tracker construction is per-request — never reuse one across requests.
-
Enforce
fluree-max-fuelstrictly: abort the query as soon as accumulated fuel would exceed the limit and return an error response. The reference server returns400 Bad Requestwith a body describing the limit and the amount used. -
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 } } }Only include
time,fuel,policyfor metrics the client actually requested. Theresultfield 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. -
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 header Source Format x-fdb-fueltracker.fuel decimal string x-fdb-timetracker.time duration string, e.g. "12.34ms"x-fdb-policytracker.policy JSON object
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.
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.
- Ledger-scoped endpoint only. The CLI always targets
POST /stream/query/{ledger}(never the connection-scopedPOST /stream/query), so the path drives the bearer'scan_readcheck exactly like the buffered/query/{ledger}path. - Content type by language. SPARQL bodies are sent as
application/sparql-query; JSON-LD bodies asapplication/json. The server must detect the language the same way it does for/query. Accept: application/x-ndjsonand 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.- Policy, tracking, and
--atride identically to/query. Policy flags travel as the Policy Enforcement Contract headers (and JSON-LD bodyopts);--max-fueltravels as thefluree-max-fuelheader from the Tracking Contract;--atinjects the time-travel suffix intofrom(JSON-LD) or aFROM <ledger@t:N>clause (SPARQL), just like buffered remote time travel. (On a local in-process ledger the CLI refuses--at/ per-request--policywith--format ndjson; over the wire they are supported because the server routes them through its dataset path.) - Terminal record is mandatory. The CLI treats the absence of a terminal
end/errorrecord as a truncated stream and exits non-zero. A server MUST emit exactly one terminal record, and never arowafter it. - Unsupported shapes return
4xxbefore the200stream commits. ASK, CONSTRUCT/DESCRIBE,selectOne, hydration, and history (to/ SPARQLFROM … TO …) must be rejected — the CLI relies on the server to enforce this rather than pre-validating the query text.
- Bare mode (default): the CLI emits each
rowrecord's inner binding object, one per line, and consumeshead/heartbeat/terminal internally. Therowbody must be the same SPARQL-JSON binding-object shape used in the buffered/querySELECT response'sresults.bindingsentries. - 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).
| Concern | Canonical location |
|---|---|
HTTP routes (/stream/query, /stream/query/*ledger) + heartbeat assembly |
fluree-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 protocol | fluree-db-api/src/format/ndjson_stream.rs |
| CLI client + consumer | fluree-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.
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}
| Parameter | Type | Required | Server default | Description |
|---|---|---|---|---|
ledger (path) |
string | Yes | — | Ledger name without branch suffix |
source |
string | Yes | — | Source branch to merge from |
target |
string | No | source's parent branch | Target branch to merge into |
max_commits |
integer | No | 500 |
Per-side cap on ahead.commits / behind.commits |
max_conflict_keys |
integer | No | 200 |
Cap on conflicts.keys |
include_conflicts |
bool | No | true |
When false, the conflict computation is skipped |
include_conflict_details |
bool | No | false |
When true, include source/target flake values for the returned conflict keys |
strategy |
string | No | take-both |
Strategy used for resolution labels in conflicts.details[].resolution; one of take-both, abort, take-source, take-branch |
include_changes |
bool | No | false |
When true, include the aggregate netted change set as changes |
max_changes |
integer | No | 500 |
Cap on changes.entries, counted in flakes, cut at subject boundaries. 0 = stats-only mode |
changes_after_subject |
string | No | — | Pagination 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.
These rules are not negotiable; the CLI and other clients depend on them:
- Source resolution.
sourcemust be a branch — its nameservice record must havesource_branch != null. Otherwise respond400with a message containing"no source branch"so the CLI's error matcher works. - Target defaulting. When
targetis omitted, resolve tosource.source_branch. - Self-merge. If
source == resolved_target, respond400with a message containing"itself". - Cross-branch ancestor lookup.
ancestoris the most recent common commit betweensourceHEAD andtargetHEAD. The walk must be able to load commit envelopes from both branches' namespaces — sibling branches offmainmust work. The reference implementation builds a union view that fans out through bothBranchedContentStoreancestries; equivalents are fine. - Fast-forward predicate.
fast_forward = (ancestor.commit_id == target_head)when both heads exist;truewhen both heads are absent;falseotherwise. - Per-side walks.
ahead.countis the total number of commits onsourcesinceancestor.t(uncapped).ahead.commitsis the same set, capped atmax_commits, strictly newest-first byt.truncated = count > commits.len(). Same shape forbehind. - Conflict computation. When
include_conflicts == true && !fast_forwardand both heads exist:- Walk both deltas:
(s, p, g)tuples touched on each side sinceancestor.t. conflicts.keysis the intersection.- Sort the intersection before truncating —
HashSet::intersectionorder 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. countis the unbounded intersection size;truncated = count > cap.
- Walk both deltas:
- Conflict details. When
include_conflict_details == true, populateconflicts.detailsfor the keys returned inconflicts.keysafter truncation. Each detail includeskey,source_values,target_values, and aresolutionannotation for the requestedstrategy. 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). - 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.
- 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 (500for commits,200for conflict keys); when a param is present, the server clamps the caller's value withmin(value, hard_max)where the reference hard maxes are5_000for commits and5_000for conflict keys (constantsMERGE_PREVIEW_HARD_MAX_COMMITSandMERGE_PREVIEW_HARD_MAX_CONFLICT_KEYSinfluree-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.
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.
| Status | When |
|---|---|
400 |
Source 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. |
401 |
Bearer required and absent/invalid. |
404 |
Ledger or branch does not exist; or the bearer cannot can_read. |
5xx |
Storage / nameservice errors. |
| Concern | Canonical location |
|---|---|
| HTTP route + auth | fluree-db-server/src/routes/ledger.rs::merge_preview |
| Orchestration | fluree-db-api/src/merge_preview.rs::merge_preview_with |
| Per-commit summary + DAG walk | fluree-db-core/src/commit.rs::walk_commit_summaries |
| Common ancestor (dual-frontier BFS) | fluree-db-core/src/commit.rs::find_common_ancestor |
| Delta-key computation | fluree-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.
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.
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.
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"
}
]| Field | Type | Notes |
|---|---|---|
branch |
string | Branch name. |
ledger_id |
string | Full ledger:branch identifier. |
t |
integer | Current commit t on this branch. |
source |
string | null | Parent branch, or null for root branches like main. Omitted via skip_serializing_if = "Option::is_none" when null. |
| Status | When |
|---|---|
401 |
Bearer required and absent/invalid. |
404 |
Ledger does not exist; or the bearer cannot can_read. |
5xx |
Storage / nameservice errors. |
| Concern | Canonical location |
|---|---|
| HTTP route + auth | fluree-db-server/src/routes/ledger.rs::list_branches |
| Response shape | fluree-db-server/src/routes/ledger.rs::BranchInfo |
| Underlying API | fluree_db_api::Fluree::list_branches |
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.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
ledger |
string | Yes | — | Ledger name without branch suffix. |
branch |
string | Yes | — | New branch name. Must pass validate_branch_name. |
source |
string | No | "main" |
Parent branch to fork from. The source must already exist and have at least one commit. |
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.
{
"ledger_id": "mydb:feature-x",
"branch": "feature-x",
"source": "main",
"t": 12
}| Field | Type | Notes |
|---|---|---|
ledger_id |
string | Full ledger:branch identifier of the new branch. |
branch |
string | New branch name (echoed). |
source |
string | Resolved parent branch. Empty string if the new record's source_branch is unexpectedly null. |
t |
integer | Commit 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.
| Status | When |
|---|---|
400 |
Invalid branch name (per validate_branch_name); malformed JSON body. |
401 / 403 |
Admin token required and absent/invalid (see admin-auth middleware). |
404 |
Source branch does not exist. |
409 |
A branch with this name already exists (ApiError::LedgerExists → 409). |
5xx |
Nameservice / storage / index-copy errors. |
| Concern | Canonical location |
|---|---|
| HTTP route + auth | fluree-db-server/src/routes/ledger.rs::create_branch |
| Request / response shapes | CreateBranchRequest, CreateBranchResponse (same file) |
| Underlying API | fluree_db_api::Fluree::create_branch (fluree-db-api/src/ledger/loading.rs) |
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.
| Field | Type | Required | Description |
|---|---|---|---|
ledger |
string | Yes | Ledger name without branch suffix. |
branch |
string | Yes | Branch 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. |
Admin-protected (same bracket as /branch, /rebase, /merge,
/create, /drop, /reindex).
The reference server's Fluree::drop_branch:
- Refuses the root branch with
400(structural check onsource_branch.is_none(), not a name comparison). - If the branch is retracted already → returns status
already_retracted. - If the branch has children (
branches > 0) → soft-retracts it (preserves storage so children can still resolve), returnsdeferred: true. - 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.
{
"ledger_id": "mydb:feature-x",
"status": "dropped",
"deferred": false,
"files_deleted": 14,
"cascaded": ["mydb:retired-parent"],
"warnings": []
}| Field | Type | Notes |
|---|---|---|
ledger_id |
string | Full ledger:branch identifier of the dropped branch. |
status |
string | "dropped", "already_retracted", or "not_found". |
deferred |
bool | true when the branch was retracted but storage preserved (had children). |
files_deleted |
integer | Omitted when 0. |
cascaded |
string[] | Ancestor ledger_ids that were cascade-dropped because they were retracted with zero remaining children. Omitted when empty. |
warnings |
string[] | 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.
| Status | When |
|---|---|
400 |
Attempting to drop the root branch (source_branch.is_none()); malformed JSON body. |
401 / 403 |
Admin token required and absent/invalid. |
404 |
Branch not found (the underlying lookup miss surfaces as ApiError::NotFound → 404). |
5xx |
Storage / nameservice errors during purge. |
| Concern | Canonical location |
|---|---|
| HTTP route + auth | fluree-db-server/src/routes/ledger.rs::drop_branch |
| Request / response shapes | DropBranchRequest, DropBranchResponse (same file) |
| Underlying API | fluree_db_api::Fluree::drop_branch (fluree-db-api/src/admin.rs) |
| Report struct | fluree_db_api::BranchDropReport |
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.
Read-only. Same auth bracket as GET /info/*ledger.
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:
| Field | Type | Notes |
|---|---|---|
iri |
string | Full 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-id |
integer | Stable graph identifier in the registry. 0 = default, 1 = urn:fluree:{ledger_id}#txn-meta, 2 = urn:fluree:{ledger_id}#config, >= 3 = user-defined graphs. |
flakes |
integer | Number of currently-asserted flakes in this graph at the response's t. May be 0. |
size |
integer | On-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.
The CLI's table and --json output both apply the same filter:
- By default, entries with
g-id ∈ {0, 1, 2}oriri == "urn:default"oririequal to the ledger'stxn-meta/configIRI are omitted. --include-systemshows 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.
| Status | When |
|---|---|
401 |
Bearer required and absent/invalid (same as /info). |
404 |
Ledger does not exist. |
5xx |
Storage / 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.
| Concern | Canonical location |
|---|---|
| HTTP route | fluree-db-server/src/routes/ledger.rs::info_ledger_tail |
| Payload assembly | fluree-db-api/src/ledger_info.rs::build_ledger_info (populates named-graphs) |
| CLI dispatcher | fluree-db-cli/src/commands/graph.rs::run_list |
| System-graph constants | fluree_db_core::graph_registry::{DEFAULT_GRAPH_ID, TXN_META_GRAPH_ID, CONFIG_GRAPH_ID, txn_meta_graph_iri, config_graph_iri} |
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.
| Field | Type | Required | Description |
|---|---|---|---|
ledger |
string | Yes | Full 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. |
graph |
string | Yes | Full 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. |
Admin-protected (same bracket as /create, /drop, /drop-branch,
/branch, /rebase, /merge, /reindex).
The reference server's Fluree::drop_named_graph:
- Normalizes the ledger id (
:maindefault). - 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-metagraph (urn:fluree:{ledger_id}#txn-meta,g_id == 1), - the
configgraph (urn:fluree:{ledger_id}#config,g_id == 2).
- the default graph (empty /
- Resolves
graphagainst the snapshot'sGraphRegistry. An unknown IRI returns404rather than silently registering a new graph slot. - 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 att = current_t + 1whose flakes are retractions only. - 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-ofan earliertstill 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.
{
"ledger_id": "mydb:main",
"graph_iri": "urn:example:org/payroll",
"retracted": 42,
"committed": true,
"t": 18
}| Field | Type | Notes |
|---|---|---|
ledger_id |
string | Normalized ledger:branch identifier the drop targeted. |
graph_iri |
string | Graph IRI that was dropped (echoed). |
retracted |
integer | Number of flakes retracted by the drop commit. 0 when the graph was already empty. |
committed |
bool | true when a new commit was produced; false for a no-op drop on an empty graph. |
t |
integer | Current 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.
| Status | When |
|---|---|
400 |
graph 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 / 403 |
Admin token required and absent/invalid. |
404 |
Ledger does not exist; or graph is not registered in the ledger's graph registry. |
5xx |
Storage / nameservice / commit-write errors during the retract commit. |
| Concern | Canonical location |
|---|---|
| HTTP route + auth | fluree-db-server/src/routes/ledger.rs::drop_named_graph |
| Request / response shapes | DropNamedGraphRequest, DropNamedGraphResponse (same file) |
| Underlying API | fluree_db_api::Fluree::drop_named_graph (fluree-db-api/src/admin.rs) |
| Absolute-IRI validator | validate_absolute_iri (same file) |
| Report struct | fluree_db_api::DropNamedGraphReport |
| Graph registry | fluree_db_core::graph_registry (system graph constants and IRI helpers) |
fluree branch rebase <branch> --remote <name> issues:
POST {api_base_url}/rebase
Content-Type: application/json
{
"ledger": "mydb",
"branch": "feature-x",
"strategy": "take-both"
}
| Field | Type | Required | Server default | Description |
|---|---|---|---|---|
ledger |
string | Yes | — | Ledger name without branch suffix. |
branch |
string | Yes | — | Branch 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. |
strategy |
string | No | "take-both" |
One of take-both, abort, take-source, take-branch, skip. Parsed by ConflictStrategy::from_str_name; unknown values respond 400. |
Admin-protected (same bracket as /branch, /drop-branch, /merge,
/create, /drop, /reindex).
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, andfast_forward: trueis returned. - If
strategy == "abort"and any branch commit conflicts with the source delta, the rebase aborts up-front with409 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.
{
"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
}| Field | Type | Notes |
|---|---|---|
ledger_id |
string | Full ledger:branch identifier of the rebased branch. |
branch |
string | Branch name (echoed). |
fast_forward |
bool | true when the branch had no unique commits and was just advanced. |
replayed |
integer | Commits successfully replayed onto source HEAD. |
skipped |
integer | Commits skipped (e.g. via skip strategy on conflicts). |
conflicts |
integer | Total 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. |
failures |
integer | Commits that failed to replay (transactional / validation errors). |
total_commits |
integer | Total branch commits considered for replay. |
source_head_t |
integer | Source branch HEAD t after rebase. |
The CLI's print_rebase_result reads fast_forward, branch, source_head_t,
replayed, skipped, conflicts, and failures.
| Status | When |
|---|---|
400 |
Rebasing the root branch (no source_branch — surfaced as InvalidBranch); unknown / unsupported strategy; malformed JSON body. |
401 / 403 |
Admin token required and absent/invalid. |
404 |
Branch or its source not found. |
409 |
BranchConflict — currently raised when strategy=abort and any commit conflicts with the source delta. |
5xx |
Storage / nameservice / index-build errors during replay. |
| Concern | Canonical location |
|---|---|
| HTTP route + auth | fluree-db-server/src/routes/ledger.rs::rebase |
| Request / response shapes | RebaseBranchRequest, RebaseBranchResponse (same file) |
| Underlying API | fluree_db_api::Fluree::rebase_branch (fluree-db-api/src/rebase.rs) |
| Report struct | fluree_db_api::RebaseReport |
| Strategy enum | fluree_db_api::ConflictStrategy |
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"
}
| Field | Type | Required | Server default | Description |
|---|---|---|---|---|
ledger |
string | Yes | — | Ledger name without branch suffix. |
source |
string | Yes | — | Branch to merge from. Must have at least one commit and a source_branch. |
target |
string | No | source.source_branch |
Branch to merge into. Defaults to the source's parent branch. Must not equal source. |
strategy |
string | No | "take-both" |
One of take-both, abort, take-source, take-branch. Parsed by ConflictStrategy::from_str_name. |
Admin-protected (same bracket as /branch, /drop-branch, /rebase,
/create, /drop, /reindex).
- Computes the common ancestor between
sourceHEAD andtargetHEAD using aBranchedContentStoreso sibling branches offmainwork. - If
targetHEAD == 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: trueis reported. - Otherwise, performs a general merge: stages the union of source and
target deltas, resolves overlapping
(s, p, g)keys viastrategy, and writes a single new commit on the target.fast_forward: falseis reported. Ifstrategy == "abort"and conflicts exist, the merge fails with409 BranchConflictand the target is rolled back to its pre-merge nameservice snapshot.
{
"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"
}| Field | Type | Notes |
|---|---|---|
ledger_id |
string | Full ledger:branch identifier of the target after merge. |
target |
string | Resolved target branch (echoed; reflects the default if the request omitted it). |
source |
string | Source branch name (echoed). |
fast_forward |
bool | true for a fast-forward merge. |
new_head_t |
integer | New commit t of the target after merge. |
commits_copied |
integer | Number 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_count |
integer | Number of conflicts resolved. 0 for fast-forward. |
strategy |
string | omitted | Strategy 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.
| Status | When |
|---|---|
400 |
Source 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 / 403 |
Admin token required and absent/invalid. |
404 |
Source or target branch not found. |
409 |
BranchConflict — currently raised when strategy=abort and conflicts exist. |
5xx |
Storage / nameservice / commit-write errors. |
| Concern | Canonical location |
|---|---|
| HTTP route + auth | fluree-db-server/src/routes/ledger.rs::merge |
| Request / response shapes | MergeBranchRequest, MergeBranchResponse (same file) |
| Underlying API | fluree_db_api::Fluree::merge_branch (fluree-db-api/src/merge.rs) |
| Report struct | fluree_db_api::MergeReport |
| Strategy enum | fluree_db_api::ConflictStrategy |
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.
- Endpoint:
POST {api_base_url}/pack/*ledger - Request: JSON
PackRequestwith"protocol":"fluree-pack-v1". Includesinclude_indexes: bool(defaulttruefor clone/pull;falsewith--no-indexes),include_txns: bool(defaulttrue;falsewith--no-txnson clone), and optionalwant_index_root_id/have_index_root_idwhen 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)
- Preamble
Clients verify integrity:
- Commit-v2 blobs (
FCV2magic): 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.
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.
- Stream the body, do not buffer it whole — production archives can be many gigabytes. Decode
fluree-pack-v1frames incrementally (preamble → mandatory Header → Data/Manifest frames → mandatory End). - Verify every object's integrity before writing it to storage, using the same rules as the pack client: commit-v2 blobs (
FCV2magic) hash over their canonical sub-range; all other objects are full-bytes SHA-256 verified against the CID. Reject the archive on any mismatch. - 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. - 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. - Roll back on any failure — a partially-ingested ledger must not be left live. (The reference server soft-drops the just-created ledger.)
- Normalize a bare name (
mydb→mydb:main) consistently across ingest, head finalization, and the response.
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).
{
"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.
| Status | When |
|---|---|
201 |
Ledger restored. |
400 |
Malformed archive: bad preamble/frame, missing Header/End, missing nameservice manifest, or a manifest head CID not present in the archive. |
409 |
A ledger with that name already exists. |
401 / 403 |
Admin token required and absent/invalid. |
5xx |
Storage / 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.
| Concern | Canonical location |
|---|---|
| HTTP route + auth | fluree-db-server/src/routes/import.rs::import_ledger_tail |
| Streaming restore + head finalization + rollback | fluree-db-api/src/commit_transfer.rs::restore_ledger |
| CLI dispatch + streaming upload | fluree-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 |
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.
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"(streamingPOST /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 onPOST /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.
| Step | Call | Server responsibility |
|---|---|---|
| 1. Mint | POST {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. Upload | PUT <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. Complete | POST {api_base}/import-upload/{import_id}/complete |
Verify the upload exists, begin restore_ledger from it asynchronously, return { import_id, status:"running" } (202). |
| 4. Poll | GET {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.
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:
- Call
CreateMultipartUploadon your bucket/key and keep the returnedUploadId. - Choose a
part_sizesuch thatceil(size / part_size) ≤ 10000(S3's hard part-count ceiling) andpart_size ≥ 5 MiB. The reference server starts frommultipart_part_size_bytesand raises it if the count would exceed 10,000. - Presign one
UploadPartURL per part (partNumber = 1..=N, sameUploadId), and return them inparts. 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:
- Verify the body lists exactly parts
1..=N(reject otherwise →400). - Call
CompleteMultipartUploadwith theUploadIdand the{ PartNumber, ETag }list (S3 validates the ETags and stitches the object). - Then begin
restore_ledgerover the now-complete object asynchronously, exactly as the single-PUT path does.
The client sends the part list in
complete; you ownCompleteMultipartUpload. 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 inpart_numberorder oncomplete. Per-part ETag re-verification is unnecessary becauserestore_ledgerSHA-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.
import_idand every upload URL must be unguessable — step 2 (single PUT or any part PUT) is authorized by possession of the URL alone.- Stream step 2 to storage, never buffer the archive (or a whole part) in memory.
- 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).
completebefore any upload →400. Unknownimport_id→404on every step.- Run the actual restore wherever it can take the time it needs (a worker, not necessarily the request handler).
- Multipart specifics: mint multipart when
size ≥ multipart_threshold_bytes; pick a part size withceil(size / part_size) ≤ 10000; oncomplete, require exactly parts1..=N(else400) and callCompleteMultipartUploadbefore 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 thecompletehandler.
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.
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).
| Concern | Canonical location |
|---|---|
Discovery import block |
fluree-db-server/src/routes/admin.rs::discovery |
| Mint / blob / part / complete / status routes | fluree-db-server/src/routes/import.rs |
In-memory job registry (incl. MultipartPlan) |
fluree-db-server/src/import_jobs.rs |
| CLI negotiation + single/multipart upload + poll | fluree-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.
These endpoints exist so a client can fetch bytes by CID without knowing storage layout:
GET {api_base_url}/storage/ns/:ledger-idreturnsNsRecordJSON with CID identity fields:commit_head_id,commit_t,index_head_id,index_t, optionalconfig_id
GET {api_base_url}/storage/objects/:cid?ledger=:ledger-idreturns raw bytes for the CID after verifying integrity.
/storage/block is only required for query peers that need server-mediated index-leaf access.
fluree log --remote issues a single read-only request:
GET {api_base_url}/log/{ledger}?limit={n}
| Parameter | Type | Required | Server default | Description |
|---|---|---|---|---|
ledger (path) |
string | Yes | — | Ledger ID, including branch suffix (org/mydb and org/mydb:main both work via the greedy *ledger capture) |
limit |
integer | No | 100 |
Number of summaries to return (newest-first by t). Server clamps to a hard maximum (reference: 5000). |
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.
{
"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
}| Field | Type | Notes |
|---|---|---|
ledger_id |
string | Ledger ID echoed from the request path. |
commits |
array | Per-commit summaries, strictly newest-first by t, capped at the resolved limit. |
count |
integer | Total commits in the chain (uncapped). truncated == count > commits.len(). |
truncated |
bool | true when the chain is longer than the returned page. |
Each commits[i] mirrors fluree_db_core::CommitSummary:
| Field | Type | Notes |
|---|---|---|
t |
integer | Transaction number. |
commit_id |
string | Content ID (CID) of the commit blob. |
time |
string | null | ISO-8601 commit time, or null for legacy commits without a timestamp. |
asserts |
integer | Asserted flakes in this commit. |
retracts |
integer | Retracted flakes. |
flake_count |
integer | Total flakes (asserts + retracts). |
message |
string | null | Extracted from txn_meta when an f:message entry with a string value is present. Returns null otherwise. |
- 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. - Newest-first ordering.
commitsis sorted strictly descending byt. The CLI prints in this order without re-sorting. - Empty ledger. When the ledger exists but has no commits, return
200 OKwithcommits: []andcount: 0. - 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.
| Status | When |
|---|---|
401 |
Bearer required and absent/invalid. |
404 |
Ledger does not exist; or the bearer cannot can_read. |
5xx |
Storage / nameservice errors during walk. |
| Concern | Canonical location |
|---|---|
| HTTP route + auth | fluree-db-server/src/routes/log.rs::log_ledger_tail |
| Underlying API | fluree_db_api::Fluree::commit_log |
| Walk + summary | fluree_db_core::commit::walk_commit_summaries |
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"
}
| Field | Type | Required | Server default | Description |
|---|---|---|---|---|
format |
string | No | "turtle" |
One of: turtle/ttl, ntriples/nt, nquads/n-quads, trig, jsonld/json-ld/json. Case-insensitive. |
all_graphs |
bool | No | false |
Export every named graph as a dataset. Requires format ∈ trig / nquads. Mutually exclusive with graph. |
graph |
string | No | — | IRI of a single named graph to export. Mutually exclusive with all_graphs. |
context |
object | No | ledger default | Prefix 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. |
at |
string | No | latest | Time 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).
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.
The body is the raw RDF for the requested format. Content-Type reflects
the chosen format:
| Format | Content-Type |
|---|---|
| Turtle | text/turtle; charset=utf-8 |
| N-Triples | application/n-triples; charset=utf-8 |
| N-Quads | application/n-quads; charset=utf-8 |
| TriG | application/trig; charset=utf-8 |
| JSON-LD | application/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.
- Format validation. Reject unknown format strings with
400. - Dataset/format coupling. When
all_graphs == true,formatmust betrigornquads; otherwise return400with a message that mentions the dataset format requirement (the local CLI surfaces the same error). - 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. - Graph IRI resolution. When
graphis set, resolve via the ledger's graph registry; an unknown IRI is a400(or5xxif you treat it as a config error — the reference returns400viaApiError::Config). - 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 as400 Bad Request. Document that shape if you implement equivalently — the CLI surfaces the message verbatim.
| Status | When |
|---|---|
400 |
Unknown format; conflicting all_graphs + graph; all_graphs with non-dataset format; unknown graph IRI; malformed JSON; ledger not indexed. |
401 / 403 |
Admin token required and absent/invalid. |
404 |
Ledger does not exist. |
5xx |
Storage / nameservice / encoding errors during walk. |
| Concern | Canonical location |
|---|---|
| HTTP route + auth | fluree-db-server/src/routes/export.rs::export_ledger_tail |
| Builder | fluree_db_api::export_builder::ExportBuilder |
| Format encoders | fluree_db_api::export |
- 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).
- Endpoint:
POST {api_base_url}/reindex - Auth: admin-protected (same middleware as
/create,/drop). - Request body:
{ "ledger": "mydb:main", "opts": { } }optsis 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
ApiErrorenvelope 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.
- Endpoint:
GET {api_base_url}/exists?ledger=mydb:main(or viafluree-ledgerheader) - 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.
The CLI currently treats GET {api_base_url}/info/*ledger as an opaque JSON object, but it requires these fields:
t(integer): required forfluree cloneandfluree pullpreflight and forfluree pushconflict checks.commitId(string CID): required forfluree pushwhent > 0so it can detect divergence.ledger.named-graphs(array): required forfluree graph list. See the Graph List Contract for the per-entry schema (iri,g-id,flakes,size). A top-levelnamed-graphsarray is also accepted for older response shapes; new implementations should nest it underledger.
Other fields are optional and may be used only for display.
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.
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.
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):
| Field | Type | Description |
|---|---|---|
name |
string | Ledger or graph source name |
branch |
string | Branch name (e.g., "main") |
type |
string | One of: "Ledger", "Iceberg", "R2RML", "BM25", "Vector", "Geo" |
t |
integer | commit_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.
GET {api_base_url}/info/*name
Existing endpoint, extended with graph source fallback. Resolution order:
- Look up
nameas a ledger — if found, return the standard ledger info response (unchanged) - Look up
nameas a graph source (append:mainif no branch suffix) — if found, return the graph source response below - Return
404 Not Found
Graph source response fields:
| Field | Type | Description |
|---|---|---|
name |
string | Graph source name |
branch |
string | Branch name |
type |
string | Source type (e.g., "Iceberg") |
graph_source_id |
string | Canonical ID (e.g., "warehouse-orders:main") |
retracted |
boolean | Whether retracted |
index_t |
integer | Index watermark |
index_id |
string? | Index ContentId (omitted if none) |
dependencies |
string[]? | Source ledger IDs (omitted if empty) |
config |
object? | 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.
POST {api_base_url}/drop
Existing endpoint, extended with graph source fallback. Request body is unchanged: { "ledger": "<name>", "hard": true }.
Resolution order:
- Try dropping
nameas a ledger — if the drop report hasstatus: "dropped"orstatus: "already_retracted", return that - If the ledger drop report has
status: "not_found", try dropping as a graph source (default branch"main") - 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").
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:
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Graph source name (no colons) |
mode |
string | No | "rest" (default) or "direct" |
catalog_uri |
string | REST mode | REST catalog URI |
table |
string | No | Table identifier (namespace.table); required for REST mode if not specified in R2RML mapping |
table_location |
string | Direct mode | S3 URI (s3://bucket/path/to/table) |
r2rml |
string | Yes | R2RML mapping source (storage address or path) |
r2rml_type |
string | No | Mapping media type (e.g., "text/turtle"); inferred from extension |
branch |
string | No | Branch name (default: "main") |
auth_bearer |
string | No | Bearer token for REST catalog auth |
oauth2_token_url |
string | No | OAuth2 token endpoint |
oauth2_client_id |
string | No | OAuth2 client ID |
oauth2_client_secret |
string | No | OAuth2 client secret |
warehouse |
string | No | Warehouse identifier (REST mode) |
no_vended_credentials |
boolean | No | Disable vended credentials (default: false) |
s3_region |
string | No | S3 region override |
s3_endpoint |
string | No | S3 endpoint override (MinIO, LocalStack) |
s3_path_style |
boolean | No | Use path-style S3 URLs (default: false) |
Validation rules:
namemust not be empty or contain:r2rmlis required (defines how table rows become RDF triples)- REST mode requires
catalog_uri; requirestableunless specified in R2RML mapping'srr:tableName - Direct mode requires
table_location(must start withs3://ors3a://) - 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):
| Field | Type | Present | Description |
|---|---|---|---|
graph_source_id |
string | Always | Created ID (e.g., "warehouse-orders:main") |
table_identifier |
string | Always | Table identifier or derived from location |
catalog_uri |
string | Always | Catalog URI or S3 location |
connection_tested |
boolean | Always | Whether catalog connection was verified (always false for direct mode) |
mapping_source |
string | Always | R2RML mapping source |
triples_map_count |
integer | Always | Number of TriplesMap definitions found |
mapping_validated |
boolean | Always | Whether 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
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 NAMEDin 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
GraphDbsnapshot 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()andquery_from()), which wire in the R2RML provider and can fall back from "ledger not found" to "mapped graph source".
Supported query paths:
// 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().awaitDo NOT use:
// Raw materialized snapshot path — native ledgers only
let view = f.db(&alias).await?;
f.query(&view, query_input).await? // ❌ No R2RML, no graph source resolutionQuery patterns that reference graph sources:
Graph sources can be queried directly, just like ledgers:
POST {api_base_url}/query/execution-log:mainwith a SPARQL or JSON-LD query body
Via FROM / FROM NAMED clauses:
SELECT * FROM <execution-log:main> WHERE { ?s ?p ?o } LIMIT 10Via 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.
POST /iceberg/mapandPOST /dropare admin-protected (same middleware as/create)GET /ledgersandGET /info/*nameare read-only (same auth as other read endpoints)POST /query/*ledgerwith graph source GRAPH patterns uses normal query auth
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.flpackThe .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.flpackAlternatively, stage locally first and then publish (useful when you also want a local working copy):
fluree create mydb --from mydb.flpack
fluree publish prod mydbThe 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.
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
{ "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 } }