Skip to content

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:

docker compose --profile fetch run --rm model-fetch
docker compose up -d

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/completions with tools and a forced tool_choice (function calling). Without it the probe of step 5 still reads ok — it cannot see this — and then a search by brief answers 502 and documents are not read;
  • answer GET /models with the name in LIMMA_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.

  1. 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 as docker save wrote it, compressed) and limma-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.sha256 must print limma-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) prints Loaded image: limma:<version>. A loaded image has no repository digest, so check docker image inspect --format '{{.Id}}' limma:<version> against the image ID in the release note.
    • Then set LIMMA_IMAGE in .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>, then docker 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.json and THIRD_PARTY_NOTICES.md at /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-alpine prints []), and then a reference pinned by digest does not resolve on that host and docker compose up tries 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 portable embedding_json column instead — do not add pgvector.
  2. Read the terms, then configure. TERMS.md, beside compose.yaml, is Limma's Software License and Terms of Service (On-Premises); terms.json beside it states its version, the governing law, the venue and Limma's contact for notices, and THIRD_PARTY_NOTICES.md lists the licences of what the image includes and of the open weights it can serve. Read them, then set LIMMA_TERMS_ACCEPTED in .env (below) to the version TERMS.md states, 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).
  1. 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 the model-weights volume instead.
  2. Start: docker compose up -d.
  3. 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 403
    

    A model still loading (a 235B load takes many minutes) reads probe: "unreachable": see docker 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 then ingest_workers.alive reads 0 and deployment.semantic_extraction.state reads "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 what LIMMA_IMAGE names: the tag docker load gave the image (limma:<version>, one host or an air gap) needs --registry-less, without which the image_pinned check 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 for LIMMA_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 the pack check 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, then smoke: PASSED (exit 0) or the names of the checks that failed (exit 1):

    check holds when
    leftovers no workspace of an earlier smoke that died is left (one older than 30 minutes is deleted and named)
    health Postgres is the store and healthy; deployment.unclassified_tables is []; deployment.schema_faults.count is 0; a worker is alive and not degraded
    auth_enforced a search with no key is answered 401 and with a wrong key 403
    image_pinned LIMMA_IMAGE is pinned by digest (--registry-less accepts a tag and tells you what to compare)
    workspace the admin key creates a workspace under its own organization
    pack (with --bundle) the named bundle applies to the smoke's workspace and declares relations
    connect, search the 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/model reads probe: "ok" and serves the configured model
    erasure the delete answers 200 with residue: {} and jobs_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. --keep leaves the workspace for you to look at. --registry-less is 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 from EPISTEM_API_KEYS (the first unscoped admin key and analyst key), writes its export to /data/imports (--imports-dir, or LIMMA_SMOKE_IMPORTS_DIR, for another directory connect may read), and speaks to http://127.0.0.1:8000 (--base-url). 6. Create a workspace with the admin key; it is bound to the key's organization:

    curl -s -X POST http://127.0.0.1:8000/api/workspaces -H 'X-API-Key: <admin key>' \
         -F name=pilot
    

    Without a key the answer is 401; a key whose role cannot manage the catalogue gets 403. GET /api/workspaces with a key lists its organization's workspaces. 7. Attach an agent over MCP. The image is the value you gave LIMMA_IMAGE in .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.server
    

    The MCP server dials LIMMA_API_BASE_URL directly. It takes no proxy from its environment: a Docker client configured with a proxies block sets HTTP_PROXY in 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: set NO_PROXY=api there 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) and model-fetch (none, when you copy the Hugging Face cache in).
  • connect dials only the SQL hosts you list in EPISTEM_CONNECT_ALLOWED_HOSTS, by hostname: an IP literal is refused even when listed, so give the database a name (your DNS, or an extra_hosts entry on the api service). Export files (JSON / JSON lines and mongoexport files, CSV / TSV, XLSX) and SQLite files are read from ./imports (/data/imports in 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=1 in compose.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 an http:// or https:// URL and nothing else. Delivery never uses an HTTP_PROXY / HTTPS_PROXY from 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 with docker compose exec -T postgres pg_restore -U limma -d limma --clean --if-exists < limma.dump, with the api and worker stopped. (Without --if-exists a restore into a fresh volume reports an error for every object it could not drop first; the data is restored either way.)
  • The source-data volume: the documents' bytes.
  • .env: your secrets.
  • docker compose down -v deletes 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-data volume: 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 (409 replay_unavailable, reason workspace_erased).

Before you delete anything, /health (with a key) says whether this database can be erased from:

  • deployment.unclassified_tables must 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 no workspace_id column makes every workspace delete refuse (500 unclassified_table, nothing touched). Drop a table that is not Limma's, or install a release that classifies it.
  • deployment.schema_faults.count must 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 — connect re-reads rows from it for every receipt — and connect it again after an update or a restart, until which its receipts read row_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:

  1. docker compose config --quiet — the compose files parse with your .env.
  2. docker compose up -d, then docker compose ps — postgres, redis, api and worker are healthy.
  3. 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 is ok (the pack check among them: the image verifies and loads the signed bundle) and the last line is smoke: PASSED. This is the erasure proven on the package's own Postgres, Redis and source-data volume: erasure must report residue {} and jobs remaining [].
  4. The must-fail: docker compose exec -e EPISTEM_API_KEYS= api python -m epistem.deploy smoke exits 2 and says the smoke has no keys.
  5. 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, then curl -s -H 'X-API-Key: <admin key>' http://127.0.0.1:8000/health reads deployment.unclassified_tables: ["legacy_foreign"], the smoke's health check fails naming it, and a DELETE /api/workspaces/<id> answers 500 unclassified_table. DROP TABLE legacy_foreign, restart, and both are clean again.
  6. The queue is reached: with the worker stopped (docker compose stop worker), upload a document to a workspace, delete the workspace, and docker compose exec redis redis-cli -a <REDIS_PASSWORD> --scan --pattern 'rq:job:*' lists no job of it; the delete's reply reads jobs_forgotten: 1.
  7. The bytes are gone: the source-data volume holds no directory named by the deleted workspace's id (docker compose exec api ls /data/sources).
  8. With the model up, the smoke without --no-model passes its model check.
  9. docker compose down -v in the scratch directory.