Skip to content

Licensing

A licensed build of Limma runs only under a license file Limma signed for your deployment. It tells Limma how much it was used — counts, never content — and when its license lapses it becomes read-only: your agents keep asking questions and you keep deleting, but nothing new is loaded until a renewed license is installed.

Where the license file goes

Limma sends you one file, license.json. With the customer package, put it in a directory named license beside compose.yaml:

mkdir -p license
cp /path/to/license.json license/license.json
docker compose up -d

The package mounts that directory read-only at /etc/limma in the API container, and the API reads /etc/limma/license.json. Elsewhere, put the file wherever you like and point LIMMA_LICENSE_FILE at it. The file holds your deployment's id, its expiry, any limits, where check-ins go, and a reporting secret the deployment signs its reports with — treat it like a password and do not commit it anywhere (deploy/customer/license/ is ignored by git for that reason).

To renew, replace the file and restart the API (docker compose restart api).

What happens at start

The API verifies the license before it builds anything else. If it cannot, it refuses to start and its log names exactly what is wrong — one of:

refusal what it means
unreadable no file at the path (the message names it), a directory there (Docker creates one when the mounted file was missing at start), or text that is not JSON
unknown_key signed with a key this release does not know — a license for a different release, or not from Limma
test_key_refused signed with Limma's development test key, which a licensed build never accepts
bad_signature the file was changed after Limma signed it, or another key signed it
malformed a field is missing or of the wrong type, or the license sets a limit this release does not know

No environment variable switches the check off in a licensed build. /health (with a key) shows the license under license: its state, the days left, its limits against what you hold, and the last check-in.

Valid, grace and read-only

  • Valid — until expires_at. Nothing to see.
  • Grace — for grace_days (14 unless your license says otherwise) after expiry, everything still works. Every response carries the header X-Limma-License: grace; days-left=<n>; read-only-from=<time>, the API logs a warning when the state changes and once a day after, and /health shows "state": "grace" and days_left.
  • Read-only — after the grace, or when a limit is exceeded, or when the system clock has been turned back by more than a day (below). Every response carries X-Limma-License: read-only; reasons=<…>.

In read-only, every operation that loads or changes data is refused with status 402 and a body whose type is license_read_only, whose detail says why in words and whose reasons name it (expired, limit_exceeded, clock_rollback): ingesting, connecting a database, uploads, creating a workspace, registering webhooks, attesting, corrections, packs, catalogue and settings edits, re-extraction, backups. The same holds for an agent calling the MCP tools: limma_ingest and limma_connect are refused, limma_search, limma_facts and limma_region are served.

Still served in read-only: every question and every read (search, facts, region, state, ask, plan, precheck, derive, verify, every listing), every deletion (a document, a workspace, a catalogue entry, a webhook — erasure never depends on a license), and running the deployment (health, keys, the license routes). Each of the API's operations is classified one by one in epistem/licensing/gate.py; one added later is classified before it ships.

Limits

A license may set max_workspaces, max_documents (documents held now; a superseded version is not counted) and max_records (the facts held now — the current rows of the address space). A count equal to its limit is within it. Above it, the deployment is read-only until you are back within it: deleting is never refused, and the counts are taken again within half a minute of a deletion.

The clock

The license is judged by the latest time the deployment has seen, kept in its database, not by the system clock alone. Turning the clock back extends nothing, and a clock more than a day behind that mark is treated as a lapsed license (clock_rollback). A day of tolerance means no time-zone or daylight-saving mistake trips it. If the clock was set right and it still trips (a clock that was once set far in the future), ask Limma for a renewed license: a new license starts a new record.

What is sent

About half a minute after the API first runs under a license, and every interval_hours after that (6 unless your license says otherwise; a restart does not reset the interval), the API sends one report to the check-in URL written into the license, signed with the license's reporting secret — one report per deployment, however many API processes run. The monthly file is the same report. This is everything it holds — no document text, no file or workspace name, no question, no name of a person, no id of a workspace, a document or a key, ever:

