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:
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 headerX-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/healthshows"state": "grace"anddays_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.
- 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 written0600and the command refuses a path inside the repository. Paste the line it prints intoPRODUCTION_KEYSinepistem/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. - The issuer master (once, ever):
python -m epistem.licensing master --out <file outside the repository>. Every license's reporting secret isHMAC-SHA256(master, license_id); a new master would invalidate every one. The hosted API reads it asLIMMA_LICENSE_MASTER(hex); without it the receiving routes refuse. - The licensed image:
docker build --build-arg LIMMA_LICENSED_BUILD=1 …. The build runspython -m epistem.licensing mark-build, which setsENFORCE = Trueinepistem/licensing/_build.pyand fails when no production key is listed. - 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-checkinfor a deployment that will send files only). It refuses a key id that is not in this release's production keys. - Receiving. Check-ins arrive at
POST /v1/licensing/checkinon the hosted API, verified by their HMAC (a forged or altered report is refused 401; one sent twice is stored once).GET /v1/licensing/deploymentslists every deployment with its last check-in, version, license state and latest counts;POST /v1/licensing/importtakes a monthly file. Both are for an admin key of an organization named inLIMMA_LICENSING_STAFF_ORGS. To check a file by hand:python -m epistem.licensing verify-report usage.json --master <file>. Twoinstance_ids under one license are two databases running it.