Skip to content

REST API

The `/v1` HTTP surface

The REST surface is an Axum server exposing records, the query DSL, generation, and administration under /v1, plus two unauthenticated probes. Every endpoint returns the uniform error envelope and stamps x-request-id.

Normative sources

core-engine/src/api/v1.rs (handlers and DTOs), core-engine/src/api/mod.rs (router, middleware, AppState), core-engine/src/api/auth.rs (credential resolution), and the generated document at GET /v1/openapi.json (pinned snapshot: core-engine/openapi/openapi.json).

Conventions

Convention Value
Base address (dev default) 127.0.0.1:7625
Auth header x-api-key: <key> (tenant-admin authority)
Alternate auth Authorization: Bearer <token> on the records/ingest, clarifications, and admin route groups only (see Auth by route group)
Request timeout 30 seconds (TimeoutLayer)
Body size limit 8 MiB (RequestBodyLimitLayer)
Request id Minted per request (UUIDv7), echoed as the x-request-id response header and as request_id in every error body
Error envelope {"code": "...", "message": "...", "request_id": "..."}, see Error reference
Timestamps Unix microseconds (integers) on wire fields; atValidTime/atTxTime-style query fields also accept RFC3339 strings

Handlers never read tenant identity from the request body or path, the Scoped extractor is the only source, and it is populated exclusively by the auth middleware from the presented credential.

Endpoint summary

Method Path Auth Summary
GET /healthz none Liveness probe
GET /readyz none Readiness probe
GET /v1/openapi.json none This OpenAPI document
GET /v1/whoami apiKey Echo the authenticated scope
POST /v1/query apiKey Run a query-DSL document
POST /v1/snapshot apiKey Query sugar with pinned temporal coordinates
POST /v1/compare apiKey Temporal delta between two coordinates
GET /v1/schema apiKey Inferred label schemas
POST /v1/generate apiKey Grounded, claim-verified generation
GET /v1/trace/{query_id} apiKey Read a generation trace
POST /v1/records apiKey | connectorToken Create (or upsert) a batch of records
GET /v1/records apiKey | connectorToken Resolve a connector externalId to its record
GET /v1/records/{id} apiKey | connectorToken Read the current version
PUT /v1/records/{id} apiKey | connectorToken Write a new full-snapshot version
DELETE /v1/records/{id} apiKey | connectorToken Tombstone (cascades to live edges)
GET /v1/records/{id}/history apiKey | connectorToken Paginated version history
POST /v1/ingest apiKey | connectorToken Ingest a source document
GET /v1/ingest/{job_id} apiKey | connectorToken Poll an ingestion job
POST /v1/relationships apiKey | connectorToken Create a typed edge
DELETE /v1/relationships/{id} apiKey | connectorToken Tombstone an edge
GET /v1/clarifications apiKey | clarifyToken List clarifications
GET /v1/clarifications/{id} apiKey | clarifyToken Read one clarification
POST /v1/clarifications/{id}/answer apiKey | clarifyToken Answer a clarification
POST /v1/person-aliases/verified-match clarifyToken only Attach a verified chat alias

Auth by route group

The router wires four distinct middleware groups (core-engine/src/api/mod.rs):

