Why: - Domain values are denormalized into every Qdrant point payload. Without validation, an unregistered or typo'd domain (e.g. "fier" for "fire") silently creates a new partition that retrieval never queries — the file ends up invisible rather than rejected. Tenants also need independently sized domain sets (one may run 14 insurance lines, another 6), which rules out an enum. Changes: - tenant_domains table (migration 41335d162de8) + repository, unique on (tenant_id, domain). - src/application/domains/: ensure_domain_allowed() is the strict-allowlist check now run inside upload_source_file()'s first transaction, before any MinIO object, job row, or Qdrant point is written. - /v1/domains (list/create/patch/disable/enable) gated on its own domains:read/domains:write scopes, deliberately separate from files:write so an upload key cannot create partitions. domain itself is immutable (denormalized into every point payload); only display_name is editable. Disable blocks new uploads without touching already-indexed points. Impact: - BREAKING: POST /v1/files now rejects any domain without an active tenant_domains row (400, unknown_domain). A domain must be created via POST /v1/domains before the first upload to it.
Talie chatbot service
Architecture decisions live in docs/adr. The first implementation
milestone is documented in the ingestion vertical-slice plan.
Provisioning the datastores
Both schema steps run as explicit deployment steps. The application performs no DDL at startup — not for Postgres (ADR-0009) and not for Qdrant (ADR-0001, "Collection provisioning").
docker compose up -d # Postgres, MinIO, Qdrant
uv run alembic upgrade head # Postgres schema
uv run python -m src.cli.qdrant_bootstrap # the `chunks` collection
uv run fastapi dev src/main.py
Before a tenant can upload, its domains must be registered — POST /v1/files
rejects an unregistered or disabled domain with 400. The calling backend
manages them over /v1/domains using a key with the domains:write scope:
curl -X POST http://localhost:8000/v1/domains \
-H "Authorization: Bearer $API_KEY" \
-H 'Content-Type: application/json' \
-d '{"domain": "fire", "display_name": "Fire insurance"}'
Both bootstrap commands are idempotent and safe to re-run. qdrant_bootstrap verifies an
existing collection against the pinned schema and exits non-zero on a mismatch,
rather than leaving a silently degraded sparse index in place.
Local Langfuse
This repo includes a root-level development Compose file for Langfuse:
Start Langfuse locally:
cp .env.langfuse.example .env.langfuse
# edit .env.langfuse and replace CHANGE_ME values
docker compose --env-file .env.langfuse -f docker-compose.langfuse.yml up -d
Open:
http://localhost:3000
If the chatbot app runs on your host machine, configure it with:
LANGFUSE_HOST=http://localhost:3000
If the chatbot app later runs inside the same Compose project/network as Langfuse, configure it with:
LANGFUSE_HOST=http://langfuse-web:3000
A future app stack can be launched together with Langfuse using multiple Compose files:
docker compose \
-f docker-compose.yml \
-f docker-compose.langfuse.yml \
--env-file .env \
--env-file .env.langfuse \
up -d