Skip to content

System architecture

Components

flowchart TB
  subgraph edge["Edge"]
    CADDY["Caddy<br/>TLS termination + reverse proxy"]
  end

  subgraph app["Application container"]
    FA["FastAPI (uvicorn)"]
    MW["Middleware<br/>CORS · rate limit · usage counter"]
    AUTH["auth.py<br/>API key | session JWT"]
    MAIN["main.py<br/>route handlers"]
    RET["memory_retriever.py<br/>MemoryRetriever"]
    SCORE["memory_scoring.py<br/>weights, recency, frequency"]
    DB["db.py<br/>SQLAlchemy Core"]
  end

  subgraph ext["External"]
    PG[("PostgreSQL")]
    EMB["Embedding provider<br/>hashing | sentence-transformers"]
  end

  CADDY --> FA --> MW --> AUTH --> MAIN
  MAIN --> RET --> SCORE
  RET --> EMB
  MAIN --> DB --> PG
  RET --> DB

A single Python process. No workers, no queues, no cache tier, no message bus.

Module map

Module Responsibility
api/main.py FastAPI app, every route handler, middleware
api/auth.py Credential resolution — API key or session JWT → org identity
api/models.py Pydantic request schemas; the validation boundary
api/db.py SQLAlchemy Core tables and every query
api/memory_retriever.py Embedding providers, MemoryRetriever, cosine
api/memory_scoring.py DEFAULT_WEIGHTS, recency and frequency curves, lexical tokenizer
sdk/client.py Python client over the REST API
mcp/server.py stdio MCP server, HTTP client to the API
security_graph/ Reference security knowledge graph (experimental)

Request path

sequenceDiagram
  participant C as Client
  participant MW as Middleware
  participant A as auth
  participant H as Handler
  participant D as db
  participant E as Embedding

  C->>MW: HTTP request
  MW->>MW: CORS check
  MW->>MW: rate limit (production only)
  MW->>A: resolve credential
  A-->>MW: 401 if absent/invalid
  A->>H: identity {org_id, auth}
  H->>H: Pydantic validation → 422
  alt write
    H->>E: embed(content)
    H->>D: INSERT + embedding_json
  else read
    H->>D: load candidates (org-scoped)
    H->>H: re-verify org per candidate
    H->>E: embed(query)
    H->>H: score, sort, truncate
  end
  H-->>MW: response
  MW->>D: record_usage(org, kind, status)
  MW-->>C: JSON

Two design choices worth calling out

The org check happens twice

Candidates are loaded through an org-keyed query, then every candidate is re-verified to belong to the caller's organization before it can reach a result. Redundant by construction — and that is the point: a stale or misrouted project id cannot leak another tenant's memories through a single missed WHERE clause.

Usage counting lives in middleware

Rather than each handler recording its own usage, a single middleware reads an org_id that the auth dependency stamps onto the request. One wiring point, every route covered, nothing to forget in a new handler.

The classification is deliberately coarse:

Kind Matches
recall GET /api/context/*/relevant
remember POST /api/context, POST /api/memory
read any other GET
admin everything else

Unauthenticated requests count nothing — there is no org to attribute them to.

Deployment topology

flowchart LR
  U["Internet"] --> CF["Cloudflare<br/>DNS + TLS"]
  CF --> C["Caddy<br/>:80 / :443"]
  C -->|"api.venkai.fr"| API["venkai-api<br/>:8100, not host-exposed"]
  C -->|"app.venkai.fr"| DASH["static dashboard"]
  API --> PG[("PostgreSQL")]
  API --> V["/data volume"]

Port 8100 is not published to the host. All traffic goes through Caddy, so the rate limiter and the logs see real client IPs rather than a spoofable X-Forwarded-For from a directly-reachable port.

Scaling characteristics

Dimension Behaviour
Requests Stateless — horizontally scalable behind a load balancer
Memories per project O(n) per query. The real ceiling.
Projects Independent; each query touches one
Organizations Row-level scoping; no per-tenant infrastructure
Write throughput One synchronous embedding per write

The scaling limit is memories per project, not requests. pgvector plus an ANN index is the planned answer; see Storage.

Absent by design

Not present Consequence
Cache layer Every read hits the database
Background workers Embedding is inline; no queue to lose data in
Read replicas Reads and writes share one connection pool
ANN index Linear scan per query
Multi-region Single deployment
Per-memory delete Supersede via PATCH, or delete the org

Each of these is a real limit rather than an oversight. They are listed so you can judge whether they matter for your workload before you find out.