field what it is
format limma-usage/1
report_id a random id for this report (a report sent twice is counted once)
kind checkin or offline (the monthly file)
license_id the license's id
deployment_id the deployment id the license names
instance_id a random id your database made for itself
version the git commit the image was built from
terms_version the version of the terms (deploy/customer/TERMS.md) your operator accepted in LIMMA_TERMS_ACCEPTED — the version id itself, or empty; never your own text
generated_at when the report was made
period the whole hours (UTC) the counts cover
uptime_seconds how long the API process has run (empty in a file the command line wrote)
license the state (valid, grace, read-only), its reasons, the days left, the expiry and the end of the grace
health whether the database answers, how many schema faults and unplaceable tables there are, the environment name
usage per operation — a verb's name such as search, or the route's template such as POST /api/workspaces/{workspace_id}/sources, never the path you sent — the number of calls, client errors, server errors and license refusals; their totals; latency p50 and p95 (the upper edge of a fixed bin)
holdings how many workspaces, documents and records are held now, and the change since the previous check-in
limits the license's limits, as issued
checkins_failed check-ins that failed since the last one that arrived

A tool call over POST /mcp is counted twice, by design: once as mcp and once as the verb it called. The counts live in your database (the license_usage table) and survive a restart; nothing is sent from anywhere else.

Turning outbound off — the monthly file

Set LIMMA_CHECKIN=off in the API's environment (.env with the customer package) and nothing is sent. A failed check-in — no route out, a proxy, a timeout — also breaks nothing: it is recorded (/health → license.checkin) and the counts go with the next one. If your network requires a proxy for the check-in, name it in LIMMA_CHECKIN_PROXY; no other proxy setting of the environment is used.

Instead, send Limma the report for each month. From the API container:

docker compose exec api python -m epistem.licensing report \
    --since 2026-09-01 --until 2026-10-01 --out /tmp/usage.json
docker compose cp api:/tmp/usage.json ./usage-2026-09.json

or download it with an admin key:

curl -fsS -H "X-API-Key: $ADMIN_KEY" -o usage-2026-09.json \
    "https://<your Limma>/v1/licensing/usage-report?since=2026-09-01&until=2026-10-01"

It is plain JSON — read it before you send it. python -m epistem.licensing status prints the license and its state.

For Limma staff

Internal: issuing licenses and receiving reports. Never run these inside a customer's environment; every command's --help says the same.

  1. The signing key (once per rotation): python -m epistem.licensing keygen --out <a directory outside the repository> --key-id prod-YYYY-MM. The private key is written 0600 and the command refuses a path inside the repository. Paste the line it prints into PRODUCTION_KEYS in epistem/licensing/keys.py — the list ships empty; a licensed image built while it is empty fails its build, and one started fails to start. To rotate, add the new key beside the old one and remove the old one once no license signed with it is in use.
  2. The issuer master (once, ever): python -m epistem.licensing master --out <file outside the repository>. Every license's reporting secret is HMAC-SHA256(master, license_id); a new master would invalidate every one. The hosted API reads it as LIMMA_LICENSE_MASTER (hex); without it the receiving routes refuse.
  3. The licensed image: docker build --build-arg LIMMA_LICENSED_BUILD=1 …. The build runs python -m epistem.licensing mark-build, which sets ENFORCE = True in epistem/licensing/_build.py and fails when no production key is listed.
  4. A license: python -m epistem.licensing issue --customer <label> --deployment <id> --days 365 --key <dir>/<key id>.private.pem --master <file> --checkin-url https://<hosted API>/v1/licensing/checkin [--limits max_workspaces=20,max_documents=50000] [--grace-days 14] [--checkin-hours 6] --out license.json (--no-checkin for a deployment that will send files only). It refuses a key id that is not in this release's production keys.
  5. Receiving. Check-ins arrive at POST /v1/licensing/checkin on the hosted API, verified by their HMAC (a forged or altered report is refused 401; one sent twice is stored once). GET /v1/licensing/deployments lists every deployment with its last check-in, version, license state and latest counts; POST /v1/licensing/import takes a monthly file. Both are for an admin key of an organization named in LIMMA_LICENSING_STAFF_ORGS. To check a file by hand: python -m epistem.licensing verify-report usage.json --master <file>. Two instance_ids under one license are two databases running it.