oll-memoryThe three doc-intelligence services — #75 extract · #76 ingest · #77 rag — are superseded by ONE service, oll-memory (PR #80, green + unmerged), which replaces the hand-rolled pgvector-SQL / RRF / chunker with the mature LlamaIndex library. #78 (sovereign tier) and #79 (oll-mcp) are parked for later (branches stay open). Rationale: don't reinvent RAG — use a mature library; fewer deploys, one DB, one contract. Detail: the oll-memory design doc ↓.
oll-memory green + unmergedoll-memory has landed as a green-CI, unmerged PR (#80) — one LlamaIndex-based stateful service that does ingest + hybrid retrieval + structured extraction, dev-tested to Core/Model rigor (65 pytest vs real pgvector, docker boot+health, 26/26 gh pr checks), with the real semantic win proven on our own stack — a synonym-only query ranked #9→#1 the moment a real embedder was switched on. Next: Sam tests locally + deploys deliberately (a tag or deploy-all.yml, never a merge). See the deploy card ↓ for the ordered runbook.
What this run delivers
- Five platform extensions, not five products.
oll-extract(schema-locked structured extraction),oll-ingest(document/vision ingestion),oll-rag(hybrid retrieval), anoll-modelsovereign tier (EU / on-device routing default), andoll-mcp(an MCP gateway that exposes the above as auth'd, Core-metered servers). Together they turn Core + Model into a document-intelligence platform other products — and other agents — call over HTTP. - Each built to Core/Model rigor. Contract-first OpenAPI → DTOs, thin routes over a service boundary behind a boot-gate factory, per-endpoint tests that pass keyless in CI, a 12-factor Dockerfile with health probes, the unified error envelope, internal service-token auth, and integration wiring. No shortcuts because it's a "helper" service.
- Sequenced by dependency, landed as unmerged PRs. The factory builds in order (extraction first, then ingestion → retrieval → sovereign tier → the MCP gateway), dev-tests each one, and opens a
feat/-branch PR with green CI. Sam tests locally and deploys deliberately (tag /deploy-all.yml) tomorrow. Phase 2 — a POC product in a separate repo consuming the extended platform — is Sam's follow-up, after 1–5 land green.
1 · The sequenced build
oll-extract · oll-ingest · oll-rag) are superseded by the single oll-memory row at the top; oll-mcp and the sovereign tier are parked. Superseded / parked rows are kept for provenance, not deleted.| Service | What it is | Depends on | Status | PR |
|---|---|---|---|---|
oll-memory |
Consolidated document intelligence — one stateful service = ingest + hybrid semantic retrieval + structured extraction, built on LlamaIndex (pgvector store, sentence splitter, Ollama embeddings). Replaces #75/#76/#77's hand-rolled internals. Design doc ↓ | oll-model · pgvector |
✅ Built · dev-tested · CI-green · consolidates #75/#76/#77 | #80 (unmerged) |
oll-extract |
Schema-locked structured extraction — text → typed JSON validated against a caller-supplied schema, on the Model gateway. The primitive every document product needs. Design doc ↓ | oll-model |
⤳ superseded by #80 | #75 (→ #80) |
oll-ingest |
Document / vision ingestion — messy PDFs, scans and images → clean, chunked, RAG-ready text with paragraph-aware boundaries + page metadata. Design doc ↓ | — | ⤳ superseded by #80 | #76 (→ #80) |
oll-rag |
Hybrid retrieval — vector + keyword + rerank with chunk-level ACL, on pgvector / Neon. Turns ingested corpora into grounded answers. Design doc ↓ | oll-ingest |
⤳ superseded by #80 | #77 (→ #80) |
oll-model sovereign tier |
An additive EU / on-device routing default on the existing gateway — the honest "your data never leaves the EU, or your device" claim, made routable. Design doc ↓ | oll-model |
⏸ parked | #78 (open, later) |
oll-mcp |
MCP gateway exposing the above as authenticated, Core-metered MCP servers — the thing no beginner ships: a billing spine wrapped around agent-callable tools. Design doc ↓ | oll-extract · oll-ingest · oll-rag |
⏸ parked | #79 (open, later) |
The sovereign tier is an additive default on the Model gateway, so it sits under the whole chain rather than in it — every service above inherits EU / on-device routing for free. As of Jul 5 the extract → ingest → rag arc is collapsed into oll-memory (below); the sovereign tier and MCP gateway are parked.
1½ · oll-memory — the consolidated service (LlamaIndex), in detail
Purpose & the pivot's why
One stateful service = document ingest + hybrid semantic retrieval + structured extraction, built on LlamaIndex (PGVectorStore on pgvector, SentenceSplitter, OllamaEmbedding). Only a thin house-style shell is hand-written — auth, the unified error envelope, health probes, the boot-gate factory — and extraction generation is routed through the oll-model gateway. It owns a private Neon oll_memory DB. The pivot's logic: don't reinvent RAG. The original #75/#76/#77 hand-rolled a chunker, pgvector SQL, and Reciprocal Rank Fusion; a mature library does that better, so the win is fewer deploys, one DB, one contract instead of three services to wire, provision and keep in sync.
Unified API
| Endpoint | What it does |
|---|---|
POST /api/memory | Ingest + embed + store — chunk a document, embed each chunk, persist to the pgvector-backed index. |
POST /api/memory/query | Hybrid retrieve — dense (vector) + sparse (keyword) retrieval over the caller's corpus, ACL-scoped. |
DELETE /api/memory/{collection}/documents/{document_id} | Remove a document and all its nodes from a collection. |
POST /api/extract | Schema-locked structured extraction — text → typed JSON, generation routed through the oll-model gateway. |
GET /api/health · /api/health/db · /api/health/model | Liveness · DB readiness (SELECT 1 + vector extension present) · upstream gateway reachability. |
The SOTA semantic win — the headline result
With the deterministic hash embedder (the keyless CI default), a synonym-only query — "mountain gold vault custodian", where keyword_hits=0 so retrieval is pure dense — ranked the correct document #9 of 9: no signal. Switching only EMBED_PROVIDER to Ollama nomic-embed-text (768-dim) ranked the same document #1.
EMBED_PROVIDER changed → correct doc ranked #1Real semantic retrieval — meaning matched with zero shared keywords. Embeddings default to a keyless deterministic local-hash provider (so CI runs real cosine maths, no keys), with nomic (self-hosted / EU) behind an env flag for real use.
Engineering notes — honest, verified by running
- LlamaIndex, pinned.
llama-index-core==0.14.16·llama-index-vector-stores-postgres==0.7.1·llama-index-embeddings-ollama==0.9.0— pinned so a library bump can't silently change retrieval behaviour. EMBED_DIM=768(the oldoll-ragwas 384) — nomic's native dimension; the pgvector column and index are sized to match.- ACL "public-or-intersecting" preserved via a stored
public∈{0,1}flag +FilterOperator.ANY. LlamaIndex'sIS_EMPTYdoes not match an empty JSONB array, so a plain "no tags = public" check would have failed — the explicit flag restores the private-by-default semantics the hand-rolled version had. document_idstored asdocument— LlamaIndex reserves thedocument_idmetadata key, so ours is remapped to avoid a collision.- Binary / PDF ingestion deferred — text / markdown only for now (other MIME → 415); a real parser can slot behind the same seam later without a contract change.
- One transitive
nltkadvisory--ignore-vuln'd per the house pip-audit pattern (documented, not silently suppressed).
Rigor evidence — the rung actually reached
- LlamaIndex engine (not hand-rolled RAG)
- 65 pytest vs real pgvector
- ruff clean
- docker boot + health against pgvector
- Keyless CI-equivalent verified — ingest / query / ACL / delete / extract
- nomic semantic win #9→#1 reproduced
- Dedicated
test-memorypgvector CI job + integration ingest→query assertion - All 26/26
gh pr checksgreen - PR #80 UNMERGED — deploy is Sam's
What this supersedes / parks
#75 extract, #76 ingest and #77 rag are superseded by #80 and will be closed when it merges — their design docs below are kept for provenance and rationale. #78 (sovereign tier) and #79 (oll-mcp) are parked — branches stay open, to revisit once oll-memory is deployed and earning its keep.
2 · The rigor bar applied to each
- Contract-first OpenAPI slice → generated DTOs (spec is the source of truth)
- Thin routes + a single service boundary + boot-gate factory
- Per-endpoint tests — mock-default, keyless in CI
- 12-factor Dockerfile ($PORT) + health probes
- Unified error envelope
{code,message,request_id} - Internal service-token auth between services
- CI matrix job + integration wiring (real cross-service call)
- Dev-tested: build + run + curl + docker, not just diff-read
- HTML design doc + architecture diagram + timeline entry
feat/-branch PR, green CI, UNMERGED
3 · Provider & infra defaults
| Service | Dependency | Default choice | Real provider behind env |
|---|---|---|---|
oll-extract |
Model call | oll-model gateway; default mock — synthesizes a deterministic schema-conforming stub with zero gateway calls, so CI is fully keyless |
Real providers behind EXTRACT_PROVIDER + gateway env (provider-aware boot gate — no silent fallback) |
oll-rag |
Embeddings | Default local — a deterministic hashed-feature, L2-normalized vector (dim 384), keyless, so CI runs real cosine maths (not a stub) |
Real embedder behind EMBED_PROVIDER=api + EMBED_BASE_URL, isolated to embed_client.py; optional cross-encoder rerank behind RERANK_PROVIDER (off by default) |
oll-rag |
Vector store | pgvector on Neon — its own private oll_rag DB in prod; pgvector/pgvector:pg16 in CI |
Same store — Sam must provision a private Neon oll_rag DB with the vector extension at deploy |
oll-ingest |
Parser | Default local — real, dependency-free normalize + paragraph-aware chunking of text / markdown with zero external calls, so CI is fully keyless (not a stub — it does real work) |
Real PDF / scan / image parser behind INGEST_PROVIDER=parser + PARSER_BASE_URL / PARSER_API_KEY (provider-aware boot gate), isolated to parser_client.py |
oll-mcp |
Auth / metering | Core JWT (Bearer) → GET /api/auth/me → plan-gate (MCP_REQUIRED_PLAN, default pro); server-side service-token custody for every downstream hop (client never sees a token); per-user identity scopes the default RAG collection / ACL; a per-call usage audit line for future metering. Keyless CI via MCP_MOCK (canned pro user + in-process fakes) |
Live Core (core.oll.am) for identity + plan; real siblings behind their X-Service-Token + *_BASE_URL. No Core change. |
oll-model sovereign tier |
Routing policy | Default MODEL_DEFAULT_PRIVACY=any — existing behaviour, zero change. privacy:eu → EU / on-device providers only (self-hosted ollama on our EU / Frankfurt VPS, mock); privacy:on_device → on-device only. US providers (groq, claude) excluded from both. |
Off-by-default EU-managed-provider toggle (metadata only — no backend wired). Honesty note: EU data residency only — never "Swiss-hosted". |
4 · oll-extract — the first extension, in detail
Purpose
Schema-locked structured extraction. Give it text (or input) plus a JSON Schema; it returns validated, typed JSON that conforms to that schema — the messy-language-in, clean-fields-out primitive every document product needs (invoice fields, résumé parsing, contract terms). It calls the Model gateway for inference and never imports a model SDK of its own. It is stateless — no database: a pure request → typed-JSON transform.
Contract
| Endpoint | Request → Response | Notes |
|---|---|---|
POST /api/extract |
{ input, schema, instructions?, provider?, max_tokens? } → { data, provider, model, tokens_in, tokens_out, latency_ms } |
data is the schema-validated typed object; the rest is call provenance for metering & debugging. |
GET /api/health |
→ liveness {status} |
Process-up probe — the Docker HEALTHCHECK target. |
GET /api/health/model |
→ upstream readiness {status} |
Proves the hop to the oll-model gateway is reachable (deep health). |
Error taxonomy
{code,message,request_id} across every failure — the same shape Core and the Model gateway return.| Status | Code | When |
|---|---|---|
| 400 | INVALID_SCHEMA | The caller-supplied JSON Schema is malformed or not a valid schema. |
| 401 / 403 | auth | Missing or wrong X-Service-Token (internal service-token auth). |
| 422 | EXTRACTION_PARSE_ERROR | The model output could not be parsed into schema-valid JSON, even after the one corrective retry. |
| 429 / 502 / 504 | propagated gateway errors | Rate-limit / upstream / timeout from oll-model, propagated with cause rather than masked. |
Key design decisions
- The llm-call-contract pattern. Prompts are named constants with the target schema embedded and an explicit "return ONLY JSON" instruction; the response goes through a defensive fence-strip parse (tolerates
```jsonwrappers), then jsonschema validation. On a validation miss it runs ONE corrective retry (feeding the error back) before returning422. Input is truncated to a safe budget so a huge document can't blow the context. - Mock-default, guarded real provider. Default
mocksynthesizes a deterministic schema-conforming stub with zero gateway calls, so tests and CI need no keys; real providers sit behindEXTRACT_PROVIDER+ gateway env. - Internal service-token auth. Callers present
X-Service-Token; this is a private platform service, not a public endpoint. - Provider-aware boot gate. If a real provider is selected, the required env is asserted at boot — no silent fallback to mock in production.
- Sole gateway HTTP boundary. All model I/O lives in
extract_client.py; routes and the service layer never touch HTTP directly — one seam to test and swap. - Unified error envelope. Every path returns
{code,message,request_id}, so callers handle failures uniformly.
Rigor evidence — the rung actually reached
- Contract-first OpenAPI slice → DTOs
- 33 pytest, keyless
- ruff clean
- docker build 270MB, non-root +
HEALTHCHECKHEALTHY - Real keyless hop to a live oll-model container proven
- Booted inside the CI integration compose
- All
gh pr checksgreen — lint+test, pip-audit, docker boot+health, integration smoke - PR #75 UNMERGED — deploy is Sam's
One deploy note to carry
OLL_MODEL_BASE_URL defaults to http://oll-model:5000, but oll-write points at the gateway on :5003. Set the real gateway port per deploy — this is flagged in .env.example so it isn't missed at wiring time.
5 · oll-ingest — the second extension, in detail
Purpose
Document / vision ingestion — the front-half of the pipeline. Give it a document (text, markdown, PDF or image); it returns clean, normalized text split into overlapping, paragraph-aware chunks with character offsets and metadata. It is the step that turns a messy source into retrieval-ready pieces — its chunks are what oll-rag embeds and searches. Like the other extensions it is stateless — no database: a pure document → chunks transform.
Contract
| Endpoint | Request → Response | Notes |
|---|---|---|
POST /api/ingest |
{ source.{text | content_base64, mime_type, filename}, options.{chunk_size, chunk_overlap, ocr} } → { document_id, mime_type, char_count, page_count, chunk_count, chunks[], provider, latency_ms } |
Either inline text or base64 bytes; options tune chunking; the response carries the chunks plus call provenance for metering & debugging. |
GET /api/health |
→ liveness {status} |
Process-up probe — the Docker HEALTHCHECK target. |
GET /api/health/parser |
→ upstream readiness {status} |
Proves the hop to the real parser is reachable when INGEST_PROVIDER=parser (deep health). |
Error taxonomy
{code,message,request_id} across every failure — the same shape Core, the Model gateway and oll-extract return.| Status | Code | When |
|---|---|---|
| 400 | INVALID_SOURCE | Neither text nor decodable content_base64 present, or the source is empty / malformed. |
| 413 | PAYLOAD_TOO_LARGE | Input exceeds MAX_INPUT_BYTES — checked both pre- and post-decode so base64 can't smuggle a bomb. |
| 415 | UNSUPPORTED_MIME | The mime_type isn't one the selected provider can parse. |
| 401 / 403 | auth | Missing or wrong X-Service-Token (internal service-token auth). |
| 502 | PARSER_UNREACHABLE / PARSER_ERROR | The real parser hop failed or returned an error — propagated with cause rather than masked. |
The chunk shape it emits
oll-rag consumes, so it is worth reading exactly. Each chunk is a self-describing slice of the source that can be embedded and, later, cited back to its exact character span.| Field | Meaning |
|---|---|
index | 0-based position of the chunk in reading order. |
text | The chunk's normalized text. |
char_start · char_end | Half-open character span into the full normalized document — the anchor for citation & highlight. |
page | Source page number, or null for formats without pages (plain text / markdown). |
metadata | Per-chunk context — notably heading, the nearest markdown heading the chunk falls under. |
Guarantees that make it safe to consume:
- Contiguous coverage —
chunks[0].char_start == 0andchunks[-1].char_end == char_count: every character is accounted for, nothing dropped. - Deterministic overlap — adjacent chunks overlap by exactly
chunk_overlapcharacters, so a retrieval hit never straddles a lost boundary. - Stable identity —
document_idis a deterministic sha256 prefix of the normalized content: the same document always yields the same id, givingoll-raga free dedup key.
Key design decisions
- The local chunker does REAL work, not a stub. The keyless default genuinely normalizes and chunks text / markdown — so CI proves the actual behaviour, not a placeholder that a real provider would later contradict.
- Paragraph-aware boundaries. Chunk cuts snap to
\n\nparagraph breaks rather than slicing mid-sentence, so each chunk is a coherent unit — better embeddings, cleaner citations. - Markdown structure is preserved AND recorded. Headings are kept in the text and written into
metadata.heading, so retrieval can weight or filter by section. - Size enforced twice.
MAX_INPUT_BYTESis checked before and after base64 decode — a small encoded payload can't expand into an oversized document past the guard. - Real parser isolated to
parser_client.py. All PDF / scan / image I/O lives behind one seam — arch-test-enforced so routes and the service layer can never reach it directly. One place to test and swap. - Provider-aware boot gate. If
INGEST_PROVIDER=parseris selected, the required parser env is asserted at boot — no silent fallback tolocalin production. - Unified error envelope + service-token auth. Every path returns
{code,message,request_id}; callers presentX-Service-Token— a private platform service, not a public endpoint.
Rigor evidence — the rung actually reached
- Contract-first OpenAPI slice → DTOs
- 40 pytest, keyless
- ruff clean
- docker build 266MB, non-root +
HEALTHCHECKHEALTHY - Verified curl: 3 chunks, contiguous offsets
0..529, exact 40-char overlap,headingmetadata present - Booted inside the CI integration compose
- All 26/26
gh pr checksgreen — lint+test, pip-audit, docker boot+health, integration smoke - PR #76 UNMERGED — deploy is Sam's
One CI note to carry
oll-ingest shares services.yml / integration.yml / docker-compose.ci-integration.yml matrix lines with its sibling extension PRs. If a sibling (e.g. oll-extract #75) merges first, this branch needs a trivial rebase of those shared workflow files — no code change, just re-add the matrix line.
6 · oll-rag — the third extension, in detail
Purpose
Hybrid retrieval over a corpus — the retrieval back-half of the pipeline. Store embedded, chunked documents (oll-ingest's output) and answer a query by fusing vector + keyword search with chunk-level ACL, backed by Postgres + pgvector. Where the other extensions are stateless transforms, this is the first extension that owns its own DB — a corpus persists between an ingest and a query.
Contract
| Endpoint | Request → Response | Notes |
|---|---|---|
POST /api/collections/{collection}/documents |
{ document_id, chunks[], acl_tags? } → { chunk_count, embedded } |
Upsert a document's chunks into a collection — each chunk is embedded and indexed; re-upserting the same document_id replaces its chunks. |
POST /api/collections/{collection}/query |
{ query, top_k?, acl?, filter? } → { results[], retrieval{ vector_hits, keyword_hits, fused } } |
Fuses vector + keyword retrieval; retrieval reports how many hits each arm returned and the fused count, so the ranking is auditable. |
DELETE …/documents/{document_id} |
→ removal {status} |
Drops a document and all its chunks from the collection. |
GET /api/health |
→ liveness {status} |
Process-up probe — the Docker HEALTHCHECK target. |
GET /api/health/db |
→ DB readiness {status} |
SELECT 1 and asserts the vector extension is present — a shallow liveness probe can't miss a mis-provisioned DB (deep health). |
Errors follow the unified envelope {code,message,request_id}: 400 on an oversize query, 401 / 403 on missing / wrong X-Service-Token, plus standard propagated failures — the same shape Core, the Model gateway, oll-extract and oll-ingest return.
How retrieval actually works
- Vector search — chunks are embedded and compared by pgvector cosine distance, accelerated by an HNSW index. Good at meaning: "revenue" matches "turnover" even with no shared word.
- Keyword search — a
GENERATED ALWAYS … STOREDtsvectorcolumn with a GIN index does full-text ranking. Good at exactness: an id, a name, a rare token the embedder blurs. - Reciprocal Rank Fusion merges the two ranked lists — for each chunk,
score += 1 / (RRF_K + rank)in whichever list it appears. A chunk that ranks in both lists accumulates from both and rises to the top, without needing to tune vector-vs-keyword weights. An optional cross-encoder rerank (behindRERANK_PROVIDER, off by default) can re-score the fused shortlist. - Chunk-level ACL enforced IN SQL, not in app code: the query carries the caller's tags and the WHERE clause is
cardinality(acl_tags)=0 OR acl_tags && caller_tags— public-or-intersecting. A chunk the caller may not see is never even fetched, so there is no app-layer path that could leak it.
Where it sits in the pipeline
oll-ingest's chunks verbatim — the shapes match, by design.scoreThe canonical flow: oll-ingest produces chunks → rag_upsert_document stores them under the same document_id and chunk shape → rag_query returns fused results. Each result carries a fused score (higher = better), not a raw distance — the RRF fusion has already normalized the two retrievers into one comparable ranking.
Key design decisions
- HNSW over ivfflat. HNSW needs no training step and works incrementally as chunks are inserted — ivfflat would need a populated table to build its lists, which fights an upsert-as-you-go corpus.
- Boot-time best-effort schema-ensure + lazy ensure.
entrypoint.shensures the schema before gunicorn (mirroring oll-core's alembic-before-gunicorn), and the service also ensures lazily — so the keyless docker-boot gate still boots against a dummy DB, while a real deploy gets its tables and indexes created idempotently. - Deterministic local embeddings. The keyless default is a hashed-feature, L2-normalized vector — so tests are stable AND the cosine maths is real, not stubbed. CI proves the actual ranking behaviour.
- Provider-aware boot gate. If a real embedder / reranker is selected, its env is asserted at boot — no silent fallback to local in production.
- Own private DB (database-per-service).
oll-ragownsoll_rag; it integrates with siblings over HTTP, never by reaching into another service's DB. - Service-token auth + unified error envelope. Callers present
X-Service-Token; every path returns{code,message,request_id}— a private platform service, not a public endpoint.
Rigor evidence — the rung actually reached
- Contract-first OpenAPI slice → DTOs
- 46 pytest incl. 14 DB-backed against real pgvector
- ruff clean
- docker non-root + HEALTHY two ways — real pgvector and keyless dummy DB
- Verified curl: RRF top-1 score 0.0328 (found by both retrievers) vs 0.0161 vector-only
- ACL hides then reveals the tagged chunk; delete works
- Dedicated
test-ragpgvector CI job + integration smoke boots pgvector + oll-rag - All 26/26
gh pr checksgreen — PR #77 UNMERGED
Deploy note to carry
oll-rag needs a new Coolify app and — because it is stateful — a private Neon oll_rag DB with the vector extension enabled (CREATE EXTENSION vector;). It shares the services.yml / integration.yml CI matrix files with its sibling extension PRs, so if a sibling merges first this branch needs a trivial rebase of those workflow files — no code change.
7 · oll-model sovereign tier — the fourth extension, in detail
Purpose
An additive, backward-compatible routing tier that lets a caller require inference to run only on EU-hosted or on-device providers — the honest, sellable "your data never leaves the EU, or your device" capability, made routable. It does NOT add a new model backend; it rides the existing provider switch. Because the Model gateway is a frozen service, the whole design turns on one promise: a caller who ignores the new field sees no change whatsoever.
What's additive
any path is byte-for-byte the old behaviour.| Surface | Addition |
|---|---|
| Request field | Optional privacy: any(default) | eu | on_device on the text call. Absent ⇒ any ⇒ the identical pre-existing code path. |
| Response echo | Additive echo fields privacy_applied, region, on_device — EXCLUDED from the body on the any path (absent, not null), so an existing caller's parsed key set is unchanged. |
| New endpoint | GET /api/text/policy — returns the provider policy matrix: per-provider region / zdr / on_device plus satisfies.eu and satisfies.on_device. This is what a UI reads to draw honest labels. |
| New errors | POLICY_VIOLATION (422) when a caller pins a provider that violates the requested privacy; NO_COMPLIANT_PROVIDER (503) when nothing satisfies it — no silent US fallback, ever. |
The backward-compat guarantee — why a frozen service can take this safely
This is the load-bearing decision, so it is worth stating exactly. A request with no privacy field:
- hits the identical code path as before the change;
- returns the identical key set
{text, provider, model, tokens_in, tokens_out, latency_ms}— the echo fields are simply not emitted; - and the 54 pre-existing tests pass UNCHANGED. Total is now 84 = 54 + 30 new — the old suite is a regression net that proves the frozen behaviour is intact, and the 30 new tests cover only the opt-in tier.
That is what makes an additive tier legitimate on a service we promised never to touch: the old contract is not edited, it is extended past.
The honest posture — what may and may not be claimed
The provider policy metadata is the single source of truth for every claim the UI is allowed to make:
| Provider | Region | On-device | ZDR | Satisfies |
|---|---|---|---|---|
ollama | self-hosted EU (Frankfurt) · eu | yes | yes | eu + on_device |
mock | local | yes | yes | eu + on_device |
groq | US | no | no | — |
claude | US | no | no | — |
Claims allowed: EU data residency · no US endpoint · on-device. NEVER claimed: "Swiss-hosted", CH residency, or a city as a selling point — the VPS being in Frankfurt is a fact of the region tag, not a marketing line. The matrix is env-overridable via MODEL_PROVIDER_REGIONS / MODEL_PROVIDER_ONDEVICE / MODEL_PROVIDER_ZDR, so a future managed EU provider can be added without a code change.
How oll-mcp will use it
The MCP gateway exposes a "Private / EU-only" toggle that simply sets privacy:"eu" or privacy:"on_device" on its model calls, and renders its honest labels by reading /api/text/policy rather than hard-coding provider claims. The label a user sees is therefore always derived from the same metadata the router enforces — the UI cannot drift from the truth.
Rigor evidence — the rung actually reached
- Additive OpenAPI — optional field + new endpoint, no breaking change
- 84 pytest — 54 unchanged + 30 new, keyless
- ruff clean
- docker builds + boots HEALTHY keyless
- Verified curl: no-privacy call unchanged (no echo fields)
- Verified curl:
on_deviceroutes + echoes region - Verified curl:
eu+groq→ 422POLICY_VIOLATION - Verified curl: nothing compliant → 503
NO_COMPLIANT_PROVIDER - Verified curl:
/api/text/policymatrix returns - Integration smoke: no-privacy cross-service proves backward-compat
- All 20/20
gh pr checksgreen - PR #78 UNMERGED — deploy is Sam's
8 · oll-mcp — the fifth extension, in detail
Purpose
A single deployable Streamable-HTTP MCP server that exposes the whole platform as authenticated, entitlement-gated tools for Claude, ChatGPT and Cursor. This is the distribution + defensibility move from the research: the same primitives (extract / ingest / RAG / private inference) that any MCP builder can wire up, but wrapped in the Core billing + identity spine most of them lack — so a caller is a known, paying user, not an anonymous key holder. It is the one ASGI service in the platform (the MCP SDK brings its own uvicorn / Starlette); every other house convention is kept, and it is purely additive — no other service is touched.
Tools exposed
platform_health is free.| Tool | What it does | Gate |
|---|---|---|
extract_structured | Text → schema-validated typed JSON (via oll-extract). | plan-gated |
ingest_document | Document / image → clean, chunked, RAG-ready text (via oll-ingest). | plan-gated |
rag_upsert_document | Embed + index a document's chunks into the caller's collection (via oll-rag). | plan-gated |
rag_query | Hybrid (vector + keyword) retrieval over the caller's corpus, ACL-scoped. | plan-gated |
rag_delete_document | Remove a document and its chunks from the collection. | plan-gated |
complete_text | Model completion — with a Private / EU-only option that sets privacy:eu / on_device on the model call; labels read live from /api/text/policy. | plan-gated |
platform_health | Roll-up readiness of the downstream services. | free |
oll-mcp://policy/privacy | Resource — the provider policy matrix (region / on-device / ZDR) an agent reads to make honest privacy claims. | free |
Auth & entitlement model — the part that teaches
/me — end to end, without modifying frozen Core.- Bearer = a Core JWT. Every
/mcpcall must presentAuthorization: Bearer <core-jwt>. The token is validated live against CoreGET /api/auth/me—oll-mcpnever verifies or trusts the JWT locally, so a revoked or expired identity fails immediately. - Plan from
/me, not from the token. Theplanfield on the/meresponse gates the paid tools (MCP_REQUIRED_PLAN, defaultpro). A free or lapsed user gets a clean "upgrade required" tool error — not a stack trace, not a silent empty result. Mutable entitlement is read live, never baked into a token (the house rule). - Server-side token custody. Core is hit with the caller's Bearer; every other hop (extract / ingest / rag / model) carries that service's own
X-Service-Token, minted server-side. The MCP client never sees a service token — it holds only its own Core JWT. One compromised agent can't reach a sibling directly. - Identity scopes the data. The authenticated user id scopes the default RAG collection and ACL (
u{id}), so one user's corpus is never visible to another by construction — the ACL is enforced in SQL byoll-rag, andoll-mcpsimply passes the right tags. - Per-call usage audit. Every tool call emits a structured audit line (user, tool, downstream, latency) — the seam future metering plugs into without touching the tool code.
- No Core change. All of this rides Core's existing
/api/auth/me; the frozen billing spine is used, not modified.
Key design decisions
- Sole downstream HTTP boundary. Every call to a sibling service lives in
downstream.py— arch-test-enforced so tools and the server layer can never reach a sibling directly. One seam to test, mock and swap. - Mock mode for keyless CI.
MCP_MOCKsupplies a canned pro user and in-process fakes for every downstream, so the full tool suite runs in CI with zero keys and zero network — real behaviour, not a stub that a live provider would later contradict. - Structured per-call usage audit. Emitted for every tool — the metering hook is present from day one, dark until Sam wires it.
- Provider-aware boot gate. If a real downstream or Core URL is selected, its env (base URL + service token) is asserted at boot — no silent fallback to mock in production.
Rigor evidence — the rung actually reached
- MCP Streamable-HTTP server ✓
- 38 pytest, keyless (+5 cross-user scoping regression, hardening pass)
- ruff clean · pip-audit clean
- docker non-root + HEALTHY ~3s (283MB)
- Real MCP client vs booted uvicorn: auth-reject 401
list_tools = 7- ingest → upsert → query chain — top chunk score 0.83
complete_text privacy=eu→ providerollama/ regioneu- free-plan caller → "upgrade required" tool error
- All 26/26
gh pr checksgreen - PR #79 UNMERGED — deploy is Sam's
Deploy note to carry
oll-mcp needs a new Coolify app at mcp.oll.am, real per-service tokens for each downstream hop, and CORE_BASE_URL pointed at core.oll.am. Its integration.yml wiring is deferred — a real oll-mcp → core → services chain can be added to the integration compose once the sibling extension PRs land, so this branch doesn't depend on merge order.
§ · Overnight hardening pass (adversarial review)
oll-rag's chunk-level ACL and oll-mcp's auth / entitlement — with one job: try to REFUTE them, not confirm them. This is the improve-loop closing on itself: assume the build is wrong and go looking for the hole. It found one.Findings
| Seam | Verdict | Action |
|---|---|---|
oll-rag chunk-level ACL |
VERIFIED SOUND | Every attack tried was refuted. No change needed — enforcement is in SQL, private-by-default, and holds on both retrieval arms. |
oll-mcp auth / entitlement |
HOLE FOUND cross-user IDOR · High | A real cross-user read/delete/overwrite was reproduced, then fixed (pin, don't default) with a dedicated regression suite. oll-mcp now 38 tests, PR #79 green. |
That split is the point: the seam that enforced in the database survived; the seam that trusted a client override did not. A genuine bug was caught before deploy, not after a stranger hit it.
1 · oll-rag chunk-ACL — VERIFIED SOUND
- Tag-less / omitted
aclcannot read private chunks. The enforcement iscardinality(acl_tags)=0 OR acl_tags && caller_tags— a chunk is public only when it carries no tags. A caller with an empty tag array overlaps nothing, so it sees only public chunks. Private-by-default: a tagged (private) chunk is invisible unless the caller presents an intersecting tag. - SQL injection via
acl/filter/collection/queryis inert. Every one of those inputs reaches Postgres as a bound parameter, never string-concatenated into SQL — the classic array-overlap and filter payloads do nothing. - No cross-collection leakage. Every read, write and delete pins
collectionin its WHERE clause, so a query against collection A can never surface a chunk stored under collection B, regardless of tags. - ACL enforced on BOTH branches. The same predicate guards the vector arm and the keyword arm before Reciprocal Rank Fusion — a chunk the caller may not see is filtered out of each retriever, so RRF can never resurrect it. A one-sided guard would have been the classic hybrid-search leak; it isn't here.
Scope note (by design, not a gap): within oll-rag a caller may present any tags — it is an internal service behind a service token, and binding tags → identity is the gateway's job, not the store's. That binding is exactly what the oll-mcp finding below is about.
2 · oll-mcp auth / entitlement — a real HOLE, found and FIXED
The bug — cross-user IDOR (High)
The gateway defaulted to the caller's own u{id} collection and user:{id} ACL tag — but then trusted client-supplied overrides. So any authenticated user could pass a victim's collection / acl on rag_query / rag_upsert_document / rag_delete_document and read, delete or overwrite the victim's corpus. Reproduced end-to-end. The oll-rag ACL was doing exactly what it was told — the tags it received were the attacker's chosen tags, because the gateway forwarded them. Default-but-trust-override is not enforcement.
The fix — pin, don't default
- Collection is namespaced under the caller. A client-requested collection is forced under the caller's own
u{id}prefix — it can name a sub-collection, never a sibling's. - ACL is forced to the caller's own tag. No membership / sharing model exists, so client ACLs are never trusted — the tag is set server-side from the authenticated identity, full stop.
- Applied to all three data tools —
rag_query,rag_upsert_document,rag_delete_document— with atest_cross_user_scoping.pyregression proving cross-user read, delete and overwrite are all blocked.oll-mcpis now 38 tests,gh pr checks 79green.
Also attacked, and refuted
- Free / expired / garbage bearer reaching a paid tool. Every paid tool runs through the plan gate (
/me→MCP_REQUIRED_PLAN); onlyplatform_healthis free. A non-pro or invalid identity gets a clean tool error, never data. - Token-custody leak. Service tokens are added only inside the downstream boundary (
downstream.py); errors surface code + message, not headers or URLs — a tool error can't exfiltrate a token or an internal endpoint.
Follow-up hardening also applied
Boot-gate refuses mock auth in production. Defense-in-depth: mock auth mode was opt-in only, so a mis-set env could in principle have booted a real deploy with fake identities. The boot gate now refuses to start in a production configuration with mock auth enabled — the safe default is enforced by the process, not by remembering to set a flag.
9 · Deploy the platform tomorrow — the ordered runbook
oll-memory. #75/#76/#77 don't need separate deploys anymore (superseded by #80); #78/#79 are parked. Here is the whole sequence for Sam, in order.- Merge PR #80 (
oll-memory). It supersedes #75/#76/#77 — close those three when #80 merges. #78 (sovereign tier) and #79 (oll-mcp) stay open but parked; nothing to deploy for them now. - One Coolify app for
oll-memory. Base Directoryservices/oll-memory, matching Watch Paths, honour$PORT, setOLL_MEMORY_SERVICE_TOKENandOLL_MODEL_*(base URL + token) so the/api/extractgeneration hop reaches the oll-model gateway. - Provision
oll-memory's database. It is stateful: create a private Neonoll_memoryDB and enable the vector extension (CREATE EXTENSION vector;) before first boot. - Wire embeddings. Real use runs on self-hosted Ollama
nomic-embed-text— setEMBED_PROVIDER=ollama+OLLAMA_EMBED_BASE_URL(andEMBED_DIM=768). The keyless local-hash default stays for CI; the boot gate asserts the Ollama env when the real provider is selected — no silent fallback. - Deploy stays deliberate. A tag or
deploy-all.yml, health-gated (/api/health/db+/api/health/model), never a merge-to-main. Then curl an ingest → query round-trip against the live app to confirm the semantic win holds with the real embedder.
10 · Phase 2 — a POC product on the extended platform
The five have landed — Phase 2 (Vault Chat) is in progress
With all five extensions green, the payoff is a POC product in a SEPARATE repo that consumes the extended platform end-to-end — ingest a document, extract typed fields, retrieve grounded answers, complete text, meter it through Core, all EU / on-device by default. Tested end-to-end against the live platform, it proves the extensions are reusable, not just present. Phase 2 (Vault Chat) is now in progress — the deliberate next move now that the foundation is real and green.
11 · An honest note on this ledger
This page is a living ledger, written through the night as each service moved Building → Dev-tested → PR-open → CI-green. All five have now landed — but the platform is built, not yet deployed. Nothing here was deployed or merged unattended — every service lands as an unmerged feat/-branch PR with green CI, waiting for Sam to test locally and deploy deliberately (a tag or deploy-all.yml, never a merge-to-main). If a service stalls, its row will say so honestly rather than claim done. The rung each service has actually reached — built / tested / PR-open — is what the Status column reports.
● LIVE LEDGER Source: Beyond the Wrapper — product & platform research · Platform: The Platform · Factory: The Factory · Control Room · Timeline
Built one service at a time, dev-tested, documented. Nothing deployed or merged unattended. Kicked off 2026-07-04.