flowchart LR
    A[whoami / query / snapshot / compare / schema / generate / trace] -->|apiKey only| M1[require_api_key]
    B[records / ingest / relationships] -->|apiKey OR connector bearer| M2[require_api_key_or_connector_token]
    C[clarifications / person-aliases] -->|apiKey OR clarify bearer| M3[require_api_key_or_clarify_token]
    D["/admin/*"] -->|admin bearer only| M4[admin interceptor]
  • apiKey: the x-api-key header, resolved by SHA-256 lookup against {data_dir}/api_keys.json. Full tenant-admin authority.
  • connectorToken: Authorization: Bearer <token>, a "connector"-audience signed token (tenant-scoped, connector-named). Deletes are namespace-checked in the handler: a connector may only tombstone records whose source_connector property matches its own name (403 CONNECTOR_NAMESPACE_MISMATCH otherwise).
  • clarifyToken: Authorization: Bearer <token>, a "clarify"-audience signed token (the clarify-bot's answer path). /v1/person-aliases/verified-match accepts only this audience.
  • adminToken: a "admin"-audience signed token for the operator retention surface (/admin/*, documented in the Operators section, not here).

All three bearer schemes are signed with the same HMAC scheme described in gRPC API › Auth, keyed off grpc.token_key.

Probes

GET /healthz

Liveness only; does not touch storage.

curl -fsS http://127.0.0.1:7625/healthz
# ok

GET /readyz

Checks the engine handle can open a snapshot.

curl -fsS http://127.0.0.1:7625/readyz
# ok

GET /v1/whoami

Echoes the authenticated scope. Unauthenticated calls get 401 UNAUTHORIZED.

curl -fsS http://127.0.0.1:7625/v1/whoami -H "x-api-key: $KEY"
{ "tenant_id": "1111...", "organization_id": "2222...", "request_id": "..." }

Query, snapshot, compare, schema

POST /v1/query

Executes a query-DSL document. The request body is parsed raw (not through a typed DTO) so validation errors carry a JSON pointer.

{ "find": "RISK", "where": { "severity": { "$gte": 7 } }, "limit": 50 }
{
  "records": [
    {
      "id": "...", "labels": ["RISK"],
      "properties": { "severity": 7, "status": "open" },
      "validTime": { "start": 1750000000000000 },
      "txTime": { "start": 1750000000000000 },
      "tombstone": false,
      "score": 0.87
    }
  ],
  "related": [
    {
      "expandIndex": 0,
      "partial": false,
      "nodes": [
        { "id": "...", "labels": ["PERSON"], "properties": {}, "validTime": {}, "txTime": {}, "tombstone": false,
          "depth": 1, "viaEdge": "...", "decayScore": 0.9 }
      ]
    }
  ],
  "nextCursor": null,
  "traceId": null,
  "stats": {
    "path": "...", "bitmapCandidates": 0, "rowsScanned": 12,
    "rowsFilteredOut": 2, "typeMismatches": 0, "rowsReturned": 10, "durationUs": 340
  }
}

score on a record or related node appears only when the query carried a vector clause. Records whose winning version is linked to an unanswered clarification also carry a pendingClarification object (id, state, kind, severity, candidates, confidences) so a guessed value is never presented as confirmed fact.

Status Code Cause
200 , Results, possibly empty
400 QUERY_INVALID Grammar violation; message carries a JSON pointer
400 BAD_CURSOR Cursor invalid, foreign-tenant, or plan mismatch
401 UNAUTHORIZED Missing/invalid API key
501 UNIMPLEMENTED e.g. vector.text without a configured embedding endpoint

POST /v1/snapshot

Query sugar: pins atValidTime (required) and optionally atTxTime, find, where, limit, cursor, and re-dispatches through the same executor as /v1/query (identical response shape).

{ "find": "RISK", "atValidTime": "2026-07-01T00:00:00Z" }
curl -fsS -X POST http://127.0.0.1:7625/v1/snapshot \
  -H "x-api-key: $KEY" -H "Content-Type: application/json" \
  -d '{"find": "RISK", "atValidTime": "2026-07-01T00:00:00Z"}'
Status Code Cause
200 , Results at the pinned coordinates
400 QUERY_INVALID Grammar violation, JSON pointer included
401 UNAUTHORIZED Missing/invalid API key

POST /v1/compare

Computes what changed between two bitemporal coordinates: added, removed (tagged tombstone or validityLapse), and modified (per-property old → new). Body is parsed raw, same pointered-error posture as /v1/query.

{
  "baseline": { "validTime": "2026-01-01T00:00:00Z" },
  "comparison": { "validTime": "2026-04-01T00:00:00Z" },
  "find": "RISK",
  "where": { "severity": { "$gte": 7 } },
  "limit": 100
}
{
  "added": [{ "kind": "node", "id": "...", "labels": ["RISK"], "properties": { "severity": 8 } }],
  "removed": [{ "kind": "node", "id": "...", "labels": ["RISK"], "reason": "tombstone" }],
  "modified": [
    {
      "kind": "node", "id": "...", "labels": ["RISK"],
      "versions": {
        "baseline": { "validTimeStart": 1, "txTimeStart": 1 },
        "comparison": { "validTimeStart": 2, "txTimeStart": 2 }
      },
      "props": { "added": {}, "removed": {}, "changed": [{ "key": "severity", "old": 5, "new": 8 }] },
      "labelsAdded": [], "labelsRemoved": [], "touch": false
    }
  ],
  "stats": { "added": 1, "removed": 1, "modified": 1, "unchanged": 4 },
  "nextCursor": null
}

Request fields:

Field Type Required Notes
baseline.validTime µs or RFC3339 yes World time B
baseline.txTime µs or RFC3339 no "As known at"; default now
comparison.validTime µs or RFC3339 yes World time C
comparison.txTime µs or RFC3339 no Default now
find string no Label scope. With a filter set, edge deltas are skipped (stats.edgesSkippedByFilter)
where object no Applies to the comparison-side selection only; swap coordinates and invert added/removed to filter the baseline side
limit integer no 1..=1000, default 100
cursor string no Continuation from a previous page

removed[].reason is "tombstone" (winning version at C is a tombstone) or "validityLapse" (no version contains C on the valid-time axis). modified[].touch marks a re-versioned entity with identical content (spec OQ-1: visible but distinguishable).

Status Code Cause
200 , The delta page
400 COMPARE_INVALID Grammar violation; JSON pointer included
400 BAD_CURSOR Invalid cursor
401 UNAUTHORIZED Missing/invalid API key

GET /v1/schema

Inferred schema per label: property names, observed types, counts.

curl -fsS "http://127.0.0.1:7625/v1/schema?label=RISK" -H "x-api-key: $KEY"
{ "schemas": [ { "label": "RISK", "properties": { "severity": { "type": "Int", "count": 42 } } } ] }
Query param Type Notes
at µs integer Valid-time coordinate; default latest
label string Restrict to one label; omit for all

Records

POST /v1/records

Creates (or, with id + upsert, idempotently upserts) a batch of records, optionally with inline relationships.

{
  "records": [
    {
      "labels": ["RISK"],
      "properties": { "title": "Supplier delay", "severity": 7 },
      "relationships": [{ "type": "OWNED_BY", "node": "3333...-...", "direction": "out" }]
    }
  ]
}
{
  "records": [
    {
      "id": "...", "labels": ["RISK"],
      "properties": { "title": "Supplier delay", "severity": 7 },
      "validTime": { "start": 1750000000000000 },
      "txTime": { "start": 1750000000000000 },
      "tombstone": false,
      "relationships": ["<edge-id>"]
    }
  ]
}

Per-record fields:

Field Type Notes
labels array of string Required
id UUID Optional client-supplied logical id. Nil → 400 INVALID_ID; existing version under the id → 409 ALREADY_EXISTS (unless upsert)
upsert boolean Requires id. Absent → created; identical → matched with no write; different → new version; tombstoned → never resurrected (409 TOMBSTONED)
properties object Values null/bool/number/string/array/object. A top-level {"$bytes": "<base64>"} value becomes raw bytes (field-encryption envelopes)
validTime {start, end?} (µs) Omitted end = open-ended
sourceRecordTime µs integer Provenance date asserted by the source
relationships[] array {type, node, direction?, properties?}; direction out (default): new record → node; in: node → new record
Status Code Cause
201 , Created versions, each with relationships (edge ids)
400 EMPTY_BATCH records is empty
400 INVALID_PROPERTY | INVALID_INTERVAL | INVALID_ID | VALIDATION Malformed input (VALIDATION = upsert without id)
401 UNAUTHORIZED Missing/invalid credential
404 NOT_FOUND A relationship's far node does not exist
409 ALREADY_EXISTS | TOMBSTONED | TYPE_COLLISION Conflicting write

GET /v1/records?externalId=&connector=

Resolves a connector-synced item to its projection root record (a point lookup via deterministic source identity).

curl -fsS "http://127.0.0.1:7625/v1/records?externalId=site,item-42&connector=sharepoint" \
  -H "x-api-key: $KEY"
Response
{
  "externalId": "site,item-42", "connector": "sharepoint",
  "sourceId": "...", "sourceVersionTx": 1750000000000000,
  "rootNodeId": "...", "aclJson": { "...": "..." },
  "record": { "id": "...", "labels": ["..."], "properties": {}, "validTime": {}, "txTime": {}, "tombstone": false }
}

connector is inferred from a connector-token caller; API-key callers must supply it explicitly (400 VALIDATION otherwise). A connector token asking for a different namespace gets 403 CONNECTOR_NAMESPACE_MISMATCH. 404 NOT_FOUND for no such source, or when the projection root has not been parsed yet.

GET /v1/records/{id}

curl -fsS http://127.0.0.1:7625/v1/records/<id> -H "x-api-key: $KEY"
Response (200)
{ "id": "...", "labels": ["RISK"], "properties": { "severity": 7 },
  "validTime": { "start": 1750000000000000 }, "txTime": { "start": 1750000000000000 }, "tombstone": false }

401 UNAUTHORIZED; 404 NOT_FOUND if no such record.

PUT /v1/records/{id}

Writes a new full-snapshot version of an existing record (appends; history is preserved).

{ "labels": ["RISK"], "properties": { "title": "Supplier delay", "severity": 8 } }
{ "id": "...", "labels": ["RISK"], "properties": { "title": "Supplier delay", "severity": 8 },
  "validTime": {}, "txTime": {}, "tombstone": false }
Status Code Cause
200 , New current version
400 INVALID_PROPERTY | INVALID_INTERVAL Malformed input
401 UNAUTHORIZED Missing/invalid credential
404 NOT_FOUND No such record
409 TOMBSTONED Record is tombstoned

DELETE /v1/records/{id}?validEnd=

Tombstones a record and cascades to its live edges.

curl -fsS -X DELETE http://127.0.0.1:7625/v1/records/<id> -H "x-api-key: $KEY"
Response (200)
{ "id": "...", "edgesTombstoned": 3, "txTime": 1750000000000000 }

validEnd (query param, µs) sets the tombstone's valid-time end; default now. A connector-token caller may only delete records whose source_connector property matches its own name (403 CONNECTOR_NAMESPACE_MISMATCH otherwise); API keys are unrestricted tenant-admin credentials. 409 TOMBSTONED if already tombstoned.

GET /v1/records/{id}/history?limit=&cursor=

Paginated version history, including tombstones.

curl -fsS "http://127.0.0.1:7625/v1/records/<id>/history?limit=50" -H "x-api-key: $KEY"
Response
{ "versions": [ { "id": "...", "labels": [], "properties": {}, "validTime": {}, "txTime": {}, "tombstone": false } ],
  "nextCursor": null }

limit defaults to 50. 400 BAD_CURSOR for an invalid cursor.

Relationships

POST /v1/relationships

{ "from": "1111...", "to": "2222...", "type": "OWNED_BY" }
{ "id": "...", "from": "1111...", "to": "2222...", "type": "OWNED_BY", "validTime": {}, "txTime": {} }
Status Code Cause
201 , Created edge
400 INVALID_PROPERTY | INVALID_INTERVAL Malformed input
401 UNAUTHORIZED Missing/invalid credential
404 NOT_FOUND An endpoint node is missing
409 TOMBSTONED | TYPE_COLLISION Conflicting write

DELETE /v1/relationships/{id}?validEnd=

Response (200)
{ "id": "...", "txTime": 1750000000000000 }

401 UNAUTHORIZED; 404 NOT_FOUND; 409 TOMBSTONED if already tombstoned.

Ingest

POST /v1/ingest

Ingests a source document. format=json applies synchronously; other formats (docling, tabular, email, web, ...) enqueue an async parse job for the Python format workers. An identical re-submission (content-hash dedup) returns 200.

curl -fsS -X POST http://127.0.0.1:7625/v1/ingest \
  -H "x-api-key: $KEY" -H "Content-Type: application/json" \
  -d '{"format": "json", "name": "risks.json",
       "content": "[{\"label\": \"RISK\", \"severity\": 7}]"}'
{ "deduplicated": false, "sourceId": "...", "sourceVersionTx": 1750000000000000,
  "nodeIds": ["..."], "edgeIds": [] }
{ "deduplicated": false, "jobId": "...", "sourceId": "...", "statusUrl": "/v1/ingest/<jobId>" }
{ "deduplicated": true, "sourceId": "..." }

Request fields:

Field Type Notes
format string json, docling, tabular, email, web, ...
name string Source name; for format=web, the URL (also the request identity)
content string Inline text content (exactly one of content/contentBase64 required)
contentBase64 string Base64 bytes; for format=web both may be omitted (the worker fetches)
options object Format-specific settings (crawl bounds, tabular header hints, JSON nesting policy) plus the connector sourceMeta envelope
Status Code Cause
200 , {deduplicated: true, sourceId}
201 , Applied synchronously (format=json)
202 , Enqueued for async parsing
400 INVALID_CONTENT Neither/both of content/contentBase64, or malformed content
401 UNAUTHORIZED Missing/invalid credential

GET /v1/ingest/{job_id}

curl -fsS http://127.0.0.1:7625/v1/ingest/<jobId> -H "x-api-key: $KEY"
Response
{ "jobId": "...", "kind": "ingest:docling", "state": "pending",
  "attempts": 0, "events": [{ "at": 1750000000000000, "transition": "enqueued", "detail": null }] }

state is one of pending, leased, completed, dead. 404 NOT_FOUND for no such job.

Generation

POST /v1/generate

Plans evidence, generates a draft with citations, verifies every claim against its planned source spans, and persists an auditable trace. See Generation & traces for the full SSE contract and verification model; this section covers the REST contract shape.

{
  "question": "Which open risks have severity 5 or higher?",
  "query": { "find": "RISK", "where": { "status": { "$eq": "open" } } },
  "semanticModel": "text-embedding-3-small",
  "expandDepth": 1,
  "stream": false
}
{
  "draft": "...", "model": "claude-sonnet-4-5", "traceId": "0197...",
  "planHash": "...", "promptHash": "...",
  "citations": [ ],
  "plan": { "budget": 6000, "used": 3200, "spans": 4, "stats": {} },
  "usage": { "inputTokens": 900, "outputTokens": 210 },
  "verification": { "status": "verified", "claims": [ ] }
}

Request fields:

Field Type Notes
question string Required; also the semantic-recall probe text
query object Structured recall leg: a full query-DSL object
semanticModel string Semantic recall leg: registered embedding model id
k integer Semantic top-k; default 16
expandDepth integer Hop expansion from recalled roots, 0-2; default 1
atValidTime / atTxTime µs or RFC3339 Temporal coordinates for recall
budgetTokens integer Token budget for evidence packing
model string Generation model; must be operator-allowlisted; default llm.model_default
maxTokens integer Output-token ask; clamped to llm.max_output_tokens
temperature float Default 0.2; must be in [0, 2]
stream boolean SSE variant (see below)
allowUngrounded boolean Proceed with an empty evidence plan instead of 422; default off

With "stream": true the response is text/event-stream instead of JSON: chunk ({delta}) events as tokens arrive, one verification event (necessarily trailing the stream), one done event ({traceId, usage, planHash, promptHash, citations}), or an error event ({message}) on a mid-stream provider failure that ends the stream without re-splicing.

Status Code Cause
200 , Verified draft (or SSE stream)
400 PLAN_INVALID | MODEL_NOT_ALLOWED | QUERY_INVALID | UNKNOWN_MODEL | VECTOR_INVALID Bad request shape or disallowed model
401 UNAUTHORIZED Missing/invalid API key
422 INSUFFICIENT_EVIDENCE Recall found nothing; pass allowUngrounded to proceed
429 TENANT_BUDGET_EXCEEDED Token cap exhausted
501 UNIMPLEMENTED Generation or embedding not configured
502 PROVIDER_ERROR | EMBEDDING_FAILED Upstream provider failure
503 PROVIDER_UNAVAILABLE Transient provider failure, retries exhausted

GET /v1/trace/{query_id}

curl -fsS http://127.0.0.1:7625/v1/trace/<traceId> -H "x-api-key: $KEY"

Returns the full persisted assembly record (request summary, evidence plan, prompt hash, per-claim verdicts, span links). 401 UNAUTHORIZED; 404 NOT_FOUND if no such trace for this tenant.

Clarifications

These endpoints back the temporal-clarification workflow (ambiguity detection escalating to a human answer). Full field semantics live alongside the clarify-bot documentation; the shapes below are the REST contract.

GET /v1/clarifications?state=&severity=&limit=&cursor=

Tenant-scoped list, backed by the same query executor as /v1/query.

Response
{ "clarifications": [ { "id": "...", "target": "...", "kind": "...", "severity": "...", "state": "..." } ],
  "nextCursor": null }

400 VALIDATION for an unrecognized state/severity; 400 QUERY_INVALID if the derived query fails validation; 401 UNAUTHORIZED.

GET /v1/clarifications/{id}

Returns the clarification plus, when resolvable, spanExcerpt (capped at 500 bytes), aclJson, sourceId, sourceName, and askedAliases (delivery identities for the ranked candidates).

401 UNAUTHORIZED; 404 NOT_FOUND.

POST /v1/clarifications/{id}/answer

Answers a clarification. Body fields: answerIndex, answerCustom, responder/rights (clarify-audience callers only), channel, messageRef, stewardOverride.

Status Meaning
200 Bound: {bound: true, factVersionTx, quorumSatisfied?}
202 Recorded without binding: partial confirmation, attestation, or conflict
400 VALIDATION: missing responder/rights for clarify-service calls
401 UNAUTHORIZED
403 RESPONDER_ASSERTION_FORBIDDEN: non-clarify caller asserted responder/rights
404 NOT_FOUND
409 CLARIFY_ALREADY_ANSWERED | CLARIFY_INVALID_STATE
422 CLARIFY_UNPARSEABLE_ANSWER

POST /v1/person-aliases/verified-match

Clarify-audience only (403 ALIAS_ATTACH_FORBIDDEN for any other caller). Attaches a verified chat alias (slack_user_id in v1) to a PERSON record after re-verifying the evidence server-side.

Status Code Cause
200 , Attached, or idempotent replay (alreadyPresent: true)
400 VALIDATION Unsupported aliasKind or empty fields
401 UNAUTHORIZED Missing/invalid credential
403 ALIAS_ATTACH_FORBIDDEN Caller is not the clarify audience
404 NOT_FOUND No such person
409 ALIAS_AMBIGUOUS | ALIAS_CONFLICT Ambiguous match or existing different alias
422 ALIAS_EVIDENCE_INVALID Evidence failed re-verification

OpenAPI document

GET /v1/openapi.json

Unauthenticated. Serves the same utoipa-derived document snapshotted at core-engine/openapi/openapi.json (title "Telha Core API", version 0.1.0), including the four security schemes (apiKey, connectorToken, clarifyToken, adminToken) and every path documented above.

curl -fsS http://127.0.0.1:7625/v1/openapi.json | jq '.paths | keys'