Skip to content

Latest commit

 

History

History
380 lines (307 loc) · 24.9 KB

api-reference.md

File metadata and controls

380 lines (307 loc) · 24.9 KB
flycanon by Firefly

API reference


The OpenAPI document is the canonical source -- visit /openapi.json or the Swagger UI at /docs on a running instance. This page is the human-readable catalogue.

Required headers

Every /api/v1/* request except /api/v1/version requires the tenant-context headers:

Header Grammar Notes
X-Tenant-Id ^[a-z0-9][a-z0-9_-]{0,63}$ Identifies the calling tenant.
X-Workspace-Id same Workspace within the tenant.

Missing or malformed headers -> 400 missing_tenant_context (RFC 7807 envelope; see Error responses below). The X-Correlation-Id / X-Request-Id propagation headers from pyfly are unchanged.

Authentication (since 26.7.1)

Header When Notes
X-API-Key: <key> or Authorization: ApiKey <key> FLYCANON_API_KEYS is non-empty Platform key for every /api/v1/* route except GET /api/v1/version, and for the admin dashboard. X-API-Key wins when both are present. Missing -> 401 missing_api_key; unknown -> 401 invalid_api_key. With the setting empty the user tier is OPEN and the boot log says so.
X-Agent-Token: agt_... /api/v1/agent/* The agent tier's own credential; a request carrying it does not need the platform key (the route verifies the token: 401 missing_agent_token, 403 invalid_agent_token / agent_scope_denied / ...).
Authorization: Bearer <jwt> optional Decoded WITHOUT signature verification purely to derive the actor label and cross-check an optional tenant claim (403 tenant_claim_mismatch). It is not an authentication gate; put a verifying gateway in front if you need JWT auth.

openapi.json carries the matching securitySchemes (ApiKeyHeader, ApiKeyAuthorization, AgentToken) and the header parameters on every operation, so generated clients send them.

Workspace scope enforcement

Workspace scope is enforced on every read-by-id route. A caller who presents a resource id from a different workspace (same tenant or different tenant) receives 404 resource_not_found. Applies to both the user surface and the agent surface (/api/v1/agent/*). This is true for knowledge, sources, candidates, conversations, ingest-jobs, and any sub-routes that take an id (e.g., /{id}/history, /{id}/provenance, /{id}/relations).

The 404 is the wire surface of two cooperating gates: the handler / repository filter on (tenant_id, workspace_id) and, on Postgres, row-level security (see architecture.md -> Row-level security). Either alone would return 404; defence-in-depth keeps the surface consistent even when a future repository forgets the WHERE clause.

Sources

Method Path Description
POST /api/v1/sources Submit a source. Body: SubmitSourceJsonPayload (base64 bytes via content_base64 or uri to fetch). Default sync 201 returns SourceRecord; add ?mode=async for the queued path (returns IngestJob, see /api/v1/ingest-jobs/{id}). Optional ?callback_url=… fires a webhook on terminal state. Same-content submissions dedup on (tenant_id, workspace_id, content_sha256).
POST /api/v1/sources:bulk Bulk-submit an array of sources. Returns per-item BulkSourceResults.
PUT /api/v1/sources/{id} Replace an existing source's content in place. Body: SubmitSourceJsonPayload (content_base64 required).
DELETE /api/v1/sources/{id} Remove a source: BM25 rows + dense vectors purged, chunk rows deleted, the stored original deleted from the object store, the row deleted, source.removed audited and SourceRemoved published. 204. Unknown id -> 404 source_not_found. (26.7.1)
GET /api/v1/sources Paginated list. Query: status, kind (csv), limit, offset.
GET /api/v1/sources/{id} Fetch a single source. 404 → resource_not_found (RFC 7807).

uri fetches and callback_url webhooks go through the outbound host policy: http/https only, no loopback, private (RFC 1918 / ULA), link-local (instance metadata), multicast or reserved address, whether given literally or reached through DNS or a redirect. Refusals are 400 url_fetch_forbidden_host (uri) and 400 callback_url_not_allowed (callback). FLYCANON_URL_FETCH_ALLOW_PRIVATE=true lifts the denylist for private dev stacks only.

Knowledge

Method Path Description
POST /api/v1/knowledge Create item + version 1. Body: CreateKnowledgeRequest. Returns the new version.
GET /api/v1/knowledge Paginated list. Query: status, domain, jurisdiction, limit, offset.
GET /api/v1/knowledge/{id} Pointer view. 404 -> knowledge_item_not_found.
PUT /api/v1/knowledge/{id} Append a new version. Body: UpdateKnowledgeRequest.
POST /api/v1/knowledge/{id}:supersede Body: SupersedeKnowledgeRequest.
POST /api/v1/knowledge/{id}:retire Body: RetireKnowledgeRequest.
GET /api/v1/knowledge/{id}/history Full version chain (oldest first).
GET /api/v1/knowledge/{id}/diff Unified diff between two versions. Query: from_version, to_version.
GET /api/v1/knowledge/{id}/provenance Citation graph for the current version. Query: version (optional explicit).
GET /api/v1/knowledge/{id}/relations List (outgoing, incoming) typed edges for the item.
POST /api/v1/knowledge/{id}/relations Add a typed edge. Body: CreateRelationRequest.
DELETE /api/v1/knowledge/{id}/relations/{relation_id} Remove an edge.
GET /api/v1/knowledge:graph Whole-canon graph view (JSON or accept: text/vnd.mermaid for Mermaid). Query: domain, kind, include_sources.
GET /api/v1/knowledge:stale Per-item staleness scores. Compares published items against fresh sources via cosine.
POST /api/v1/knowledge:detect-conflicts Pairwise conflict scan. Returns confirmed conflict pairs as CandidateRows + auto-creates conflicts_with edges.

Candidates

Method Path Description
POST /api/v1/candidates:propose Run the consolidation LLM against a source. Body: ProposeCandidateRequest. Returns the list of proposed candidate records.
GET /api/v1/candidates Paginated list. Query: status, source_id, domain, limit, offset.
GET /api/v1/candidates/{id} Fetch a single candidate.
POST /api/v1/candidates/{id}:accept Materialise as a knowledge version. Body: AcceptCandidateRequest.
POST /api/v1/candidates/{id}:reject Discard with a reason. Body: RejectCandidateRequest.

Query

Method Path Description
POST /api/v1/search Hybrid retrieval (BM25 + vector + RRF + optional rerank + optional query expansion). Body: SearchRequest.
POST /api/v1/query Answer with citations. Engine selected by FLYCANON_ANSWER_MODE (RLM by default; RAG opt-in). Body: AnswerRequest.
POST /api/v1/query/stream Same as /query but streams the answer as Server-Sent Events.
POST /api/v1/query/suggest Suggest follow-up questions for an existing answer. Body: AnswerRequest.

A nonempty source_ids selector is intersected with the authenticated tenant and workspace before BM25 and dense candidate ranking, fusion and top_k selection. Sources with no matching chunks return no hits; retrieval never falls back to the global source set. Omitting source_ids, using null, or supplying an empty list retains the existing meaning of no additional source constraint within the authenticated scope. Hydrated source checks remain active after ranking as defense in depth. Other knowledge metadata filters retain their documented hydration behavior.

Answer mode (RLM default / RAG deprecated)

The answer endpoints (/api/v1/query, /api/v1/query/stream, and the agent-tier /api/v1/agent/query[/stream]) route to one of two engines, selected by FLYCANON_ANSWER_MODE:

  • rlm (default) — the Recursive Language Model engine reasons over whole documents (read from the object store) and returns the same AnswerResponse shape (answer text + citations + model + elapsed_ms). The request and response contracts are identical to RAG, so callers need no changes. The streaming variant emits a single status frame ({"stage":"reasoning",...}) instead of pre-retrieval hit frames before the terminal final frame.
  • rag (opt-in, deprecated) — the legacy hybrid-retrieval answerer. When selected, the server logs a deprecation warning on every RAG-mode answer and returns a client-facing X-Flycanon-Deprecation response header (carrying the same message) on the answer endpoints. The path is slated for removal in a future release. The streaming variant still emits per-hit citation frames first.

The request / response shapes are unchanged across modes; AnswerRequest in, AnswerResponse out. /api/v1/search (raw retrieval, no LLM) is independent of the answer mode and always available.

Conversations

Method Path Description
POST /api/v1/conversations Start a new conversation. Body: CreateConversationRequest. Returns the conversation id.
GET /api/v1/conversations/{id} Fetch conversation header + last N turns. The summary field is derived from the turn rows on demand (race-free across concurrent turn appends).
POST /api/v1/conversations/{id}/turn Submit a user turn; returns the assistant answer with citations. Body: CreateTurnRequest. Two parallel POSTs serialise via UNIQUE(conversation_id, turn_index) with a bounded retry loop.

Async ingest jobs

The /api/v1/ingest-jobs/* surface parallels flyradar's /api/v1/discovery-jobs/*.

Method Path Description
GET /api/v1/ingest-jobs Paginated list. Query: status (csv), limit, offset.
GET /api/v1/ingest-jobs/{id} Job header — status, attempts, source_id once succeeded, error_code/error_message on failure.
GET /api/v1/ingest-jobs/{id}/stream Server-Sent Events feed of job events. Every event frame carries an id:; resume with ?after_id=N or the Last-Event-ID request header (both honoured since 26.7.1).

Workspaces

CRUD over the canon_workspaces table. All six endpoints require the standard tenant and workspace headers. For creation, the body id must equal X-Workspace-Id. A mismatch returns 400 workspace_scope_mismatch before any row or event is written. Production RLS restricts row visibility to the tenant and workspace in those headers; the tenant-wide list exception is described below.

Method Path Description
POST /api/v1/workspaces Create. Body: WorkspaceCreate; id must match X-Workspace-Id. Returns 201 with WorkspaceSpec.
GET /api/v1/workspaces List visible workspaces within the tenant, newest first. No filter or pagination arguments.
GET /api/v1/workspaces/{id} Fetch one. 404 → workspace_not_found.
PATCH /api/v1/workspaces/{id} Sparse update. Body: WorkspaceUpdate.
POST /api/v1/workspaces/{id}:close Close (idempotent; sets status=closed + closed_at). Data stays.
POST /api/v1/workspaces/{id}:purge Erase everything the workspace holds, then close it: every source through the full removal pipeline (vectors, chunks, stored originals, audit + event each), then knowledge items / versions / citations / relations, candidates, conversations / turns, ingest jobs / events and cost events in one transaction; emits WorkspaceDeleted; audit rows are kept and a workspace.purged row records the counts. X-Workspace-Id must equal the path id (400 workspace_scope_mismatch). Returns WorkspacePurgeResult -- every counter is what this call erased, originals_deleted only objects actually found in the store, closed only when this call closed the row; a repeat is all zeros, closed: false, no event, no audit row. (26.7.1)

With a separate BYPASSRLS connection configured through FLYCANON_ADMIN_DATABASE_URL, GET /api/v1/workspaces lists the tenant's workspaces on that connection. Otherwise it uses the request connection; under the production non-bypass role, RLS limits the result to the workspace named in X-Workspace-Id. Both tenant and workspace headers remain required in either configuration.

Billing

Billing endpoints scope by (tenant_id, workspace_id) from the request headers; there is no actor Query param. actor is a column on canon_cost_events for audit / forensics, not a query partition.

Method Path Description
GET /api/v1/billing Aggregated cost report. Query: group_by (csv of date/model/agent_name), since, until.
GET /api/v1/billing/events Per-call drill-down (correlation id + subject + latency). Query: agent_name, since, until, limit (1-500), offset.
GET /api/v1/billing/summary Rolling-window snapshot (24h / 7d / 30d) with top model per window.
GET /api/v1/billing/top Top-N consumers by cost. Query: dimension (model / agent_name), since, until, limit (1-100).
GET /api/v1/billing/by-subject Cost attribution per (subject_kind, subject_id). Query: subject_kind, since, until, limit (1-200).
GET /api/v1/billing/latency p50 / p95 / p99 latency per group. Query: group_by (csv of model / agent_name), since, until.

Inventory

Method Path Description
GET /api/v1/stats One-shot corpus + queue + cost-stream snapshot (sources, knowledge items, versions, candidates, chunks, ingest jobs, cost headline).

Taxonomy

Method Path Description
GET /api/v1/taxonomy Flat list of every node, ordered by depth.
POST /api/v1/taxonomy/nodes Append a node. Body: CreateTaxonomyNodeRequest.

Audit

Method Path Description
GET /api/v1/audit Paginated audit-log view. Query: subject_id, subject_kind, event_type, limit, offset.

Agent surface

Curated subset of the user surface, protected by X-Agent-Token. Tokens are tenant-scoped, minted via POST /api/v1/agent-tokens (user tier, JWT-protected), and verified per-request against the canon_agent_tokens table.

Token management

These three routes are mounted under /api/v1/agent-tokens and are themselves user-tier (operator JWT or anonymous in dev). Agent-tier callers (whose actor begins with agent:) are refused with 403 agent_cannot_mint.

Method Path Description
POST /api/v1/agent-tokens Mint. Returns 201 + AgentTokenCreated. The raw secret is returned in the token field exactly ONCE. Capture it on the response; there is no recovery path. Subsequent reads expose only the public 12-char prefix. Body: AgentTokenMintRequest.
GET /api/v1/agent-tokens List tokens for the current tenant (newest first). Returns AgentTokenSummaryDto[] -- the raw token is never round-tripped on this endpoint, only the prefix + metadata.
DELETE /api/v1/agent-tokens/{token_id} Revoke. Idempotent: revoking an already-revoked token is a no-op (still 204). Unknown token_id returns 404 resource_not_found.

Agent endpoints

Every agent route requires three headers together: X-Tenant-Id, X-Workspace-Id, and X-Agent-Token: <secret> (the raw token as returned by the mint endpoint, shape: agt_<8hex>_<32hex>). A request carrying X-Agent-Token is exempt from the platform API key, and the platform key is not accepted in the token's place (a platform-key-only call to an agent route is 401 missing_agent_token); the OpenAPI document lists AgentToken as the sole security requirement on these operations. When an Authorization: Bearer JWT is also present its sub wins as the actor label and the agent token is still verified for authorisation.

Idempotency-Key is mandatory on every agent-tier POST, PUT and DELETE. Missing -> 400 missing_idempotency_key. (The user-tier routes do not read the header; the agent surface is stricter so duplicate machine calls can never silently create extra sources, candidates, or queries.)

Method Path Scope Description
POST /api/v1/agent/sources agent.sources:ingest Ingest a Source (always synchronous -- no mode=async on this tier). Body: SubmitSourceJsonPayload. Idempotency-Key mandatory. Reuses the same handler as the user-tier POST /api/v1/sources.
GET /api/v1/agent/sources/{id} agent.sources:read Retrieve a Source row. 404 -> resource_not_found.
PUT /api/v1/agent/sources/{id} agent.sources:ingest Replace in place (content_base64 required). Idempotency-Key mandatory.
DELETE /api/v1/agent/sources/{id} agent.sources:ingest Remove a source incl. its stored original. 204. Idempotency-Key mandatory; a replay returns the original 204.
POST /api/v1/agent/query agent.query:run Answer with citations (engine per FLYCANON_ANSWER_MODE; RLM by default). Body: AnswerRequest. Idempotency-Key mandatory. Same dispatcher as the user-tier POST /api/v1/query.
POST /api/v1/agent/query/stream agent.query:run Streaming answer (Server-Sent Events). Body: AnswerRequest. Idempotency-Key mandatory. Same wire format as the user-tier POST /api/v1/query/stream.
POST /api/v1/agent/search agent.query:run Hybrid retrieval (BM25 + vector + RRF), no LLM. Body: SearchRequest. Idempotency-Key mandatory. Reuses the user-tier POST /api/v1/search handler.
GET /api/v1/agent/knowledge/{id} agent.knowledge:read Fetch a KnowledgeItem (pointer view). 404 -> knowledge_item_not_found.
GET /api/v1/agent/knowledge/{id}/provenance agent.knowledge:read Fetch citation provenance for the current version. Query: version (optional explicit).
POST /api/v1/agent/candidates:propose agent.candidates:propose Propose Candidate rows from a source. Body: ProposeCandidateRequest. Idempotency-Key mandatory. Does not auto-accept -- candidates land in proposed and must be reviewed via the user-tier POST /api/v1/candidates/{id}:accept route.

Scope strings

The per-route scope argument the verifier matches against the token's scopes list:

Scope Endpoints it unlocks
agent.sources:ingest POST /api/v1/agent/sources
agent.sources:read GET /api/v1/agent/sources/{id}
agent.query:run POST /api/v1/agent/query, /api/v1/agent/query/stream, /api/v1/agent/search
agent.knowledge:read GET /api/v1/agent/knowledge/{id}, /api/v1/agent/knowledge/{id}/provenance
agent.candidates:propose POST /api/v1/agent/candidates:propose
* Wildcard -- matches every scope above.

A token with scopes: ["*"] (the mint default) can call every agent route; a token minted with a narrower list is restricted to those routes exactly. Cross-scope calls return 403 agent_scope_denied.

Agent-tier error codes

In addition to the standard error envelope, the agent surface raises:

Status Code slug Raised when
401 missing_agent_token X-Agent-Token header is absent on an /api/v1/agent/* route.
403 invalid_agent_token Token shape is malformed, prefix is unknown, tenant doesn't match, or the hash comparison fails.
403 agent_token_expired The token's expires_at is in the past.
403 agent_workspace_not_in_allowlist X-Workspace-Id is not in the token's workspace_allowlist.
403 agent_scope_denied The per-route scope is not in the token's scopes list (and * is absent).
403 agent_cannot_mint An agent-tier caller (actor begins with agent:) hit a user-tier /api/v1/agent-tokens route.
400 missing_idempotency_key An agent POST was made without the mandatory Idempotency-Key header.

Service

Method Path Description
GET /api/v1/version VersionInfo -- service identity, models, retrieval / EDA backends.
GET /actuator/health Aggregated health (database + EDA).
GET /actuator/health/readiness Readiness probe for K8s.
GET /actuator/health/liveness Cheap liveness probe.
GET /actuator/metrics Prometheus-format metrics.
GET /openapi.json The full OpenAPI 3 document.
GET /docs Swagger UI.
GET /redoc ReDoc UI.
GET /admin/ pyfly admin dashboard. Its API requires authentication: a valid platform key (X-API-Key) when FLYCANON_API_KEYS is set; otherwise it answers 401 unless FLYCANON_ADMIN_REQUIRE_AUTH=false.

Error responses

Every 4xx / 5xx response is an RFC 7807 application/problem+json document produced by flycanon.web.conventions.ProblemDetail. The shape:

{
  "type": "https://firefly.dev/problems/knowledge_item_already_retired",
  "code": "knowledge_item_already_retired",
  "title": "Knowledge item already retired",
  "status": 409,
  "detail": "knowledge item 'add55fda-…' is already retired",
  "instance": "/api/v1/knowledge/add55fda-.../:retire",
  "correlation_id": "01HV...",
  "errors": []
}

Programmatic clients dispatch on code (stable snake_case slug); title is now the human-readable label and detail is the free-form message. The type URI base is https://firefly.dev/problems/... (was https://flycanon.dev/...). Field-level validation details land under errors[].

Status-code catalogue

Status Code slug When
400 missing_tenant_context X-Tenant-Id / X-Workspace-Id header missing or malformed.
401 missing_api_key / invalid_api_key FLYCANON_API_KEYS is set and the request carried no key / an unknown key (X-API-Key or Authorization: ApiKey).
400 callback_url_not_allowed callback_url targets a non-globally-routable host (private, loopback, link-local, shared address space 100.64.0.0/10, reserved) or a non-http scheme.
400 url_fetch_forbidden_host / url_fetch_unsupported_scheme uri targets a refused host / a non-http scheme.
413 url_fetch_too_large The origin's document exceeds FLYCANON_MAX_BYTES.
502 url_fetch_http_error / url_fetch_failed / url_fetch_too_many_redirects The origin answered 4xx/5xx, could not be reached, or redirected more than five times.
400 workspace_scope_mismatch Workspace creation with a body id different from X-Workspace-Id, or POST /workspaces/{id}:purge with a different path id.
404 source_not_found DELETE/PUT on an unknown source id (either tier).
400 invalid_request Pydantic validation, missing-field ValueError raised inside a controller helper.
404 resource_not_found Generic missing resource (knowledge item, source, conversation, candidate, job, workspace).
404 knowledge_item_not_found / knowledge_version_not_found / candidate_not_found Typed not-found variants for callers that prefer the specific slug.
409 knowledge_item_already_retired :retire against a retired item, or losing the atomic claim against a concurrent retire.
409 knowledge_version_conflict Concurrent PUT /knowledge/{id} updates collided on UNIQUE(item_id, version). Extensions: attempted_version.
409 invalid_supersede_target :supersede against a non-existent / retired target, OR losing the atomic claim against a concurrent supersede.
409 candidate_already_decided Two operators accepted / rejected the same candidate; the loser sees this.
409 relation_already_exists (from, to, kind) UNIQUE collision on POST /knowledge/{id}/relations.
415 unsupported_source_kind Binary normaliser doesn't recognise the bytes.
422 empty_source / corrupt_source Loader produced no text / pre-flight rejected the file.
422 invalid_relation Self-relation or unknown target on a relation add.

Concurrent-operation conflicts

These 409s are the surface contract for the atomic-claim plumbing in concurrency.md. Two operators clicking the same :retire / :supersede / :accept simultaneously produce exactly one success and one typed 409 — never two writes, never a generic 500.