Why: - ingestion is inline in the request instead of dispatched through RabbitMQ/outbox/worker; ADR-0014 is superseded (not deleted) and named as the design to adopt once ingestion needs to move off the request path. Changes: - new ADR-0017 plus amendments to every ADR/plan that referenced the job-shaped/broker contract, so none silently contradict it. Impact: - no broker, outbox, or worker code; rabbitmq test marker removed.
306 lines
14 KiB
Markdown
306 lines
14 KiB
Markdown
# 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 <api_key>
|
|
```
|
|
|
|
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.
|