Install Limma inside your network¶
This is the one page for running Limma in your own environment, with the model inside it.
You receive the image (limma-image-<version>.tar.gz with its checksum,
limma-image-<version>.tar.gz.sha256) and the bundle
(limma-<version>.tar.gz), which holds this page and Data handling
with the rest of the documentation (the operator's pages, docs/integrator/,
docs/pack-author/, docs/examples/ — every page a page in it links to),
the package deploy/customer/ (two compose files, .env.example, Limma's terms —
TERMS.md and terms.json — and THIRD_PARTY_NOTICES.md) and scripts/models/
(the script that serves open weights, and the list of weights it serves). Nothing leaves
your network at runtime; the one-time exceptions are the image
pull and, for the open-weights variant, the weights fetch — and both can be replaced by
copying files in. The same install on one page, with the five tools an agent is given and
how your data goes in: Quickstart.
1. What you get¶
Five containers plus a one-shot fetch:
| container | what it is | network |
|---|---|---|
api |
the agent-facing API (/v1/public/*, the MCP verbs' backend); the only published port |
internal + egress |
worker |
the ingestion worker (python -m epistem.ingestion) |
internal + egress |
postgres |
the store | internal only |
redis |
the durable ingest queue | internal only |
vllm |
open weights on your GPUs (the compose.vllm.yaml variant only) |
internal only |
model-fetch |
stages the weights once (profile fetch) |
egress only |
The limma_internal network is internal: true: postgres, redis and vllm have no route out.
What you need: Docker Engine with Compose v2; for the vLLM variant, the NVIDIA Container Toolkit, the GPUs and the disk the table in section 2 gives, and the right to use the model under its licence.
Who calls it: agents, each holding an API key you write into EPISTEM_API_KEYS. There are
no user accounts in this package — the sign-up and login routes answer 404, and so do the
routes that serve only a signed-in user (minting keys through the API, and the audit
listing by workspace: Keys and roles names them; the audit
trail is read with a key). A session row in the store authenticates nobody here — a
restored backup of a deployment that had accounts brings its users and sessions
tables with it, and nothing reads them. Every
route asks for a key except: the health routes (/health, /health/model,
/health/audit), the API's own description (/openapi.json — the two HTML viewers of
it, /docs and /redoc, are not served on-prem: each is a page whose scripts a reader's
browser fetches from public hosts; open /openapi.json in a viewer you already run), and the
ingestion webhook (/v1/ingest/webhook), which asks for its signature instead and refuses
every call (503) until you configure one. This package configures none — your agents
ingest over MCP — so that door is shut as shipped; to open it, add
EPISTEM_WEBHOOK_HMAC_SECRET (the shared secret a sender signs each call with) to the
api service's environment in compose.yaml. Know what it opens before you do: that ONE
secret lets its holder enqueue a document into ANY workspace of ANY organization with no
API key, and the same signed upload is accepted again (while 1,000 jobs wait in the
queue the door answers 429, until a worker has taken one); it must have at least 32
characters (the boot refuses a shorter one), and it is the only thing between a sender
and your workspaces. An upload that door accepts is recorded in the audit trail of the
workspace's organization — GET /v1/audit/events?event_type=ingestion as an admin key
lists it, action webhook_upload: when, into which workspace, the file's name, size
and SHA-256 (content_sha256), the job, and which secret signed it. The worker that
reads the job writes its own event beside it (ingest_source), which names the document;
that document's file_hash (GET /v1/public/generations) is the upload's
content_sha256. IT CARRIES NO KEY, so the trail cannot name a person or a system —
only "a holder of the secret" — and a call the door refuses is not recorded. (If the
trail cannot be written at that moment the upload is still taken: the API's log has
the failure and /health/audit reads unhealthy until a write succeeds.) An upload for a
workspace that does not exist is refused (404) once its signature has verified. WHAT
THE DOOR READS, AND FOR HOW LONG: one multipart form with ONE file (file) and its
fields, each stated once — a second file, or a field stated twice, is refused (400);
a file of up to 100 MB (LIMMA_MAX_UPLOAD_BYTES; a larger one is 413); a body whose
size is stated (a sender that streams with no Content-Length is 411); and no upload
for longer than 15 minutes (LIMMA_UPLOAD_READ_SECONDS; 408). To change either number,
add it to the api service's environment in compose.yaml, as the secret. A caller
with no valid signature is answered before a byte is read where it sends none, and
otherwise costs the door one upload read to disk in pieces — never held in memory — and
removed when it is refused. What stays yours: a sender that opens many connections and
stalls each holds what it has sent on the container's disk until that time is up; put
the door behind the ingress you already run, with its own limits on connections and
body time.
| the door answers | when |
|---|---|
| 503 | no secret is configured — the door as shipped |
| 401 | the upload carries no signature, or one that does not verify |
| 411 | the body states no size (Content-Length) |
| 413 | the body, or the file in it, is over LIMMA_MAX_UPLOAD_BYTES |
| 400 | the body is not one multipart form with one file and each field stated once |
| 422 | a required field — file, workspace_id — is missing |
| 408 | the upload took longer than LIMMA_UPLOAD_READ_SECONDS |
| 404 | the workspace does not exist (said once the signature has verified) |
| 429 | 1,000 jobs are waiting in the queue |
EVERY OTHER ROUTE ASKS WHO IS CALLING BEFORE IT READS WHAT THEY SENT. A call with no key, with a key that names nobody, or with a key that may not do what the call asks is refused (401 or 403) before its body is read — the same answer in the same words as before, only sooner. One exception, and it is bounded: an agent verb records every call in the audit trail, refused ones too, under the workspace the call named — and that name travels in the body. So a refused call to a verb is read once where it states a size within a request's limit (4 MB, below), for that record alone: the workspace it named and the first 2,000 characters of its question. Nothing else of it is kept. A larger body from a refused caller is never read, and its record names no workspace.
And every body has a limit, by what its route takes:
| a request that carries | may hold | set by | over it |
|---|---|---|---|
a document: POST /v1/public/ingest, the upload routes, the webhook door |
100 MB of document | LIMMA_MAX_UPLOAD_BYTES |
413 |
| anything else: a question, a mapping, a list of claims | 4 MB | LIMMA_MAX_BODY_BYTES |
413 |
A body over its limit is refused by the size it states, before it is read; one that
states none — or sends more than it stated — is counted as it is read and cut at the
same number. The ingest verb carries its document as JSON text, which escapes what it
carries, so its body may be up to twice the document's limit; the document itself is
then held to the limit, measured in bytes. A request to an upload route holds one
document's limit across all the files it carries. Set either number in the api
service's environment in compose.yaml, as a whole number of bytes; a value that is
not one above zero — 0, 200MB, 1e9 — refuses the start, naming the variable (as a
mistyped LIMMA_UPLOAD_READ_SECONDS does). What stays yours here too: the server still receives what a
refused sender goes on sending, and discards it — none of it is held — so a limit on
what one connection may send belongs to the ingress.
Without
a key /health/audit says only whether the
audit trail is being written; with any key it adds each sink's counters and the kind of
its last failure. /health and /health/model are the same: without a key they answer
what step 5 below reads and what a deploy probe compares — the status and whether the
schema is in place, the build's id and version, when the process started and how long it
has been up, the store's kind and whether it answers, the embedder's state (whose it
is, whether it started, whether its probe passed), its model and dimension and the ones
the stored vectors were made with, the queue's kind and whether it is durable, how many
workers are alive, known or degraded and how many ingest threads the API itself runs,
the environment, the version of the terms accepted (deployment.terms_version), the
egress rules in force, whether webhooks and semantic extraction are
on, whether the ingestion webhook's shared secret is set (deployment.
ingest_webhook_shared_secret: unset as shipped — the door above is shut), how far the
map's background migration is (label_map: its state and how many workspaces it found,
observed and could not — counts only), and the
model probe's verdict, whether the endpoint serves the configured model and when that
was read — a closed list, and no more; with any
key they add each worker and the job it is on, the queue's depth, the database's and the
model endpoint's host, the model names and the text of any error.
The image holds its OCR engine (tesseract, English): a PDF page that is only a picture of
text, and a PNG, JPG or TIFF, are read by OCR on this host — no page leaves it (cloud OCR
is refused, E2 below). Text PDFs, office files (DOCX, XLSX, PPTX, and the legacy .doc /
.xls), OpenDocument (.odt, .ods, .odp), HTML, e-mail (.eml and Outlook .msg,
their attachments too), zip archives (each file in it), RTF, Markdown, plain text and JSON
exports are read as well. A file the reader cannot take — a legacy .ppt / .xlsb, a
damaged or password-protected file, text that is not UTF-8, a binary under a text name — is
refused by name: its document reads failed with diagnostic_reason
format_unsupported, and the refusal's words (what to re-save it as) are on the
source_failed event.
2. Choose the model¶
Limma reads your documents with open weights on your own server: a model served inside your network, never a hosted model API. That is Limma's own reader and nothing else — your agents may use any model you choose, hosted or not. They reach Limma over MCP or the API, and nothing on this page constrains them.
Recommended: one GPU. Qwen/Qwen3-30B-A3B-Instruct-2507-FP8 on one GPU with at least
48 GB of memory (an NVIDIA L40S, or larger) — compose.yaml + compose.vllm.yaml. In
.env, remove the # from the COMPOSE_FILE line and set the other three:
COMPOSE_FILE=compose.yaml:compose.vllm.yaml
VLLM_IMAGE=vllm/vllm-openai:v0.29.0@sha256:c2914767605584b6d8f45686b82de173ecc99e781897aa3d0a66dacd72c51ae1
LIMMA_VLLM_ARTEFACT=Qwen/Qwen3-30B-A3B-Instruct-2507-FP8
MAX_MODEL_LEN=32768
Then stage the weights (28.4 GiB, once) and start — steps 3 and 4 of section 3, which is the whole install:
At a 32,768-token window the weights and one sequence's KV cache (3.0 GiB) sit well
inside 48 GB at vLLM's 0.9 memory share (python3 scripts/models/model_access.py sizing
<artefact> <window> prints the sizing for any entry).
More GPUs — the same two compose files, another artefact. LIMMA_VLLM_ARTEFACT names
one safetensors entry of scripts/models/checkpoints.json; there is no default, because it
is your hardware and your licence. The GPU count is the artefact's tensor-parallel size;
with fewer GPUs vLLM fails to start (it never downgrades silently).
| artefact | GPUs | weights | licence | native context | measured by Limma |
|---|---|---|---|---|---|
Qwen/Qwen3-30B-A3B-Instruct-2507-FP8 |
1 (48 GB or more) | 28.4 GiB | Apache-2.0 | 262,144 | as the one-GPU reader, through a hosted endpoint serving this model (2026-10-17), and served on one 48 GB L40S at the pinned vLLM release; not yet measured on its own GPU |
Qwen/Qwen3-235B-A22B-Instruct-2507 |
8 × 80 GB | 437.9 GiB | Apache-2.0 | 262,144 | yes: on 10 synthetic documents about 5 people, through a hosted endpoint serving this model, all 30 expected facts were read and none was wrong (one run); 13 further rows it wrote — where a person is based, what they speak — had no expected answer and were not checked |
Qwen/Qwen3-235B-A22B |
8 × 80 GB | 437.9 GiB | Apache-2.0 | 32,768 (131,072 with YaRN) | no |
Qwen/Qwen2.5-72B-Instruct |
4 × 80 GB | 135.4 GiB | Qwen LICENSE AGREEMENT — not Apache-2.0; read its commercial-use terms | 32,768 (131,072 with YaRN) | no |
Qwen/Qwen3-32B |
2 × 80 GB | 61.0 GiB | Apache-2.0 | 32,768 (131,072 with YaRN) | no |
The served window is MAX_MODEL_LEN: 131,072 tokens when it is left empty, 65,536
selectable, and 32,768 for the one-GPU reader above. An unmeasured artefact is not claimed
to read as well as a measured one.
Open weights you already serve — compose.yaml alone, with your own server inside
your network (vLLM, SGLang, llama.cpp's server: anything that speaks the OpenAI-compatible
protocol): LIMMA_MODEL_BASE_URL (e.g. http://10.0.4.7:8000/v1), LIMMA_MODEL (the
name it serves), and LIMMA_MODEL_API_KEY when it needs one. If the endpoint's host is a
name that does not end .internal or .local, list it in LIMMA_ONPREM_ENDPOINT_HOSTS:
Limma judges names as written and never resolves them, so listing a host is your
statement that it resolves privately. List bare hosts, comma-separated — no scheme, no
port.
What the endpoint must do:
- answer
POST /chat/completionswithtoolsand a forcedtool_choice(function calling). Without it the probe of step 5 still readsok— it cannot see this — and then a search by brief answers 502 and documents are not read; - answer
GET /modelswith the name inLIMMA_MODEL: the probe reads it (serves_configured_model); - serve a window large enough for one question. Limma's own serving script offers 131,072 tokens (its default) and 65,536, and the one-GPU reader above is served at 32,768.
3. Install in ten minutes, once the weights are staged¶
Staging the weights (61–438 GiB) takes as long as your link takes; everything else is minutes.
- Verify the image, load it, and read its reference. The image Limma ships you is its
on-prem build (built by Limma with
docker build --target onprem): the engine is compiled, and it starts only under the license file Limma issues you (docs/operator/licensing.md). It arrives as two files:limma-image-<version>.tar.gz(the image asdocker savewrote it, compressed) andlimma-image-<version>.tar.gz.sha256(its SHA-256).- Verify it first, in the directory that holds both files:
sha256sum -c limma-image-<version>.tar.gz.sha256must printlimma-image-<version>.tar.gz: OK(on macOS:shasum -a 256 -c limma-image-<version>.tar.gz.sha256). Anything else — a mismatch, a missing file — and you do not load it: ask Limma for the files again. - Load it:
docker load -i limma-image-<version>.tar.gz(Docker reads the compressed file as it is) printsLoaded image: limma:<version>. A loaded image has no repository digest, so checkdocker image inspect --format '{{.Id}}' limma:<version>against the image ID in the release note. - Then set
LIMMA_IMAGEin.env(step 2). Registry-less (one host, or an air gap): the tag,limma:<version>. With a registry, tag and push it —docker tag limma:<version> <registry>/limma:<version>, thendocker push <registry>/limma:<version>— and use the digest the push prints,<registry>/limma@sha256:<64 hex>. - The image carries the bundle's
TERMS.md,terms.jsonandTHIRD_PARTY_NOTICES.mdat/app/deploy/customer/:docker run --rm --entrypoint cat limma:<version> /app/deploy/customer/TERMS.md. - In an air gap, mirror or load the Postgres, Redis and vLLM images the same way
(
POSTGRES_IMAGE,REDIS_IMAGE,VLLM_IMAGE). An image loaded from a tar may carry no repository digest (docker image inspect --format '{{.RepoDigests}}' postgres:16-alpineprints[]), and then a reference pinned by digest does not resolve on that host anddocker compose uptries to pull it: set the tag it was loaded under (postgres:16-alpine) and compare its image ID (docker image inspect --format '{{.Id}}' postgres:16-alpine) with the one on the machine that pulled it, as for Limma's image above. - The database is the stock Postgres image, without pgvector, on purpose: Limma's
pgvector columns are
vector(1024), the hosted embedder's dimension, while the on-premises embedder's vectors have 768, so they are kept in the portableembedding_jsoncolumn instead — do not add pgvector.
- Verify it first, in the directory that holds both files:
- Read the terms, then configure.
TERMS.md, besidecompose.yaml, is Limma's Software License and Terms of Service (On-Premises);terms.jsonbeside it states its version, the governing law, the venue and Limma's contact for notices, andTHIRD_PARTY_NOTICES.mdlists the licences of what the image includes and of the open weights it can serve. Read them, then setLIMMA_TERMS_ACCEPTEDin.env(below) to the versionTERMS.mdstates,LIMMA_TERMS_ACCEPTED=<version>: that is your acceptance, on your organization's behalf. It ships empty; while it is empty or names another version, the API and the worker refuse to start, and the refusal names the file to read and the exact value to set.
cp .env.example .env. Choose the variant: leave COMPOSE_FILE unset for
your own endpoint, or set COMPOSE_FILE=compose.yaml:compose.vllm.yaml. Fill the
[common] section and that variant's section only:
* common: LIMMA_IMAGE, POSTGRES_IMAGE, REDIS_IMAGE, POSTGRES_PASSWORD,
REDIS_PASSWORD, SESSION_SECRET, EPISTEM_API_KEYS (an admin key and an analyst
key, each written key:organization:role), LIMMA_APP_BASE_URL,
LIMMA_TERMS_ACCEPTED. Generate every secret with openssl rand -hex 32 (the terms'
version is typed, not generated);
* your endpoint: LIMMA_MODEL, LIMMA_MODEL_BASE_URL, LIMMA_ONPREM_ENDPOINT_HOSTS;
* your GPUs: VLLM_IMAGE (vllm/vllm-openai:v0.29.0@sha256:<digest> — the release
serve_vllm.sh verifies; any other version refuses to start), LIMMA_VLLM_ARTEFACT,
VLLM_API_KEY;
* optional: LIMMA_MODEL_API_KEY, LIMMA_MODEL_EXTRA_BODY,
EPISTEM_CONNECT_ALLOWED_HOSTS, LIMMA_API_BIND, LIMMA_API_PORT, MAX_MODEL_LEN,
HF_TOKEN, LIMMA_LOCAL_MODEL_RATE_USD_PER_MTOK_IN,
LIMMA_LOCAL_MODEL_RATE_USD_PER_MTOK_OUT, LIMMA_MCP_TOOLS (the tools your agents
are offered: empty is core, the five product tools; add a tier or a tool by name,
e.g. core,limma_ask for finished answers — MCP over HTTP).
(The two rates price the cost meter. In this release the meter's records are
kept in each process's memory — the API's for a search, the worker's for an
ingest — and no route reports them yet.) LIMMA_CHECKIN (off sends no usage
check-in) and LIMMA_CHECKIN_PROXY: see Licensing.
LIMMA_INGEST_DOCUMENT_BUDGET_S (the most seconds one document's reading may take;
empty is the ingest job's wall less 60 s — past it the document ends failed with
reading_budget_exceeded, what its reading wrote is erased, and the worker reads
the next one) and LIMMA_MODEL_OUTPUT_TOKENS_PER_S (your endpoint's slowest reply
rate, in tokens a second; empty caps no reply — set, a reply that cannot finish
inside the model timeout asks for one that can; measure it on your endpoint first);
* the license: put the license.json Limma sent you at license/license.json beside
compose.yaml (Licensing) — a licensed image does not start
without it, and says why.
The API refuses to boot while any value still contains `REPLACE_ME`, while any key
or `SESSION_SECRET` is shorter than 32 characters, and while `EPISTEM_API_KEYS` holds
no usable key or an entry that is not one (no key, a key holding a space, a tab or
any character that is not printable ASCII — a paste can carry an invisible one —
no organization or one written with such a character, a role that is not `admin`,
`analyst`, `service` or `viewer`, a scope field that is written and empty or names
an id with such a character, a quote, a backtick, an angle bracket or a backslash
anywhere in it — write each value alone, without what a page wraps it in — the
variable's own name pasted into its value, or a sixth field). Write all
three parts of every entry — `key:organization:role`: here an entry with FEWER
refuses the boot (elsewhere a bare key is the legacy form, an ADMIN of the
organization `default`, and `key:organization` an ADMIN of that organization). To
confine a key, add `:<workspace ids>` and `:<document ids>` — the ids of each
JOINED WITH `|`, never with a comma: the comma separates entries, so
`…:viewer:<ws 1>,<ws 2>` is a viewer key scoped to the first workspace and, after
the comma, an entry that is only an id (refused, by its position — it would have
been an admin key that every caller of that workspace sends).
- Stage the weights (vLLM variant):
docker compose --profile fetch run --rm model-fetch. In an air gap, copy a Hugging Face cache holding the artefact at its pinned revision into themodel-weightsvolume instead. - Start:
docker compose up -d. -
Check it. Every line below must hold:
curl -s http://127.0.0.1:8000/health database.backend "postgres", healthy true deployment.environment "onprem" deployment.terms_version the version you accepted (LIMMA_TERMS_ACCEPTED) deployment.egress_rules ["E1", ..., "E10", "model_private_host", "webhook_private_host"] deployment.webhooks "disabled" deployment.semantic_extraction.state "on" (section 9) ingest_workers.alive 1, not degraded curl -s http://127.0.0.1:8000/health/model probe "ok", serves_configured_model true curl -s -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:8000/v1/public/search \ -H 'Content-Type: application/json' -d '{"workspace_id": "x", "brief": "x"}' 401 (no key); with -H 'X-API-Key: wrong' it is 403A model still loading (a 235B load takes many minutes) reads
probe: "unreachable": seedocker compose logs vllm. Search by constraints works meanwhile, and ingest jobs wait durably in Redis. On the vLLM variant the worker starts only once the model is healthy: until theningest_workers.alivereads 0 anddeployment.semantic_extraction.statereads"unknown", and those two lines hold as soon as it is up. Then run the smoke, which makes those checks and four more in about a minute. Which command depends on whatLIMMA_IMAGEnames: the tagdocker loadgave the image (limma:<version>, one host or an air gap) needs--registry-less, without which theimage_pinnedcheck fails; a registry digest needs nothing more:docker compose exec api python -m epistem.deploy smoke --bundle bundle_expert_network --registry-less docker compose exec api python -m epistem.deploy smoke --bundle bundle_expert_network(the first for
LIMMA_IMAGE=limma:<version>, the second forLIMMA_IMAGE=<registry>/limma@sha256:<64 hex>)It creates a workspace named
limma-smoke-<run>, connects three invented records from a file it writes into./imports, searches them, deletes the workspace and reads back that nothing of it is left. It makes no model call of its own. The smoke itself needs no pack: its pull declares the relations of its own three records (document_schema).--bundle <bundle id>names the signed bundle this deployment is meant to carry — the expert-network pack here — and thepackcheck applies it first, so its signature is verified by the image you are running; leave the flag out and that one check is not made. One line per check, thensmoke: PASSED(exit 0) or the names of the checks that failed (exit 1):check holds when leftoversno workspace of an earlier smoke that died is left (one older than 30 minutes is deleted and named) healthPostgres is the store and healthy; deployment.unclassified_tablesis[];deployment.schema_faults.countis 0; a worker is alive and not degradedauth_enforceda search with no key is answered 401 and with a wrong key 403 image_pinnedLIMMA_IMAGEis pinned by digest (--registry-lessaccepts a tag and tells you what to compare)workspacethe admin key creates a workspace under its own organization pack(with --bundle) the named bundle applies to the smoke's workspace and declares relationsconnect,searchthe export's 4 posts and 3 record rows are written and nothing is quarantined; 2 of the 3 records state Germany and 1 does not — each number computed from the records, not written beside them model/health/modelreadsprobe: "ok"and serves the configured modelerasurethe delete answers 200 with residue: {}andjobs_remaining: []While the model is still loading,
--no-model(and, on the vLLM variant, where the worker waits for the model,--no-worker) checks everything else.--keepleaves the workspace for you to look at.--registry-lessis for an image loaded from a tar. A smoke that cannot start (no keys in its environment, an API that does not answer) exits 2 and says which. It reads the deployment's keys fromEPISTEM_API_KEYS(the first unscoped admin key and analyst key), writes its export to/data/imports(--imports-dir, orLIMMA_SMOKE_IMPORTS_DIR, for another directoryconnectmay read), and speaks tohttp://127.0.0.1:8000(--base-url). 6. Create a workspace with the admin key; it is bound to the key's organization:Without a key the answer is 401; a key whose role cannot manage the catalogue gets 403.
GET /api/workspaceswith a key lists its organization's workspaces. 7. Attach an agent over MCP. The image is the value you gaveLIMMA_IMAGEin.env(your shell does not hold it — write it out):docker run -i --rm --network limma_limma_egress \ -e LIMMA_API_BASE_URL=http://api:8000 -e LIMMA_API_KEY=<analyst key> \ <LIMMA_IMAGE from .env> python -m epistem.mcp.serverThe MCP server dials
LIMMA_API_BASE_URLdirectly. It takes no proxy from its environment: a Docker client configured with aproxiesblock setsHTTP_PROXYin every container it starts, and the server would otherwise have handed that proxy the key and each document. (The SDK is a library in YOUR process and follows your process's proxy settings, as any HTTP client does: setNO_PROXY=apithere if your environment names a proxy.)
4. What leaves your network¶
Nothing of your content, ever. One channel leaves by design: a licensed deployment's
usage COUNTS and license state, sent to the check-in URL written into the license it runs
under — LIMMA_CHECKIN=off stops it, and the monthly file replaces it; every field it
carries is listed in Licensing. Every place the code in the image opens a
connection is on this list, each with the rule that closes it in this package. A test reads every Python file
the image ships and fails when a call that opens a connection appears unlisted; it knows
the standard library's and the image's dependencies' ways of doing so, by name and through
import aliases. It is a check, not a proof — hence the last bullet of this section.
| channel | reaches | in this package |
|---|---|---|
model |
your model endpoint (LIMMA_MODEL_BASE_URL) | model_private_host: a private or listed host only, dialled directly (no proxy from the environment) |
anthropic_direct |
api.anthropic.com | refused: the provider is locked to openai_compat and ANTHROPIC_API_KEY is refused at boot |
bedrock |
AWS Bedrock (models and Titan embeddings) | refused: the provider lock and LIMMA_PROFILE=onprem |
webhook |
webhook callback URLs an operator registers | off (EPISTEM_DISABLE_WEBHOOKS=1); when switched on, webhook_private_host at registration and delivery, http(s) only, no proxy, no redirect followed |
hf_hub |
huggingface.co (embedder and GLiNER weights) | E1: HF_HUB_OFFLINE=1 required |
textract |
AWS Textract (OCR) | E2: OCR_PROVIDER=textract or documentai refused (and E5) |
s3_audit |
Amazon S3 (the audit trail) | E3: LIMMA_AUDIT_SINK=s3 refused |
cloudwatch |
Amazon CloudWatch (audit metrics; built only by the S3 audit sink) | E3 and E5 |
s3_sources |
Amazon S3 (document bytes) | E4: LIMMA_SOURCE_BACKEND=s3 refused |
ses |
Amazon SES (email) | E5: AWS_REGION, AWS_DEFAULT_REGION and LIMMA_SES_REGION refused |
stripe |
api.stripe.com (hosted billing: the checkout, the customer portal, confirming a payment) | hosted_only: every billing route answers 404 billing_not_served, the Stripe client refuses to be built, and E5 refuses STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET at boot |
secretsmanager |
AWS Secrets Manager (the previous session secret) | E5 |
store_database |
the store's database (DATABASE_URL / DATABASE_HOST) | E6: a private or listed host only |
redis |
Redis (REDIS_URL): the ingest queue and the rate limiter | E7: a private or listed host only |
connect_sql |
a SQL database an operator connects (the connect verb) | operator_listed: only hosts in EPISTEM_CONNECT_ALLOWED_HOSTS, by hostname; an IP literal is refused even when listed |
webhook_sink |
an audit WebhookSink URL | unwired: nothing in epistem/ constructs a WebhookSink |
cloud_storage |
S3 / Google Cloud Storage / Azure Blob connectors | stranded: no product module imports epistem.ingestion.cloud_storage |
sync_sources |
your own S3, Azure Blob or Google Cloud Storage bucket, Google Drive folder or SharePoint library, read by the sync command | client_side: only python -m epistem.sdk sync with an s3://, az://, gs://, gdrive:// or sharepoint:// source opens it — run by you, for the bucket or drive you name, with the credentials of the machine it runs on; the API and the worker never import it |
inbound_client |
this Limma API, from an agent (the MCP server, the SDK) or from the operator's deploy smoke | inbound_client: dials LIMMA_API_BASE_URL, the API itself — the MCP server directly, taking no proxy from its environment; the smoke dials the API it is pointed at (--base-url, by default the one in its own container), likewise with no proxy |
in_process |
the API itself, in process (audit replay, and the tool calls of the MCP endpoint POST /mcp) | in_process: an ASGI transport, no socket |
license_checkin |
Limma's check-in URL, named in the signed license: usage COUNTS and the license state every interval_hours, never content | off: LIMMA_CHECKIN=off sends nothing (send the monthly file instead); otherwise to the license's URL only, no proxy from the environment (LIMMA_CHECKIN_PROXY names one), no redirect followed |
local_process |
a program on this machine: git, asked for the commit id the image was built from | local: it opens no connection, and runs only when GIT_SHA is unset — the image sets it |
window |
the demo window's files — in Limma's hosted image, NOT in the on-prem image: this API, for its side-by-side comparison api.anthropic.com and huggingface.co, and — from the BROWSER of whoever opens the page — cdnjs.cloudflare.com (the PDF viewer's script) | not_started: no service of this package runs the window. Started by hand, its comparison answer is refused on-prem before any client is built, HF_HUB_OFFLINE=1 stops the download, its agent's model is the model channel above, and the page's PDF viewer is fetched by the browser, not by the server (without a route to it the viewer does not load) |
The boot rules, all refused by name when LIMMA_ENV=onprem: E1 HF_HUB_OFFLINE must
be exactly 1; E2 no cloud OCR; E3 no S3 audit sink; E4 no S3 source backend;
E5 no AWS region variable and no Stripe secret (STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: an install sells no key); E6 the database host and E7 the Redis host must be
private or listed; E8 EPISTEM_ENFORCE_RBAC may not switch workspace isolation off;
E9 no environment value may still contain REPLACE_ME; E10 every entry of
EPISTEM_API_KEYS is key:organization:role (three fields or more), and every API key,
SESSION_SECRET and — where you set one — the ingestion webhook's shared secret has at
least 32 characters. /health lists them under
deployment.egress_rules.
- One-time egress: the image pull (none, when you
docker load) andmodel-fetch(none, when you copy the Hugging Face cache in). connectdials only the SQL hosts you list inEPISTEM_CONNECT_ALLOWED_HOSTS, by hostname: an IP literal is refused even when listed, so give the database a name (your DNS, or anextra_hostsentry on theapiservice). Export files (JSON / JSON lines andmongoexportfiles, CSV / TSV, XLSX) and SQLite files are read from./imports(/data/importsin the container) and from nowhere else; a file route dials nothing. The image carries the drivers for PostgreSQL, MySQL / MariaDB (PyMySQL:mysql+pymysql://user:password@host:3306/database) and SQLite.- Webhooks are off (
EPISTEM_DISABLE_WEBHOOKS=1incompose.yaml). To switch them on, remove that line. Callbacks must then be private or listed hosts: registration answers 422 otherwise, and delivery refuses such a row whenever it was written. A callback is anhttp://orhttps://URL and nothing else. Delivery never uses anHTTP_PROXY/HTTPS_PROXYfrom the environment and never follows a redirect. A subscription belongs to the organization of the key that registered it: it is sent that organization's workspaces' events and no other's, and only that organization's keys list or remove it. - The demo window is not in this image. The image you install is Limma's on-prem build: compiled, licensed, and without the hosted demonstration's files.
- For a network-level guarantee, block egress from the Docker host except those hosts. These rules are the application's; only a firewall binds a compromised container.
5. Updates¶
Back up first (section 7). Verify and load the new image (section 3, step 1), set the new
LIMMA_IMAGE, docker compose up -d, then repeat the checks of section 3, step 5. A
release that ships a new version of the terms does not start until
LIMMA_TERMS_ACCEPTED names it: read its TERMS.md first. The API brings the schema
forward when it starts: the changes
are additive, idempotent and forward-only, and the worker waits for the API to be healthy,
so two processes never apply them at once.
A key list an earlier build accepted may be refused by a newer one, and the refusal says
which entry and why (section 10). One case needs a decision of yours: a key written alone,
with no organization and no role, ran as an admin of the organization default, and the
workspaces it created belong to default. Write it as <key>:default:admin to keep
them reachable; under another organization's name the key is answered 403 at each of them.
Postgres: leave idle_in_transaction_session_timeout unset (the image's default, 0), or set
it above your longest connect — a connect writes one transaction for its whole pull, and a
timeout would abort it.
6. Rollback¶
The previous LIMMA_IMAGE plus the backup taken before the update: an older image on a
newer schema is untested. (Nothing else rolls an install back: the tooling of Limma's own
hosted deployment does not apply here and is not in the bundle.)
7. Backups¶
- Postgres:
docker compose exec -T postgres pg_dump -Fc -U limma limma > limma.dump. Restore into a fresh volume withdocker compose exec -T postgres pg_restore -U limma -d limma --clean --if-exists < limma.dump, with the api and worker stopped. (Without--if-existsa restore into a fresh volume reports an error for every object it could not drop first; the data is restored either way.) - The
source-datavolume: the documents' bytes. .env: your secrets.docker compose down -vdeletes every volume — all of the above.
8. Deleting a workspace¶
DELETE /api/workspaces/{workspace_id} with the admin key. Only an admin key can: the
analyst key your agents carry is refused (403).
The delete is proven by enumeration. The tables are read from your database's own
schema — every table that carries a workspace's rows, not a list kept in the code — and
after the commit the reply's erasure.residue says, per table, how many rows of the
workspace are still there. 200 means residue is {} and jobs_remaining is [].
The reply's fields, the refusals (409 legal_hold, 409 workspace_busy, 500
unclassified_table, 500 erasure_incomplete) and what to do about each are on
Data handling, with the table of
where personal data is held.
What it reaches beyond the tables:
- The documents' bytes in the
source-datavolume: everything under the workspace's directory, whether or not a row names it. If the bytes cannot be removed the whole delete is rolled back and the same request finishes it. - The ingest queue. A document waiting to be read sits in Redis with its bytes; a job
a worker has finished stays there for an hour and one that failed for a day. Every job
of the workspace that is not running is deleted with it. A job a live worker is
running refuses the delete with 409
workspace_busy(its queued jobs are cancelled first, so there is no backlog to wait out); a job whose worker died does not. - The audit trail (
audit_events): each row is kept — who called which verb, when, with what status — and its content is erased (the recorded request, the question, the ids returned, the actor a caller typed). A redacted call cannot be replayed (409replay_unavailable, reasonworkspace_erased).
Before you delete anything, /health (with a key) says whether this database can be
erased from:
deployment.unclassified_tablesmust be[]. A table named there is one the erasure census cannot place — a table of yours, or of a restore tool, in Limma's database. One with noworkspace_idcolumn makes every workspace delete refuse (500unclassified_table, nothing touched). Drop a table that is not Limma's, or install a release that classifies it.deployment.schema_faults.countmust be 0. Anything else names a DDL statement or a migration that failed when the API started (section 10 lists what is expected in the logs and is not a fault).
The smoke (section 3, step 5) fails on either.
What a delete does not reach:
- Your files in
./imports. Limma never deletes a file you put there. Keep an export there while its workspace lives —connectre-reads rows from it for every receipt — and connect it again after an update or a restart, until which its receipts readrow_unreadable. Delete the file yourself when you delete the workspace. - Backups taken earlier, Postgres dead tuples until vacuum, Redis append-only history
until it is rewritten, container logs, process memory until a restart (the API keeps
recent audit events in memory for download), and your agents' own transcripts. What a log line holds of a document
is its name, its size and the ids it was filed under. It does not hold the document's
bytes or its text, a model's reply about it, or what the model endpoint answered an
error with: where one of those must be named, the line gives its length and a
reference (
<2177 characters, ref 1a2b3c4d5e6f>) that tells two contents of one process's log apart and is keyed by a secret the process makes when it starts — it cannot be checked against a guess of the content, and it is another in the next process; an endpoint's error is quoted by its type and code where each is one word, and by its message only where the message repeats no run of 24 letters and digits of what was sent, and — with the digits left out of both, so a request that numbers its sentences hides no echo of them — no run of 24 letters (compared with the case folded and everything but letters and digits dropped; an echo written backwards or in base64, or one that keeps only some of the words it repeats, is not found); a failed job is logged with its traceback and with its content replaced. A structure in a log line is written with its content replaced; an object that has a text form of its own (a database URL, a set of headers) is written by that form and never field by field. Two limits. A line that reports a DECISION about an extracted value names that value — a table's line item that lost a merge, the registrant a filing names (Merge-REJECTED …,Registrant recorded …) — so give container logs the access and the retention you give the database. And this is the level the package runs at:LOG_LEVEL=DEBUG, which the package does not set, is for development and is not held to it. - One expert inside a workspace that lives on: that is a document delete, which does not redact the audit trail (Data handling).
9. Semantic extraction¶
It is on: the prose-clause layer is built from your documents. Its embedder
(sentence-transformers/all-mpnet-base-v2, 768 dimensions, Apache-2.0) is IN THE IMAGE, at
the revision THIRD_PARTY_NOTICES.md names, in its Hugging Face cache (HF_HOME), and is
loaded with nothing downloaded (HF_HUB_OFFLINE=1). The api and the worker each load it at
start and REFUSE TO START when it does not load (section 10) — the layer is never silently
off. /health reports deployment.semantic_extraction "on", and facts reports
coverage.semantic_extraction the same way.
It costs CPU embedding of every clause of every document in the api and the worker (about 1 GB resident each) and a model call per novel clause for its label.
10. When it refuses to start¶
Every boot refusal names its variable: read docker compose logs api. The app's names map
to your .env names:
| the refusal names | set in .env |
|---|---|
DATABASE_PASSWORD |
POSTGRES_PASSWORD |
database_url resolves to … SQLite |
nothing in .env: compose.yaml names the database by DATABASE_HOST, DATABASE_NAME, DATABASE_USER and DATABASE_PASSWORD — one of them was removed or emptied, or a DATABASE_URL that names SQLite was added (it overrides the four) |
REDIS_URL |
REDIS_PASSWORD — letters and digits only (openssl rand -hex 32). The same text is the Redis server's own password, so a character a URL would need encoded (@, /, #, ?) cannot be used here at all: encoded as the refusal says, the server holds a different password and the next boot answers AuthenticationError |
LIMMA_MODEL_API_KEY |
VLLM_API_KEY (vLLM variant) or LIMMA_MODEL_API_KEY |
EPISTEM_API_KEYS key key_… |
the key with that id in EPISTEM_API_KEYS (the id, never the key, is printed) |
EPISTEM_API_KEYS entry 2 … |
the second entry of EPISTEM_API_KEYS, counting the entries that are not empty (a,,b: b is entry 2) — its position, never its text, is printed |
LIMMA_ONPREM_ENDPOINT_HOSTS entry 2 … |
the second host you listed: a bare host, no scheme and no port |
PGHOSTADDR, PGSERVICE |
not from .env: libpq variables in the container's environment; unset them |
the semantic embedder (LIMMA_PROFILE=onprem) does not load |
nothing in .env, unless you added EPISTEM_DISABLE_EMBEDDER (remove it) or HF_HOME (remove it: the weights are in the image's own cache). Otherwise the image was built without its weights: use the release's image |
LIMMA_MODEL is unset and LIMMA_MODEL_BASE_URL is unset together usually mean the vLLM
variant was intended: set COMPOSE_FILE=compose.yaml:compose.vllm.yaml. host 'vllm' is not
private means LIMMA_ONPREM_ENDPOINT_HOSTS was edited in compose.vllm.yaml.
EPISTEM_API_KEYS names no usable key means the variable holds only separators — a ,
left by two unset secrets, for instance. Limma refuses to start: the license at … is the
license file: Licensing says where it goes and what each refusal means.
LIMMA_TERMS_ACCEPTED is unset or LIMMA_TERMS_ACCEPTED does not name version … is the
terms: read TERMS.md and set the version the refusal names.
Once it is up, /health, /health/model and
deployment.egress_rules say what runs.
Expected in the logs, and not a fault: pgvector CREATE EXTENSION failed at each start (the
stock Postgres image has no pgvector, on purpose — section 3, step 1: the on-premises
embedder's 768-dimensional vectors are kept in embedding_json).
11. Before a build of this package ships: the checks that need Docker¶
The erasure half of the package was built and tested without a container runtime (the
app through its test client, on SQLite and on Postgres 16). These are the checks that
need one; run them on a machine with Docker and a built image before a release, in a
scratch directory with a filled .env:
docker compose config --quiet— the compose files parse with your.env.docker compose up -d, thendocker compose ps—postgres,redis,apiandworkerare healthy.docker compose exec api python -m epistem.deploy smoke --no-model --bundle bundle_expert_network --registry-less(a built or loaded image is a tag, not a digest) — every check isok(thepackcheck among them: the image verifies and loads the signed bundle) and the last line issmoke: PASSED. This is the erasure proven on the package's own Postgres, Redis andsource-datavolume:erasuremust reportresidue {}andjobs remaining [].- The must-fail:
docker compose exec -e EPISTEM_API_KEYS= api python -m epistem.deploy smokeexits 2 and says the smoke has no keys. - A foreign table is seen:
docker compose exec postgres psql -U limma -d limma -c "CREATE TABLE legacy_foreign (id text)",docker compose restart api, thencurl -s -H 'X-API-Key: <admin key>' http://127.0.0.1:8000/healthreadsdeployment.unclassified_tables: ["legacy_foreign"], the smoke'shealthcheck fails naming it, and aDELETE /api/workspaces/<id>answers 500unclassified_table.DROP TABLE legacy_foreign, restart, and both are clean again. - The queue is reached: with the worker stopped (
docker compose stop worker), upload a document to a workspace, delete the workspace, anddocker compose exec redis redis-cli -a <REDIS_PASSWORD> --scan --pattern 'rq:job:*'lists no job of it; the delete's reply readsjobs_forgotten: 1. - The bytes are gone: the
source-datavolume holds no directory named by the deleted workspace's id (docker compose exec api ls /data/sources). - With the model up, the smoke without
--no-modelpasses itsmodelcheck. docker compose down -vin the scratch directory.