Files
chatbot_v3/docs/adr/0008-rest-api-and-fastapi-boundary.md
Ali Zarinkolah fd70ad01af docs(architecture): adopt inline synchronous ingestion (ADR-0017)
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.
2026-08-16 11:53:59 +03:30

14 KiB

0008. REST API and FastAPI boundary

Status

Proposed

Context

The project now has decisions for ingestion and Qdrant storage (0001), point-level CRUD and keyword search (0002), agent retrieval (0003), conversational graph behaviour (0006), and LangGraph thread persistence (0007). 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.
  • 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:

Authorization: Bearer <api_key>

A FastAPI dependency validates the key against Postgres and returns an AuthContext:

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:

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 supersedes the job-shaped contract originally specified here: ingestion runs inline and the response is terminal.

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:

{
  "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.

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.