15 KiB
Architecture
Relata is a modular monolith with in-process background workers: one binary, one process, three external data stores. That choice is deliberate and is documented in decisions/0001-modular-monolith.md.
Package layout
cmd/relata/ serve · createadmin · seed · version
internal/
domain/ types + port interfaces. Standard library only.
config/ environment configuration, validated at startup
ids/ UUIDs, session tokens, deterministic dedupe keys
observability/ structured logging, correlation IDs, metrics
parsing/ text · markdown · json · pdf · docx → Document
chunking/ structure-aware chunking
entities/ normalisation, deterministic extraction, extraction service
analysis/ Analyser port, Ollama adapter, strict model-output validation
embeddings/ Embedder port, Ollama adapter, bounded retry
relationships/ candidate discovery, versioned scoring, human review
search/ hybrid retrieval, answer generation
ranking/ rank fusion and result explanation
ingest/ ingestion service and pipeline stages
jobs/ leased job queue worker pool
storage/mongo/ repositories implementing the domain ports
storage/qdrant/ vector store implementing the domain port
auth/ Argon2id, sessions, CSRF, login rate limiting
audit/ append-only audit recording
api/ /api/v1 REST, middleware, router
web/ server-rendered HTML, templates, static assets
tests/ integration and security tests (build tag: integration)
migrations/ note on why there are none; indexes are applied at startup
docs/ this documentation, plus openapi.yaml
The dependency rule
internal/domain imports only the standard library. No MongoDB driver, no HTTP types, no
Qdrant client. It declares:
- the domain types (
Item,Chunk,Entity,Claim,Relationship,Job, …); - the port interfaces the service layer depends on (
ItemRepository,VectorStore,Embedder,Analyser, …).
Adapters implement those ports:
┌───────────────┐
│ domain │ types + ports, stdlib only
└───────▲───────┘
│ implements │ depends on
┌──────────┴──────────┐ ┌───────────▼──────────┐
│ storage/mongo │ │ ingest · search │
│ storage/qdrant │ │ relationships │
│ embeddings (Ollama) │ │ entities · analysis │
│ analysis (Ollama) │ └───────────┬──────────┘
└─────────────────────┘ │
┌──────────▼──────────┐
│ api · web │
└─────────────────────┘
Practical consequences:
- Swapping Ollama for an OpenAI-compatible API means adding one file in
embeddings/and one inanalysis/. Nothing else changes. See AI_PROVIDER_INTERFACE.md. - The scoring model can be unit-tested without a database —
scoring_test.goruns in milliseconds and needs nothing running. apiandwebdepend on services, never on storage directly.
api and web have a one-way relationship: web imports api for the principal/session
context helpers, and the router accepts the web handlers through the api.WebRoutes
interface, so api never imports web.
Data model
MongoDB is the authoritative store. Qdrant holds vectors and the minimum needed to resolve a hit back to MongoDB.
Collections
| Collection | Contents | Key indexes |
|---|---|---|
workspaces |
The data boundary | slug (unique) |
users |
Local accounts, Argon2id hashes | username (unique) |
sessions |
Server-side sessions | token_hash (unique), TTL on absolute_expiry |
items |
Source items and their originals | (workspace, ingested_at), (workspace, content_hash), text index |
chunks |
Retrievable sections | (workspace, item, sequence), (workspace, embedded, model), text index |
entities |
Extracted subjects | (workspace, normalised_key) unique, (workspace, item_ids) |
entity_aliases |
Candidate name links | (workspace, entity, normalised) unique |
claims |
Statements items make | (workspace, subject), (workspace, dedupe_key) unique |
relationships |
Scored, evidenced links | (workspace, dedupe_key) unique, (workspace, source), (workspace, review_status, score) |
jobs |
The persistent queue | (status, run_after, priority), (status, lease_expires), partial unique on idempotency_key |
processing_runs |
Per-item processing history | (workspace, item, started_at) |
model_versions |
Models that produced stored data | (workspace, kind, name) unique |
audit_events |
Append-only action log | (workspace, at), (workspace, action, at) |
api_tokens |
Bearer credentials; hashes only, never secrets | token_hash unique, (workspace, user, created_at), TTL on expires_at |
Identifiers
Every record uses a string UUID _id. Chunk IDs are used verbatim as Qdrant point IDs,
which Qdrant accepts in UUID form. That removes an entire class of ID-mapping bugs and makes
"delete this item's vectors" a filter rather than a join.
Chunk IDs are deterministic — derived from (workspace, item, sequence, content_hash) —
so re-chunking an unchanged item reuses the same point IDs and overwrites the vectors rather
than orphaning them.
The three unique indexes that carry the weight
These are not merely constraints; they are what make the pipeline idempotent under concurrency.
entities (workspace, normalised_key)— one entity per normalised name and type. Concurrent workers extracting the same company from different documents converge on one record instead of racing to create two. Similar-but-different names never collide here; they produce a candidate alias for a person to adjudicate, and even a confirmed alias leaves both records in place.relationships (workspace, dedupe_key)— the key is derived from the endpoint pair, so reprocessing updates an existing relationship rather than adding a parallel one. Crucially, the upsert refuses to overwrite a human review decision: if someone has confirmed, rejected or edited it, only the evidence is refreshed.jobs (idempotency_key)wherestatus = "pending"— a partial index, so at most one pending job may exist per key while completed jobs remain as history. Re-enqueuing after a crash is a no-op rather than duplicate work.
Workspace enforcement
Every user-content record carries workspace_id, and every repository method takes one. The
helper wsFilter is the only sanctioned way to begin a filter and returns an error on an
empty workspace, so a missing principal cannot silently widen a query.
There is deliberately no GetByID(id) that skips the workspace. Cross-workspace access
returns not_found, never forbidden, so probing cannot confirm that an ID exists elsewhere.
Qdrant payloads
Only what retrieval needs:
workspace_id · item_id · chunk_id · source_type · created_at · embedding_model
No text, no titles, no relationship data. Keeping the payload thin is what stops Qdrant drifting into a second, unversioned copy of the corpus. Every payload field is indexed, so a workspace-filtered search stays a filtered search rather than degrading into a scan.
Request flow
A search
GET /api/v1/search?q=…
│
├─ RequestID · Recover · Logging · SecurityHeaders · RequireAuth
│
├─ searchQueryFromRequest — typed values only; unknown enums rejected
│
└─ search.Service.Search
├─ items.TextSearch (MongoDB $text, workspace-filtered)
├─ chunks.TextSearch (MongoDB $text, for excerpts)
├─ embedder.Embed(query) → vectors.Search (Qdrant, workspace-filtered)
├─ entities.FindByNormalised (query n-grams → entity keys)
├─ items.GetMany (re-checks workspace; drops anything not returned)
├─ rels.ListForItems (expansion; rejected links excluded)
└─ ranking.Rank (reciprocal-rank fusion + explanations)
Note the second workspace check after GetMany: candidates whose items are not returned are
dropped. Qdrant's filter is already workspace-scoped, but the authoritative check is the
MongoDB read.
An ingestion
POST /api/v1/items
│
└─ ingest.Service.Ingest
├─ admission semaphore (bounded concurrent ingestion)
├─ parsing.Sniff — type from CONTENT, not the filename or header
├─ validate title, metadata, size, creation date
├─ SHA-256 → FindByContentHash → exact-duplicate check
├─ store original bytes (binary) or inline text
├─ items.Create
└─ jobs.Enqueue(parse_item)
│
└─ parse → chunk → embed → entities → claims → relationships
Each stage is a separate job that enqueues the next. See INGESTION_PIPELINE.md.
Background workers
A bounded pool draining a Mongo-backed queue. The design goals are recoverability and boundedness.
Leases, not locks. A claimed job records its owner and an expiry. A worker renews the
lease while it runs. A worker that dies stops renewing; the lease expires; ReleaseExpired
returns the job to the pending pool. Nothing external is needed to detect the crash.
Losing a lease cancels the work. If a heartbeat finds the lease gone — another worker has taken over — the job's context is cancelled so the original worker abandons its attempt instead of writing a stale result.
Two independent limits. Worker count bounds concurrent jobs; a separate semaphore bounds concurrent AI calls. A small local model is not swamped just because the worker count was raised.
Only transient failures retry. Invalid model output, an unparseable document, a missing handler and a deleted item are permanent — repeating them wastes the queue. Transient failures retry with bounded exponential backoff and full jitter, within an attempt budget.
A panicking handler does not take the worker down. Handlers run guarded; a panic becomes an ordinary job failure.
Shutdown drains. The HTTP server stops accepting first, then workers are given until the shutdown deadline. A job that does not finish keeps its lease, which expires and returns it to the queue.
Idempotency
Reprocessing must not multiply data. Each derived record type has its own mechanism:
| Records | Mechanism |
|---|---|
| Chunks | ReplaceForItem — delete then insert, so one generation exists |
| Vectors | Deterministic chunk IDs; DeleteByItem before re-chunking |
| Claims | ReplaceForItem, plus a unique dedupe_key |
| Entities | UpsertByKey on (workspace, normalised_key); observations accumulate |
| Relationships | UpsertByDedupeKey; human decisions are never overwritten |
| Jobs | Partial unique index on idempotency_key for pending jobs |
| Items | Partial unique index on (workspace_id, content_hash) for live, non-deliberate items — duplicate detection is a read then a write, so the index is what actually enforces it (ADR 6) |
| Users | Partial unique index on (oauth_provider, oauth_subject) — first sign-in is a read then a write, and two concurrent ones must not provision two workspaces for one person (ADR 8) |
| API tokens | Unique index on token_hash; authentication is a lookup on it and nothing else |
Processing runs are the exception: each reprocess adds a run, because the point is to retain what previous parser and model versions produced.
Security architecture
Detailed in THREAT_MODEL.md. The structural points:
- Content-based type detection. A browser's
Content-Typeand the filename are hints; the magic bytes decide. A zip renamed.docxis rejected unless it genuinely containsword/document.xml. - Stored paths never derive from user input. An uploaded file is stored under the item's own UUID with an extension from a fixed allow-list. The user's filename is retained for display only.
- Escaping by construction. All HTML goes through
html/template's contextual escaping, and thewebpackage deliberately contains no helper that turns a stored string intotemplate.HTML. - A CSP that makes XSS inert.
default-src 'none',script-src 'self', nounsafe-inline, no remote origins. Relata ships its own CSS and JavaScript as files, so nothing inline is needed. - Typed queries only. Filters are assembled in the storage layer from Go values. A client string becomes an operand, never a key or an operator.
- Model output is untrusted input. Strict schema decoding with unknown fields rejected, enum validation, reference checking against what was actually supplied, and no regex-based repair of malformed JSON.
- Uploaded content is data, never instruction. Retrieved text reaches a model only inside delimited blocks whose terminator the content cannot forge.
Testing strategy
| Level | Location | Needs |
|---|---|---|
| Unit | internal/*/[!i]*_test.go |
nothing |
| Fuzz | parsers, model-output decoding, fences, hashes | nothing |
| HTTP | internal/api/http_test.go |
nothing |
| Integration | tests/ (tag integration) |
Docker: MongoDB + Qdrant |
| Security regression | across packages | nothing |
Unit tests assert behaviour and design invariants, not coverage. Several encode the central claims of the design and will fail if a change breaks them:
- similarity alone cannot produce a strong relationship score;
- a maximally confident model opinion cannot reach the storage threshold;
- observed contradiction outranks a model's opinion when inferring a type;
- chronology alone never yields a causal or updating relationship;
- a cross-workspace access is indistinguishable from a missing record;
- a fabricated citation cannot resolve to a real item.