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: thex-api-keyheader, 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 whosesource_connectorproperty matches its own name (403 CONNECTOR_NAMESPACE_MISMATCHotherwise).clarifyToken:Authorization: Bearer <token>, a"clarify"-audience signed token (the clarify-bot's answer path)./v1/person-aliases/verified-matchaccepts 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.
GET /readyz¶
Checks the engine handle can open a snapshot.
GET /v1/whoami¶
Echoes the authenticated scope. Unauthenticated calls get 401 UNAUTHORIZED.
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.
{
"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).
| 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.
{
"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.
| 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.
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"
{
"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}¶
{ "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).
| 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.
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.
{ "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¶
| 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=¶
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.
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}¶
{ "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.
{
"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}¶
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.
{ "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.
Related¶
- Query language - the JSON DSL behind
/v1/query,/v1/snapshot,wherein/v1/compare - gRPC API - the same operations over the internal gRPC surface
- MCP tools - agent-facing tools wrapping this surface
- Generation & traces -
/v1/generateSSE contract and verification detail - Error reference - every error code, HTTP status, and gRPC mapping
- TypeScript SDK / Python SDK - typed clients over this surface