An MCP server that gives your agents durable, hash-chained memory under a signed identity, and serves licensed expert corpora as ranked passages. Your container, your disk, your network. The licence is verified offline and the software never calls home.
Asphodel is one Python service, shipped as one container, that speaks the Model Context Protocol. It holds two kinds of store and nothing else:
PRAGMA query_only=ON,
so writes are refused by SQLite itself. Mindset tools return ranked grounding
passages, not answers. Your agent does the reasoning; Asphodel supplies the lens
and the citation.By default there is no LLM anywhere in the serving or entitlement path. Routing is lexical, gating is an Ed25519 signature check plus clock arithmetic. The same query returns the same passages, deterministically, with no network egress.
/mindsets is created empty. The
framework is the engine; corpora are licensed separately and mounted in. An install
with no mindsets is still fully useful — memory is ungated at every tier,
including with no licence at all.
Your licence is also your registry password. The username is the account reference printed on your receipt and in your licence email.
docker login registry.saluca.com -u <your account reference> -p "$ASPHODEL_LICENSE" docker pull registry.saluca.com/saluca/asphodel:latest
Nothing external is required. No database to provision, no broker, no network dependency — state is SQLite on a volume you own.
docker run -d --name asphodel -p 8351:8351 \ -e ASPHODEL_LICENSE="$ASPHODEL_LICENSE" \ -e ASPHODEL_TENANT=default \ -v asphodel-data:/data \ -v "$PWD/mindsets:/mindsets:ro" \ registry.saluca.com/saluca/asphodel:latest
Confirm it is up, and what it thinks your entitlement is:
curl -s localhost:8351/healthz docker exec asphodel asphodel license scopes
license scopes is the command to reach for whenever a tool returns
not_entitled. It prints the licence status, the effective tier, and every
scope derived from it — which is the whole subscription model in one screen.
The licence is an Ed25519 JWT — optionally a hybrid Ed25519 + ML-DSA-44 token, where both signatures must verify or the token is rejected. It is checked against a public key baked into the image at build time, so verification is entirely offline: there is no licence server, no activation call, and no network dependency for it to fail on.
Supply it whichever way suits your secret management. The literal wins over the file if both are set.
| Variable | Meaning |
|---|---|
ASPHODEL_LICENSE | The JWT itself. |
ASPHODEL_LICENSE_FILE | Path to a file containing it. Preferred when you mount secrets. |
ASPHODEL_GRACE_HOURS | Hours an expired licence is still honoured. Default 72. |
| State | Meaning | Effect |
|---|---|---|
valid | Signature good, not expired. | Your tier and features apply. |
grace | Expired less than ASPHODEL_GRACE_HOURS ago. | Tier still honoured; a warning is logged every call. |
missing | No licence configured. | Runs as community: memory works, mindsets refuse. |
invalid | Bad signature, tampered, malformed, or past grace. | Degrades to community. Never fails open. |
not_entitled refusal naming the
feature required, which you can alert on.
The licence is re-validated on every gated call, not cached at boot. A licence that crosses from grace into expiry takes effect on the next tool call, with no restart and no watchdog.
When your subscription renews we mint a fresh licence and email it. Restart with the new value. The expiry date is stated in every licence email, so you never have to decode a token to find out when it lapses.
Two transports, selected by which command you run. There is no transport environment variable.
asphodel serve is the container default and listens on
:8351. Point any MCP client at http://<host>:8351/mcp.
asphodel mcp speaks stdio, which is what Claude Desktop and most local
MCP clients expect:
{
"mcpServers": {
"asphodel": {
"command": "docker",
"args": ["run","--rm","-i",
"-e","ASPHODEL_LICENSE",
"-v","asphodel-data:/data",
"-v","/srv/mindsets:/mindsets:ro",
"registry.saluca.com/saluca/asphodel:latest","mcp"],
"env": { "ASPHODEL_LICENSE": "<your licence>" }
}
}
}
ASPHODEL_TENANT. The default bind is
127.0.0.1; the container overrides it to 0.0.0.0 because a
container-local bind would be unreachable. Put it behind your own authenticating proxy
or on a private network — do not expose :8351 to the internet.
These are the variables that matter for a self-hosted install. Everything else in the package is operator-side and has no effect here.
| Variable | Default | What it does |
|---|---|---|
ASPHODEL_LICENSE | — | Your licence JWT. |
ASPHODEL_LICENSE_FILE | — | Read the licence from a file instead. |
ASPHODEL_GRACE_HOURS | 72 | Grace window after expiry. |
ASPHODEL_DATA_DIR | /data | Tenant memory stores. Back this up. |
ASPHODEL_MINDSETS_DIR | /mindsets | Read-only corpus root. |
ASPHODEL_TENANT | default | Which tenant this instance serves. |
ASPHODEL_HOST | 0.0.0.0 in the image | Bind address. |
ASPHODEL_PORT | 8351 | Bind port. |
ASPHODEL_COUNCIL_MINDSETS | ciso,cissp,ccsp | Candidate bench for the council tool. |
ASPHODEL_SEMANTIC | off | Opt-in hybrid retrieval. See below. |
ASPHODEL_EMBEDDER | — | ollama to enable embeddings. |
ASPHODEL_EMBED_URL | http://localhost:11434 | Ollama base URL. |
ASPHODEL_EMBED_MODEL | nomic-embed-text | 768-dimension embedding model. |
ASPHODEL_LICENSE_PUBKEY appears in older notes as a trust-root override. It
is inert in released images — the baked root always wins, deliberately,
because a customer-settable trust root is a complete licence bypass. Do not plan
around it.
Lexical routing is exact on security vocabulary and weak on paraphrase. Setting
ASPHODEL_SEMANTIC=1 with an embedder configured upgrades mindset
retrieval only to a hybrid rerank. It activates only when the corpus actually carries
embeddings, and falls back to byte-identical lexical results the moment anything is
missing — no errors, ever.
Understand the trade. The lexical default has zero runtime dependencies and is reproducible forever. Opting in buys paraphrase recall at the cost of a live embedding dependency and non-determinism across model versions: identical queries can rank differently after an upgrade. The entitlement gate never touches this path, so licensing stays deterministic under either setting.
Thirteen tools. Memory and status are ungated; every mindset tool is gated on a feature in your licence.
| Tool | Arguments | Gate |
|---|---|---|
memory_write | text, persona, subdomain, kind, ref, conf | Ungated |
memory_recall | query, k, persona | Ungated |
memory_search | query, k, persona, subdomain | Ungated |
mindset_recall | mindset, query, k, subdomain | <name>_mcp |
ciso_recall and siblings | query, k, subdomain | ciso_mcp, etc. |
security_council | query, k (per mindset) | council_mcp + each seat |
asphodel_status | — | Ungated |
Per-mindset tools exist for ciso, cissp, ccsp,
nist, compliance, threat_intel,
frameworks and shi. mindset_recall reaches any
installed corpus by name and is the forward-compatible one to write against.
A passage carries rank, score, subdomain,
kind, ref, source, conf and
text — enough to cite what you were grounded on.
Refusals are structured, never exceptions. A tool you are not entitled to returns
{"error":"not_entitled","feature":"...","detail":...} naming exactly the
feature required, so an agent can degrade gracefully instead of crashing.
security_council fans one query across every security mindset you are
otherwise entitled to and ranks the lenses loudest-first. It composes with
per-mindset entitlements and never bypasses them: council_mcp with no
mindset features seats nobody and returns an empty council with a notice — a
successful call, not an error.
Corpora are licensed separately and delivered as directories. Drop them under your mindsets root so that the database lands at either path below, and mount the root read-only:
/mindsets/<name>/millennium.db /mindsets/tkhr-<name>/millennium.db
Discovery is at startup. Confirm what the server found:
docker exec asphodel asphodel mindsets docker exec asphodel asphodel stats
Holding a corpus on disk is not entitlement. The gate is the licence, and a mindset you have not licensed refuses even when its database is mounted — so mounting a shared corpus directory across a fleet is safe.
If you have enabled the semantic tier, seed embeddings once per corpus. Embeddings
sit outside the hash chain, so asphodel verify still passes afterwards:
docker exec asphodel asphodel mindset embed ciso
This is the part most licensing documentation gets wrong, so it is worth stating plainly: in Asphodel, capability is granted by the features in your licence, not by the tier name on it.
| Feature claim | Unlocks |
|---|---|
<name>_mcp | The matching mindset — ciso_mcp unlocks ciso_recall and mindset_recall("ciso", ...). |
council_mcp | The security_council aggregator, over the mindsets you already hold. |
sovereign_mcp | Upgrading a tenant to a constitution-bound sovereign. See below. |
| — none required — | All three memory tools, agent identity, chain verification. |
Every tenant owns a SoulKey — a hybrid Ed25519 + ML-DSA-44 keypair, both or neither. It is minted lazily on the first write, and every memory entry is signed with it. That is the difference between a chain that is merely tamper-evident and one that is attributable: you can show not only that the record was not altered, but which identity asserted it.
docker exec asphodel asphodel tenant provision acme docker exec asphodel asphodel tenant identity acme docker exec asphodel asphodel tenant verify acme
tenant verify is the strong check: hash chains, plus every per-entry
signature, plus the constitution seal where one exists.
A tenant can be upgraded to a constitution-bound SHI, which requires
sovereign_mcp. A twelve-principle constitution is sealed by a retirement key
that is destroyed at provisioning, and every boot re-verifies the seal. Tampering
is not repaired and not logged-and-continued — the tenant refuses to operate, and
both reads and writes return constitutional_halt.
docker exec asphodel asphodel tenant provision acme --sovereign
Run asphodel verify on a schedule and alert on a non-zero exit. It walks
every chain and prints OK or TAMPERED per persona and per
mindset. This is cheap and it is the check that makes the storage claim meaningful
— an unverified hash chain is just a table with an extra column.
docker exec asphodel asphodel verify
All state is under ASPHODEL_DATA_DIR. Back up that volume and you have
backed up the product. Mindset corpora are read-only and re-obtainable, so they need no
backup. Snapshot with the container stopped, or use SQLite's backup API — copying a
live database file mid-write is how you acquire a chain that fails verification.
Pull the new tag and recreate the container. State lives on the volume, so there is nothing to migrate. Verify after upgrading.
Work in this order. asphodel license scopes tells you what the install
believes it holds; asphodel mindsets tells you what it can see on disk. A
not_entitled refusal with the corpus present is a licence question; an
unknown_mindset error is a mount question. They are different problems and
the error distinguishes them.
Technical questions and licence problems:
support@saluca.com. Include your account
reference and the output of asphodel license scopes. Billing, tier changes
and invoicing: sales@saluca.com.