# 0008. REST API and FastAPI boundary ## Status Proposed ## Context The project now has decisions for ingestion and Qdrant storage ([0001](0001-ingestion-pipeline-and-collection-schema.md)), point-level CRUD and keyword search ([0002](0002-chunk-crud-and-search-api.md)), agent retrieval ([0003](0003-agent-hybrid-retrieval.md)), conversational graph behaviour ([0006](0006-conversational-agent-graph.md)), and LangGraph thread persistence ([0007](0007-agent-persistence-threads-and-memory.md)). What is still missing is the HTTP boundary that ties these together. FastAPI has to do more than expose thin route handlers: - Authenticate the calling backend/API client with an API key. - Resolve that API key to an active tenant in Postgres, using the tenant/API-key tables defined by [ADR-0009](0009-postgres-sqlalchemy-alembic-schema.md). - Inject `tenant_id` into every Qdrant operation and every LangGraph run, so callers never submit or override the tenant boundary themselves. - Receive user chat messages and pass them to the compiled LangGraph graph. - Receive source files (starting with CSV, but aligned with the docx/xlsx/csv ingestion decisions from ADR-0001/0004) and kick off ingestion. - Expose direct management of Qdrant records. ADR-0002 originally sketched `/chunks/...` endpoint names, but the API is directly managing Qdrant **points**. The REST surface should use `/points/...` while the payload can still contain chunk-specific fields such as `chunk_id`, `content`, and `previous_chunk_id`. This ADR owns the public REST shape for this FastAPI service. If its endpoint names differ from indicative endpoint examples in older ADRs, this ADR wins; the older ADRs still own their data-model and pipeline semantics. ## Decision ### FastAPI application structure Use a single versioned API prefix, `/v1`, and split the app into routers by resource: | Router | Prefix | Responsibility | |---|---|---| | `threads` | `/v1/threads` | Chat thread runs, transcript pagination, thread purge. | | `files` | `/v1/files` | Upload source files and inspect ingestion status. | | `points` | `/v1/points` | Direct CRUD, ordering, payload updates, keyword search, and batch edits over Qdrant points. | | `users` | `/v1/users` | Cross-thread memory inspect/reset. | | `health` | `/healthz`, `/readyz` | Process health and dependency readiness. | Routers use FastAPI's dependency injection with `Annotated[..., Depends(...)]`. Shared dependencies live at the router level where they only gate access, and as typed parameters where the endpoint needs the resolved value. Path operation functions stay one HTTP operation per function. The app lifespan owns long-lived resources: - SQLAlchemy async engine/sessionmaker. - Qdrant client. - LangGraph checkpointer/store and the compiled graph (compiled once, reused). - Any ingestion/retrieval model clients. No DDL runs at FastAPI startup. Postgres schema changes are Alembic migrations; LangGraph checkpoint/store setup is a deployment step as decided in ADR-0007. ### Authentication and tenant dependency Every `/v1` route except health checks requires an API key. The key is supplied as a bearer token: ```http Authorization: Bearer ``` A FastAPI dependency validates the key against Postgres and returns an `AuthContext`: ```text AuthContext( tenant_id, tenant_slug, api_key_id, scopes, actor_type="backend" | "admin" | "worker", ) ``` Validation rules: 1. API keys are stored only as hashes, never plaintext. 2. The presented key is split into a lookup prefix/key id plus secret material; Postgres lookup uses the prefix/id, then compares the stored hash with a constant-time comparison. 3. The key must be active: tenant active, key not revoked, key not expired. 4. The key must carry the scope required by the router/action. 5. `tenant_id` is derived only from the key's tenant relationship. The service never accepts `tenant_id` in request bodies or query strings. Any client-supplied tenant field is rejected as an invalid request. This preserves the ADR-0001/0002 Qdrant tenant-isolation rule and extends it to the REST boundary. Recommended dependency aliases: ```python SessionDep = Annotated[AsyncSession, Depends(get_db_session)] AuthContextDep = Annotated[AuthContext, Depends(get_auth_context)] ``` Scope dependencies compose on top of `AuthContextDep`, for example `require_scope("points:write")`, `require_scope("threads:run")`, and `require_scope("files:write")`. ### Thread/run endpoints Chat uses the LangGraph vocabulary already chosen in ADR-0007. | Endpoint | Purpose | |---|---| | `POST /v1/threads/{thread_id}/runs` | Execute one graph run for a new user message. | | `GET /v1/threads/{thread_id}/messages?limit=20&before=...` | Return a paginated transcript window for the frontend. | | `DELETE /v1/threads/{thread_id}` | Purge the thread checkpoints for explicit clear/erasure. | | `POST /v1/threads/{thread_id}/runs/{run_id}/feedback` | Attach feedback to the answer produced by a run. | `POST /runs` passes this context into LangGraph: - `thread_id` from the path. - `tenant_id` from `AuthContext`, not from the caller body. - `user_id` from the trusted main-backend request payload/header. - locale/language from the request, with server-side fallback. The endpoint streams with Server-Sent Events when the caller asks for streaming. Token events come from LangGraph `stream_mode="messages"`; progress and terminal status events come from `stream_mode="custom"` or explicit route wrapping. Terminal statuses are the ADR-0006 statuses: `answered`, `clarifying`, or `escalate`. When `escalate` is returned, the main backend uses its existing human-assistant transfer path and stops issuing runs for that thread. One in-flight run per thread is allowed. The route takes a Postgres advisory lock keyed on `thread_id`; a concurrent run returns `409 Conflict`. ### File ingestion endpoints Files are source documents, not Qdrant points. Uploading a CSV creates or re-ingests a file and then creates/updates many points via the ADR-0001/0004 ingestion pipeline. | Endpoint | Purpose | |---|---| | `POST /v1/files` | Upload one `.csv`, `.xlsx`, `.docx`, or converted `.doc` source file for ingestion. Initial callers may only use CSV, but the route is named for the generalized source-file concept. | | `GET /v1/files/{file_id}` | Return ingestion status and source-file metadata. | | `GET /v1/files/{file_id}/points?limit=...&offset=...` | List active points belonging to a file, ordered by `order_id`. | | `DELETE /v1/files/{file_id}` | Soft-delete all active points for a file, preserving audit data unless a future compliance hard-delete endpoint is added. | `POST /v1/files` uses FastAPI `UploadFile` and multipart form fields for metadata such as `domain`. File validation is server-side: - Allowlist extensions/content types: `.csv`, `.xlsx`, `.docx`; legacy `.doc` is accepted only if the conversion pipeline from ADR-0004 is enabled. - Enforce maximum file size before reading the full body into memory. - Sniff enough content to reject obvious extension spoofing. - Derive `tenant_id`, `created_by`, and `updated_by` from `AuthContext`, not from form fields. Ingestion parses, chunks, embeds, and writes many Qdrant points. [ADR-0017](0017-synchronous-ingestion-in-the-request-path.md) supersedes the job-shaped contract originally specified here: ingestion runs inline and the response is terminal. ```text 201 Created -> { file_id, ingestion_job_id, status: "succeeded", chunks_indexed } ``` `ingestion_job_id` is retained so the attempt stays inspectable via `GET /v1/files/{file_id}`, and so a future move back to a queued `202 Accepted` contract (ADR-0014) is additive for clients that already read it. Ingestion failures are HTTP failures: `400` unparseable, `413` too large, `502` embedder failure, `503` at capacity, `504` past the ingestion timeout. ### Point endpoints replace the older `/chunks` sketches The Qdrant collection is still named `chunks` in ADR-0001, because each stored point represents one chunk. The REST resource is nevertheless `/points`, because callers are managing Qdrant point records and the operations map almost one-to-one to Qdrant's Points API. | Endpoint | Qdrant primitive | Notes | |---|---|---| | `POST /v1/points` | upsert one point | Manual/admin creation of one chunk-backed point. | | `GET /v1/points/{point_id}` | retrieve by ID | Returns vectors only when explicitly requested. | | `PUT /v1/points/{point_id}` | upsert with `update_only` | Replaces content/vectors/payload with optimistic concurrency. | | `PATCH /v1/points/{point_id}/payload` | `set_payload` / `overwrite_payload` | Payload-only update. | | `DELETE /v1/points/{point_id}` | payload update or delete | Soft-delete by default (`is_active=false`, `deleted_at`). | | `GET /v1/points?file_id=...` | `scroll` | List/paginate active points in `order_id` order. | | `PATCH /v1/points/{point_id}/order` | `points/batch` | Move/insert point and relink neighbors. | | `GET /v1/points/count` | `count` | Count active points under tenant/domain filters. | | `GET /v1/points/search?q=...` | full-text payload match | Keyword search over `content`, not semantic retrieval. | | `POST /v1/points/batch` | `points/batch` | Bulk multi-operation edits. | Every operation injects a Qdrant filter containing: - `tenant_id == auth_context.tenant_id` - `is_active == true` by default on reads - optional `domain`, `file_id`, and other allowed filters supplied by caller `point_id` names the Qdrant point ID in the URL. Payload fields keep their ADR-0001 names (`chunk_id`, `file_id`, `previous_chunk_id`, etc.) so the API name change does not rename the storage schema. ### Response and error conventions Use explicit return types or `response_model` to validate and filter public responses. Do not return raw ORM objects, raw Qdrant responses, or full LangGraph state. Error responses use a stable envelope: ```json { "error": { "code": "tenant_not_found | invalid_api_key | missing_scope | validation_error | conflict | not_found | internal_error", "message": "human-readable summary", "details": {}, "request_id": "..." } } ``` Important status codes: | Status | Use | |---|---| | `201 Created` | Ingestion completed inline (ADR-0017). | | `400 Bad Request` | Invalid domain/filter combinations or unsupported file type. | | `401 Unauthorized` | Missing/invalid API key. | | `403 Forbidden` | Valid key without required scope. | | `404 Not Found` | Resource absent within this tenant. | | `409 Conflict` | Concurrent thread run or optimistic-concurrency mismatch. | | `422 Unprocessable Entity` | Pydantic request validation failure. | ### Observability Every request gets a `request_id` and structured logs containing route, status, latency, tenant id, and API key id — never the API key itself. LangGraph runs include the same request id in `config` metadata so API logs, graph traces, and Qdrant operations can be correlated. ## Consequences ### Positive - Tenant isolation starts at the FastAPI dependency boundary, before any route logic reaches Qdrant or LangGraph. - `/points` aligns the REST API with what it actually manages — Qdrant point records — while preserving the chunk payload schema underneath. - `/threads/{thread_id}/runs` remains compatible with the LangGraph thread/run model already chosen in ADR-0007. - Inline file ingestion (ADR-0017) gives callers a terminal result in one request, with failures surfaced as ordinary HTTP errors. - Router-level dependencies and typed FastAPI dependencies keep auth, tenant resolution, sessions, and scopes reusable instead of repeated per endpoint. ### Negative - The API now has two words for related concepts: `files` are source documents, `points` are the chunk records produced from them. This is accurate but requires clear frontend/backend naming. - `/points` exposes storage terminology to API consumers. That is acceptable for this internal/admin-oriented service, but it would be less friendly as a public product API. - API-key auth in Postgres adds a database lookup to every request unless short-lived caching is introduced. Caching must preserve revocation semantics. - Inline ingestion ties the upload's duration to proxy/client timeouts, and moving back to a queued `202` contract later is a breaking change for clients (see ADR-0017's trigger list). The `ingestion_jobs` store is kept either way. - The REST layer now depends on the tenant/API-key, ingestion-job, audit, and usage tables defined in [ADR-0009](0009-postgres-sqlalchemy-alembic-schema.md). ## Alternatives Considered - **Keep `/chunks/...` from ADR-0002**: rejected. The storage record being managed is a Qdrant point, and several operations (`points/batch`, retrieve by point ID, vector inclusion flags) map directly to Qdrant's Points API. Keeping `/chunks` would make the REST layer look more domain-friendly but less honest about what it does. - **Accept `tenant_id` in every request**: rejected. Tenant is an isolation boundary, not user input. It must come from the API key's Postgres tenant relationship. - **Use `X-API-Key` instead of `Authorization: Bearer`**: rejected as the default because bearer tokens are standard for service-to-service auth and work cleanly with middleware, proxies, and generated clients. A compatibility alias can be added later if an upstream system cannot send `Authorization`. - **Expose one generic `/v1/qdrant/*` proxy**: rejected. It would leak Qdrant's full API surface, bypass tenant/scoping rules too easily, and couple clients to storage operations the service should hide. - **Synchronous file ingestion only**: originally rejected here on the grounds that embedding and late-interaction vector generation can exceed HTTP timeouts; **adopted** by ADR-0017 for the first slice. Dense embedding is async network I/O that batches and runs concurrently, and late-interaction vectors are not populated at ingest yet — which is what made the original objection decisive and is now the named trigger for reverting to a job-shaped contract. - **Create threads with `POST /v1/threads`**: rejected for now. The main backend owns conversation/session records, and LangGraph can create a checkpoint sequence on the first run for a `thread_id`. A create endpoint would imply metadata this service has explicitly chosen not to store.