Agience Mantle
The lattice — where the data lives.
Mantle is the lattice the whole universe persists in: the memory of the Agience system — an encrypted-by-default artifact store and search engine where authorization is the encryption.
Mantle holds that data; it does not back it up. There is no backup, snapshot, restore or corruption-detection machinery in this repository. Durability is an operator responsibility, and Backing a node up is the procedure — a runbook, not a feature.
Mantle holds knowledge so that it can be trusted by people who weren't there when it was made. Every artifact carries its identity, version history, and provenance inside itself — audit is the data structure, for everything written through the system; a bulk import supplies no provenance and gets none. Search runs over an encrypted lexical index — blind-token MANTLE-SSE, covering 5.9% of our reference corpus today and growing, with the remainder served by a plaintext lexical index — and access control is enforced cryptographically: each cell of the index is encrypted under a per-cell key derived from the owner's master key, and authorization is computed as reachability in a typed graph (the light cone).
Reachability decides which keys are issued — it does not derive them. A grant is not a rule the storage layer chooses to honour; it is what decides whether a key is handed out at all. The cell key itself is derived on demand and never persisted, so there is nothing at rest to take, and one piece of code decides both what a search may touch and what key is issued — so the two cannot disagree silently. Revocation is a single grant edit — no re-encryption, no key rotation — effective within the authorization cache's window, 30 seconds by default and disableable outright where that matters.
Grants are keys, not metadata — access is enforced cryptographically, not by an ACL filter applied after the fact.
What is encrypted, and what is not. Content is encrypted per principal and bound cryptographically to the collection it was written for; on a live store, every content object measured is encrypted at rest, in fact wrapped twice. What the store keeps in the clear is what it must read to find things: the offer text the lexical index reads, and the identifiers in the posting store. The blind-token index exists to close exactly that, and covers 5.9% of our reference corpus today — the rest is served by a plaintext lexical index. That number is small and it is the real one.
Mantle is one of the instruments of the Agience system. This repository is the production Mantle service.
Layout
| Path | Purpose |
|---|---|
src/mantle/ |
The FastAPI service: the standalone lattice store (one SQLite file + a filesystem CAS, opened in-process — db/backend.py → db/lattice_api.py → db/vertex.py + db/edge.py), encrypted retrieval on both arms — MANTLE-SSE blind-token narrowing and anchor-routed vector cells, on object storage or the local disk; the vector arm is inert until an AnchorSet is seeded (see below) — per-state index segments, light-cone authorization, and governable trusted-issuer auth. Depends on agience-prism and talks to Origin over the wire (HTTP/MCP) — clients/origin_client.py is the only outbound peer client. |
tests/e2e/ |
Blackbox HTTP end-to-end suite — drives a live stack over the wire. See tests/e2e/README.md. |
.env.example |
Config template — copy to .env. Only MANTLE_LATTICE_PATH and KEYS_DIR are set outright; everything else is commented out, so an untouched copy runs on defaults. |
MinIO lives in agience-observe alone, as the local S3 edge for content and SSE cells.
Subsystems under src/mantle/
| Path | Purpose |
|---|---|
db/ |
The standalone lattice store. backend.py is the one import point → lattice_api.py → vertex.py/edge.py/seq.py/schema.py (SQLite + filesystem CAS), plus the S3 content adapter. doc_boundary.py is the write chokepoint: content envelope crypto and the change event, in one place. |
routers/ |
The five FastAPI routers main.py mounts — artifacts, grants, events, system, mcp. Thin and type-agnostic: validate, delegate, return. |
services/ |
Orchestration — workspaces, collections, grants, content, contexts, OIDC, seed provisioning, plus peer_signing.py (the one outbound signature, a service JWT). content_crypto.py is the per-principal content envelope, which is also all there is to a secret. acting_principal.py answers who is acting; principal.py answers what artifact a principal IS — a person, or a foundation entity for an author that is not a human. |
api/ |
Pydantic request/response models, grouped by domain — including api/vectors.py, the shape validation for writer-supplied vectors. |
entities/ |
Entity models and serialization. A collection is an artifact; entities/collection.py says so literally. context.py and subscription.py are the same move again: a role an artifact plays, discriminated by content_type. |
search/ |
Retrieval. embeddings.py and embeddings_cache.py are the vector arm's provider facade and its long-term cache; search/mantle/sse/ is the encrypted lexical arm (blind-token narrowing), covering 5.9% of our reference corpus with the remainder on the plaintext index; search/mantle/lightcone.py is authorization as reachability; search/anchors/ and search/beacon/ are the semantic arm and its result cut — search/anchors/store.py loads a client-seeded AnchorSet and never derives, grows or reconciles one, so the semantic arm stays dark until a set is seeded; search/ingest/ is the indexing queue. |
attenuation.py |
The one authorization meet — CRUDEASIO masks, deny absorbing, composed along every path. One of the four modules in the package root, beside __init__.py (the BLAS pin), main.py (the app) and config.py (the settings every layer reads). |
events/ |
The change feed: event_bus.py is in-process fan-out and a durable log with cursor replay; event_backplane.py is the optional Redis/MQTT back-plane for multi-process nodes. |
system/ |
Boot and operations — logging_utils.py and its uvicorn_log_config.json, runner_hooks.py (what the store asks of a runner, injected rather than imported), and the manage_*.py bootstrap/seed/addon/anchor operations. |
ui/ |
Server-rendered browse pages — browse_page.py. |
mesh/ |
The peering plane: content-addressed Ed25519-signed shards, anchor-keyed regions, incremental Merkle sync. |
oci/ |
An OCI registry over the lattice — an image is a collection, a blob is content. No side-car registry. |
shard/ |
Persistence beneath the store: local cache and its regions, content tiering, curation, and erasure. |
clients/ |
The wire between Mantle and someone else. origin_client.py is the only outbound peer client; artifact_helpers.py is the consumer's side — content_type ⇄ mimeType for the MCP servers that call Mantle, used by agience-chorus. |
scripts/ |
Operator and developer CLIs — dev_init_keys.py, dev_mint_token.py (the credential a standalone node had no other way to obtain), serve.py, manage_erasure.py, CAS rekey, usage snapshots. |
tools/ |
One-shot migrations — migrate_env_to_db.py. |
Run it
Mantle IS the database. The store is one SQLite file (MANTLE_LATTICE_PATH, schema created on open) plus a filesystem CAS, opened in-process — zero external database processes to provision.
The trust floor: agience-prism
[service] (and [semantic], and [dev]) require agience-prism, published on PyPI under that
name — the import name is prism, and the GitHub repository is agience-prism-py. Nothing extra is
needed to install:
pip install -e '.[service]' # resolves agience-prism from PyPI
prism is the trust floor main.py's key initialization calls into, so the app fails at import
without it.
To work on both at once, clone prism beside this repo and install it editable — an editable install takes precedence over the released wheel:
<workspace>/
├── agience-mantle/ ← this repo
└── agience-prism/py/ ← the `agience-prism` distribution
git clone https://github.com/Agience/agience-prism-py ../agience-prism/py
pip install -e ../agience-prism/py
Local
pip install -e ../agience-prism/py # the prerequisite above
pip install -e '.[service]' # mantle + its service extra; base dep is just `cryptography`
# An empty KEYS_DIR does not boot: the lifespan loads key material that nothing else in this repo
# writes. This generates a throwaway keyset — local development only, never a deployment.
mantle-init-keys --keys-dir ./.data/keys
KEYS_DIR=./.data/keys MANTLE_LATTICE_PATH=./.data/mantle.db mantle-serve --port 8081
mantle-serve, mantle-init-keys and mantle-token are console scripts any install puts on the
path; mantle-serve needs [service] for uvicorn, the other two need only the base install.
Spelled out they are uvicorn mantle.main:app, python src/mantle/scripts/dev_init_keys.py and
python src/mantle/scripts/dev_mint_token.py — the latter two run straight from a checkout with
nothing installed but cryptography.
Connect a client
Six commands from a fresh install to an MCP client that stores something and finds it again. A standalone node is a complete node: no Origin, no S3, no AnchorSet and no bootstrap step.
pip install -e ../agience-prism/py # the prerequisite above
pip install 'agience-mantle[service]'
mantle-init-keys --keys-dir ./.data/keys
AGIENCE_BASE_DIR=$PWD KEYS_DIR=./.data/keys MANTLE_LATTICE_PATH=./.data/mantle.db \
mantle-serve --port 8081
Set AGIENCE_BASE_DIR on a pip-installed node. It is the root every derived default hangs
off — the SSE index (.data/mantle-sse), the encrypted cells (.data/mantle-cells), the embeddings
cache (.data/mantle/), KEYS_DIR and MANTLE_LATTICE_PATH. Unset, an installed node derives it
from the directory you start it in, because the alternative — the directory the package was
installed into, site-packages — is a tree the next pip install --upgrade rewrites, and the
indexes are data, not cache: a full rebuild is measured in days-to-weeks. The working directory
is a floor rather than a plan, though. Start the same node from somewhere else and it derives a
different root and comes up healthy serving an empty universe, so say it outright, as the command
above does. MANTLE_LATTICE_PATH and KEYS_DIR do not save you on their own: they move two of the
four parts a node is made of, and Backing a node up is the list of all four.
A checkout — including an editable install, which is the same tree seen through a finder — keeps
deriving the repo root, so .data/ stays beside src/ and nothing about developing here changes.
Then, in a second shell, mint a credential. KEYS_DIR already holds one: mantle-init-keys wrote
the signing key and the trust anchor that verifies it, so the node has trusted its own key since
its first boot — this command is the first thing to sign a user token against it.
mantle-token --keys-dir ./.data/keys
Minted a user token, signed by /home/you/node/.data/keys/mantle.private.pem and trusted by this
node's own authority.manifest.json anchor. DEVELOPMENT ONLY - these keys have no custody.
subject d99d859b-876e-57f6-b196-7b22fa54335c
audience http://localhost:8080 (config.AUTHORITY_ISSUER - what the verifier requires)
expires 2026-08-12T13:55:49Z (12 hours)
Add it to Claude Code:
claude mcp add --transport http mantle http://localhost:8081/mcp --header "Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Im1hbnRsZS0xIiwidHlwIjoiSldUIn0.eyJhdWQiOi..."
Or send the header yourself:
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Im1hbnRsZS0xIiwidHlwIjoiSldUIn0.eyJhdWQiOi...
The subject is derived from this keyset's instance.uuid, so re-running mints for the
SAME person and everything the last token stored stays reachable. ...
Three claims in that block are worth reading twice. audience is config.AUTHORITY_ISSUER, read
from the same module attribute the verifier compares against — http://localhost:8080 here is the
name a token must carry, not a host anything dials, and nothing needs to be running there.
expires is services/auth_service.ACCESS_TOKEN_EXPIRE_HOURS, this package's own declared
lifetime for an end-user access token, not a number the command picked. subject is
uuid5(instance.uuid, "mantle/local-user") — derived from the keyset, so it is the same person on
every run; a random subject would mint a new principal holding no grants and strand everything the
previous token stored. --subject <label> names a second identity on the same keyset.
Paste that claude mcp add line and the client is connected. Everything below is the same thing
with curl, so you can see the wire:
TOKEN=$(mantle-token --keys-dir ./.data/keys --token-only)
curl -s http://localhost:8081/mcp \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_artifact",
"arguments":{"name":"Kickoff notes","content_type":"text/markdown",
"content":"We agreed to ship the encrypted lexical arm first."}}}'
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"…"}],
"structuredContent":{"id":"df65a79f-1a57-4a5b-b10d-62f4032557fc","name":"Kickoff notes",
"content_type":"text/markdown","state":"committed","collection_id":"",
"created_by":"d99d859b-876e-57f6-b196-7b22fa54335c",
"created_time":"2026-08-12T01:56:01.828633+00:00"}}}
created_by is the token's subject, so the credential really is the principal — the creator gets
an owner grant, which is why the next call can find it.
Send content_type. Omitted, it defaults to application/vnd.agience.collection+json — the
label for a container — so a stored conversation comes back as a collection and every type:
filter that would have found it misses. Measured, not assumed:
create_artifact with content_type left out answers "content_type": "application/vnd.agience.collection+json".
Send identity for anything you will store more than once. It names the thing the
artifact is of — file:/repo/README.md, session:7c7bcb7b — and the artifact's id is derived
from it (services/artifact_identity), so the write is idempotent: storing the same thing
again updates that one artifact instead of leaving a second copy.
curl -s http://localhost:8081/mcp \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"create_artifact",
"arguments":{"identity":"file:/repo/NOTES.md","name":"Notes",
"content_type":"text/markdown","content":"Second revision."}}}'
Run that twice and there is one artifact holding Second revision.; run the same pair without
identity and there are two, with nothing in either saying which is current. Without it the id
is a fresh uuid4 per call, so the only way to update rather than duplicate is for the client
to remember the id it was given — and a write whose reply is lost still succeeds here,
leaving the client with nothing recorded and the next write creating a second root that nothing
reconciles.
The derivation includes the calling principal, so the same name from a different principal is a
different artifact: two people capturing their own README.md cannot collide, and converging on
one artifact stays a deliberate act — a grant — rather than an accident of filenames. identity
is top-level only; a member of a collection has a draft/committed lifecycle with more than one
live version, so combining it with container_id is a 400 naming the reason rather than a
silently-dropped argument.
curl -s http://localhost:8081/mcp \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"recall",
"arguments":{"query_text":"encrypted lexical arm"}}}'
{"jsonrpc":"2.0","id":2,"result":{"structuredContent":{
"hits":[{"id":"df65a79f-1a57-4a5b-b10d-62f4032557fc","score":3.0,
"root_id":"df65a79f-1a57-4a5b-b10d-62f4032557fc","collection_id":"",
"title":null,"description":null,
"content":"We agreed to ship the encrypted lexical arm first."}],
"total":1,"ordering":"coverage","applied_filters":[],"corrections":[],
"query_text":"encrypted lexical arm","size":20,"from":0}}}
The results are under hits. ordering: "coverage" is the lexical arm answering — score is
the count of distinct query stems that hit carries, not a relevance measure. "semantic" requires a
seeded AnchorSet (below). title is null
because name and title are different fields: title comes from the context JSON, which this
call did not send.
KEYS_DIR is the root credential of a standalone node. mantle-token does not create that
exposure, it names it: anyone who can read that directory can already mint this token by hand, and
read access to it is full access to the store — bounded by no grant, no revocation and no expiry.
Back it up separately and under different custody, as Backing a node up says.
There is no OAuth flow to complete here. A standalone Mantle serves exactly one document of
the OAuth surface — /.well-known/oauth-protected-resource — and none of the endpoints: no
/.well-known/oauth-authorization-server, no /authorize, no /token, no dynamic client
registration. A standards-compliant MCP OAuth flow therefore cannot complete against it, and the
static Authorization header above is the supported path.
config.authorization_servers() names an authority only once one has been declared — an
AUTHORITY_ISSUER or ORIGIN_URI in the environment, a branding.origin_uri row that differs from
the default, or a configured AGIENCE_TRUSTED_ISSUERS. An undeclared node omits the key rather than
naming a server it cannot serve. Point a node at a real issuer and the document names it.
Running it as a service
Mantle is distributed on PyPI and runs as an ordinary Python process. There is no image to build.
pip install 'agience-mantle[service]'
mantle-init-keys --keys-dir ./.data/keys
mantle-serve
mantle-serve wraps uvicorn, so the app object, the default port and the log config travel with the
package. Point MANTLE_LATTICE_PATH and KEYS_DIR at wherever the node's state should live.
Mantle boots as a pure database layer with an empty type registry. An application on top (Agience/Origin) provisions data via Mantle's API.
Semantic recall is inert until you seed an AnchorSet
A node you just installed answers POST /artifacts/recall from the lexical arm only. Both
arms are wired and both have somewhere to write — the encrypted vector cells work on local disk
with no bucket — but the semantic arm routes every vector against an AnchorSet, the shared
coordinate system, and a fresh node has none. You seed it.
The whole contract is three steps:
- Seed a set.
python -m mantle.system.manage_anchors --action load --path anchors.json - Send query vectors in that set's space — the
space_idyou supply must equal the set'smodel_id, and the width must equal itsdim. - Read ranked results.
POST /artifacts/recallreturns them withordering: "semantic"and a cosine on every hit.
Mantle does not derive, grow, reconcile or crosswalk a coordinate system. That is deliberate, for two independent reasons:
- Locally-derived anchors would not be shared. An anchor id is content-addressed over
(label, model_id, embedding), so anchors fitted to whatever corpus a node happens to hold mint region ids no other node computes. Two deployments would each route confidently, into disjoint cells, with no overlap — an index that looks healthy and shares with nobody. - Anchors are vectors, and Mantle runs no model. The no-models rule (
search/embeddings.py) means this process has nothing that could produce them, or that could fit a projection between two spaces. Vectors arrive from a caller.
So the client owns the coordinate system end to end: it authors the set, it names the space, and one node serves exactly one space. A query in any other space is refused with a 400 naming both.
Until a set is seeded:
| Path | What happens |
|---|---|
| An artifact write | Succeeds and is indexed lexically. The vector arm returns skipped and logs a WARNING naming the missing AnchorSet (search/ingest/pipeline_unified.py). |
POST /artifacts/recall |
Narrows on the query's terms, then answers most-recently-updated first: ordering is "recency" and score is null on every hit. |
POST /artifacts/recall with vector + space_id |
400. This node ranks in no space, so the vector names one that does not exist here — the same refusal a foreign space_id gets on a seeded node, for the same reason. The message names both ways out: seed the set, or send the recall without vector, which is the row above and works. It refuses rather than ignoring the vector, because ignoring it answers with a body identical to that row's — leaving the caller unable to tell their vector had no effect. |
| A full reindex | Runs, and reports "vector_arm": "off (no AnchorSet)". |
Check which state a node is in. KEYS_DIR must already hold a keyset (the command derives
the platform system principal from it):
python -m mantle.system.manage_anchors --action inspect
It names the live set's anchor count, model, dimension and fingerprint — a hash over the
anchor ids, so two operators can establish that their nodes route into the same regions without
either node exporting an anchor, a label or a vector. /status carries the same value.
Seed it. An anchor is an artifact (application/vnd.agience.anchor+json) and the
AnchorSet is the collection of them (slug agience-anchorset, created on first use):
python -m mantle.system.manage_anchors --action load --path anchors.json
anchors.json is the single-file form AnchorSet.save/load emits and ember ingest --anchors
consumes; there is no second serialisation and no other way in. --dry-run verifies the file and
reports its fingerprint without writing. Reindex afterwards so already-stored artifacts reach the
vector cells.
Use that command rather than POST /artifacts. An anchor's id is
uuid5(sha256(label ‖ model_id ‖ embedding)) and that id is the cluster id — it names the
cell storage path, the HKDF key info, the AEAD associated data and the mesh region.
CreateArtifactRequest has no id field and the write path assigns a fresh uuid4, so posting
anchors one at a time replaces exactly the value that makes two nodes' cells comparable, and
nothing downstream can tell: queries route to regions the writer never produced, cells miss, the
semantic arm returns nothing, the request still answers 200 on lexical results, and mesh sync
transfers nothing and reports success. The load command preserves every id and verifies each
one against its own content, refusing a file whole if any anchor disagrees.
What goes wrong, and what each says to do:
| Symptom | The error names |
|---|---|
Recall comes back ordering: "recency" with null scores |
No set is seeded. --action inspect says so and gives the load command. |
400 on a query with vector |
Either the width is not the set's dim, or the space_id is not the set's model_id. The message names the expected value and both spaces. |
REFUSED: from --action load |
An anchor's stated id disagrees with its own content. Re-export the file with AnchorSet.save; the ids cannot be repaired by hand, because the id is the content hash. |
| The arm refuses after it worked | The seeded set is a different space from the one this store's cells were written under. Restore that set, or drop the cells and reindex. |
Architecture
Mantle is a verify-only encrypted database on the data plane: it serves and verifies data, never fabricates provenance, and never embeds on its own behalf — vectors arrive from a caller, they are not produced here. It emits a change-feed event for every artifact write.
Verify-only is a claim about the data plane, not about the network. Mantle signs exactly one
thing (services/peer_signing.py): a short-lived, audience-scoped service JWT saying "Mantle
is calling", used on its one outbound call to Origin. It never signs a user token, and it
mints no delegations — RFC 8693 delegation is inbound-only here: services/dependencies.py
accepts one, resolves it to the subject with the acting server recorded in actor, and the peer
that issues it is the authority issuer.
The running service signs no user token, and mantle-token is not an exception. That command is
an offline CLI over KEYS_DIR — it holds the same private key the service holds, so it mints
exactly what anyone with read access to that directory could mint by hand, and the service merely
verifies the result through the same generic path it verifies every other issuer with. No route
issues a token, and sign_service_jwt hard-codes principal_type: service.
The design canon lives in the agience-pharos repo under genesis/ — LATTICE-CONTRACT.md,
SEARCH-ARCHITECTURE.md, MANTLE-TYPES.md, S3-SUBSTRATE-RESILIENCY.md and
SUBSTRATE-ONE-FIELD.md. What is true today, measured, is status/CURRENT.md, and
status/CLAIMS.md binds what any surface may claim. Mantle verifies tokens from any configured
OIDC issuer via one generic verifier, with the authority manifest as a bootstrap seed.
The surfaces
Everything is an artifact, so most of what follows is a content_type rather than a new plane.
-
HTTP. Five routers:
/artifacts(CRUD, children, commits, content,POST /artifacts/recallfor ranked and candidate-set retrieval),/grants(grants, invites, grant keys and key bundles),/events,/system(the whole admin namespace behind one predicate — issuers, users, seed, admin grants, andPOST /system/erasure/{person_id}), and/mcp./mcpis Model Context Protocol over Streamable HTTP, and the surface is ROUND:create_artifactstores andrecallfinds, besideupdate_artifact,delete_artifact,list_artifacts,get_artifactandget_children— seven tools. Every tool dispatches into the REST handler that owns the verb with the caller's own principal, so the write is the onePOST /artifactsmakes and the search is the onePOST /artifacts/recallmakes — including the field filters, the coverage ordering, and every 400 and 401 verbatim.create_artifacttakes an optionalidentity, which makes the write idempotent by deriving the id from a caller-chosen name for the thing being stored — see above./docsand/openapi.jsonare always served: the schema is the API's contract, not a secret, and every route behind it enforces its own authorization. There is no/secrets: a secret is avnd.agience.credential+jsonartifact whose value is its content, so the envelope encrypts it at rest and the light cone decides who may read it — one cipher and one authorization path, the same two every other artifact gets. -
The change feed. Every artifact write emits an event at the persistence chokepoint (
db/doc_boundary.py), so coverage is complete by construction.event_bus.pyfans out in-process and appends to a durable log a consumer resumes from by cursor; a subscription is an artifact (vnd.agience.subscription+json) carrying a filter and that cursor, created and shared through/artifactslike anything else.event_backplane.pyadds optional Redis or MQTT distribution across processes — unconfigured is a supported configuration, not a degraded one, and the app refuses to boot multi-worker without one rather than dropping events quietly. Live delivery re-runs the ACL filter in every receiving process, so a back-plane carries signal and never authority. -
The context lattice. A context is an artifact too (
entities/context.py), composed over context edges by one bounded, attenuating walk (services/context_service.py). The walk takes a required authority ceiling — the id universe it may not leave — so a context edge only ever narrows and can never manufacture reach;UNCONFINEDis a named opt-out for callers wanting the lattice's shape rather than an authorization answer, and there is no default, so no call site gets the unsafe one by not thinking about it. It is additive: cell-key derivation isHKDF(master, collection_id ‖ cluster_id), unchanged, and no ciphertext moves. Todayservices/dependencies.check_access— the gate in front of every read — walks containment only, so the resolver holds the context walk to the grant-derived set and it contributes nothing. Deliberately: two answers to "what may this principal reach" that disagree is a key issued for an artifact the gate would refuse, and the narrower answer is the only safe one. -
Vector ingress. An artifact create or update may carry an optional
vectorplus thespace_idit belongs to;space_idis required whenevervectoris present, because two vectors are comparable only within a named space.api/vectors.pyvalidates shape only — finite numbers, a bounded positive dimension, a non-zero norm — never quality. Mantle stores what a writer produced and never embeds.POST /artifacts/recalltakes the same pair as the reader's half of that seam, so a caller holding a query vector supplies it the way a writer supplies the vector of what it stores. Both halves reach a semantic arm that stays inert until an AnchorSet is seeded — see above. -
Query syntax.
query_textcarries terms and filters together. Terms:+term(required),!term(excluded),~term(selects what gets embedded),="phrase"(exact). Filters narrow the result set:Filterable id,root_id,collection_id,content_type(aliastype),owner_id,title,description,tags(aliastag),created_at,updated_atOperators field:value(case-insensitive;a,bis any-of) ·field:="Exact Value"(case-sensitive, whole) ·!field:value·field:>value/field:<valueoncreated_at/updated_atonlyCombining filters conjoin; each additional one narrows further Refused, with a 400 naming it and why state:,content:,size:,filename:— fields a caller may reasonably expect that this store cannot answer ·field:~value· a range on an unordered field · a query of nothing but filtersNot a filter at all any other word — it searches as an ordinary term word:valueis a filter only whenwordis a field on one of those two rows, sohttps://example.com,meeting at 3:30,C:\Users\exampleandratio 16:9are ordinary searches: a colon in a token the field list does not name is just a character in that token, and it reaches retrieval unquoted and unchanged. The parser holds no field list of its own — it askssearch/field_filters.is_filter_field, the same roster the resolver resolves against, so the two cannot disagree about what a field is. The cost is that a misspelled field is a search term, not an error:titel:foosearches for the literal texttitel:fooand finds nothing, rather than telling youtitelis not a field. Check the Filterable row when afield:valuequery returns nothing.A filter resolves to a set of artifact ids and is intersected with the light cone before retrieval, so both arms honour it identically and
totaland pagination count filtered matches. It can only ever narrow: the predicate is shown docs of authorized artifacts only, so no filter can reveal — or hint at — an artifact the caller could not already read, and a filter naming an unreadable artifact is indistinguishable from one matching nothing. Filterable is everything a doc plainly carries;contentis not, because it is encrypted at rest and its postings are blind tokens over the 5.9% the encrypted index covers, plaintext postings over the rest.stateis not either — it selects the index segment, which is a separately keyed tree chosen before the query runs, so it stays thestaterequest field. Both are refused with a 400 naming them, because both are fields a caller can reasonably expect — being unfilterable here is a fact about the store, not a spelling mistake. Filter tokens never reach the index: retrieval sees the terms only, andapplied_filterson the response lists what actually narrowed the result. Quoting forces a term either way, which is how you search for a field's name literally ("type:pdf").
Security invariants
Four properties hold across the codebase, are asserted by tests, and must survive every change:
- Geometry never authorizes. Embeddings and routing rank within an already-authorized candidate set; they never widen one. The routing path receives no key material and runs strictly before any key request.
- Authorization is decided only by the light cone and grants, and the light cone is bounded above by them. An unauthorized context is never routed to, padded in, or key-issued. Access is default-deny; there is no owner fast-path — even the creator holds an explicit, revocable grant. Two tighter statements hold inside this one, and both are structural rather than asserted after the fact:
- The resolve cannot exceed the read gate. The context walk is confined to the grant-derived id set, so
resolve(principal, action) ⊆ grants-alone(principal, action)is a property of how the call is made. A resolver that reached further would hand out a content key for an artifactcheck_accessthen 404s. - A grant on one artifact means one artifact. Recall cuts twice: a posting entry must clear the collection cut (which index may be read at all) and the artifact-granular cut from the same resolve. Without the second, sharing one document would expose every document filed beside it. An empty authorized set and an absent one are distinct — empty means "the light cone authorized nothing" and returns nothing, never the whole scope.
- The resolve cannot exceed the read gate. The context walk is confined to the grant-derived id set, so
- Ciphertext is bound to its identity. Every cell is AEAD-encrypted with associated data bound to its context and cluster, so a blob presented under the wrong key or moved to the wrong slot fails authentication before deserialization.
- Revocation requires no re-encryption. Removing a grant alone prevents routing to, deriving keys for, and decrypting the affected cells.
Composition along a path is monotone and non-amplifying, and there is exactly one operator
that makes it so. src/mantle/attenuation.py holds the meet: a bounded meet-semilattice over the
CRUDEASIO action set, with an absorbing deny and a full-authority identity. Both storage encodings
— the edge.propagate TEXT column and Grant's nine can_* booleans — are codecs onto the same
Mask type and round-trip through it, so the light-cone walk and a grant's own mask cannot
disagree about the zero element. tests/test_attenuation_algebra.py proves the laws exhaustively
and tests/test_attenuation_is_single_sourced.py sweeps src/mantle by AST for a second
implementation. Invariant #1 is the same principle but not this operator: it stays an ordering and
import-boundary discipline, enforced where it already is.
If a contribution weakens any of these, it will not be merged — see CONTRIBUTING.md.
Backing a node up
This section is a runbook rather than a feature. Mantle ships no backup, snapshot, restore or corruption-detection code — nothing here runs on a schedule, nothing verifies a copy, and nothing notices if you never make one. What follows is the procedure an operator runs with the tools their platform already has. It is written down because the store is a file and a directory, so the procedure is short and getting it slightly wrong is silent.
A node is four things, and a backup missing any one of them does not restore:
| Part | Where | Notes |
|---|---|---|
| The lattice | MANTLE_LATTICE_PATH |
One SQLite file, in WAL mode — so it is really three files (.db, -wal, -shm). |
| Key material | KEYS_DIR |
Without this the rest is unreadable ciphertext. See below. |
| Content | The local CAS under AGIENCE_BASE_DIR/.data, and/or the content bucket |
Whichever tiers this node uses — see CONTENT_* in .env.example. |
| The indexes | MANTLE_SSE_DIR, MANTLE_CELL_DIR |
Derived from the lattice, and a full rebuild is measured in days-to-weeks under S3 write contention (main.py), so treat them as data rather than as cache. |
Copy the lattice with VACUUM INTO, not with cp. A plain file copy of a WAL-mode database
while the service is running captures the .db without the committed pages still in the -wal,
and the result opens without complaint and is missing recent writes. VACUUM INTO runs inside a
read transaction and writes one consistent, already-compacted file, with no downtime and without
blocking writers:
sqlite3 "$MANTLE_LATTICE_PATH" "VACUUM INTO '/backups/mantle-lattice.db'"
Copy KEYS_DIR and the content tiers with an ordinary file copy, and take the key material
separately and under different custody. Grants are keys, not metadata: a copy of the lattice
without encryption.key decrypts to nothing, and a copy of both in one place is a single object
that surrenders the whole store.
Restoring is placing those parts back where the environment points and starting the service —
there is no import step and no restore command. Restore into a node whose KEYS_DIR holds the
same keyset the backup was taken under; a different one leaves every secret and platform setting
permanently unreadable, which .env.example warns about at length under KEYS_DIR.
Nothing above is verified by this repository. A backup you have not restored is a hypothesis; restore into a scratch node and read an artifact back. Scheduling, retention, verification and off-site replication belong in your platform.
Contributing
Bug reports, tests, and hardening contributions are welcome. Read CONTRIBUTING.md first — Mantle has a security-invariant test discipline that contributions must follow.
Security issues → connect@agience.ai (do not open a public issue).
License
Apache License 2.0. See LICENSE and NOTICE. Contributing: CONTRIBUTING.md.
Trademarks. "Agience" and the Agience logo are trademarks of Ikailo Inc. Apache-2.0 §6 licenses copyright and patent, not the marks — take the code, build on it, ship it; call your product your own name. Worth stating on a permissive repository precisely because permissive is otherwise read as "everything is granted".
Star History
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file agience_mantle-0.1.0.tar.gz.
File metadata
- Download URL: agience_mantle-0.1.0.tar.gz
- Upload date:
- Size: 2.0 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
40d2a5365665564e6a45e2f0ce7948fb96b85433e7f21b3b8d380e69fd53f13b
|
|
| MD5 |
43ce248b6d06090675a5f101a10fb556
|
|
| BLAKE2b-256 |
3b86229b9fbd7f923e8f143431155c2fd8ac14335c172a454a36e7c030376a2a
|
Provenance
The following attestation bundles were made for agience_mantle-0.1.0.tar.gz:
Publisher:
publish.yml on Agience/agience-mantle
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agience_mantle-0.1.0.tar.gz -
Subject digest:
40d2a5365665564e6a45e2f0ce7948fb96b85433e7f21b3b8d380e69fd53f13b - Sigstore transparency entry: 2712011987
- Sigstore integration time:
-
Permalink:
Agience/agience-mantle@e8c84063f93fde9390d54720702ef17d2ad0abc3 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Agience
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e8c84063f93fde9390d54720702ef17d2ad0abc3 -
Trigger Event:
push
-
Statement type:
File details
Details for the file agience_mantle-0.1.0-py3-none-any.whl.
File metadata
- Download URL: agience_mantle-0.1.0-py3-none-any.whl
- Upload date:
- Size: 1.3 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
16376ac78b7906a16ec68cd74bdc0b82a1a877b23c134a84482eb139b6255b00
|
|
| MD5 |
ee4aa8edd093b2c68738a70fb46839bc
|
|
| BLAKE2b-256 |
a7f2067996f6b3b3a611f6a5fece774096db180a3ed0b7e625d6ff16f85182f4
|
Provenance
The following attestation bundles were made for agience_mantle-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on Agience/agience-mantle
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agience_mantle-0.1.0-py3-none-any.whl -
Subject digest:
16376ac78b7906a16ec68cd74bdc0b82a1a877b23c134a84482eb139b6255b00 - Sigstore transparency entry: 2712012025
- Sigstore integration time:
-
Permalink:
Agience/agience-mantle@e8c84063f93fde9390d54720702ef17d2ad0abc3 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Agience
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e8c84063f93fde9390d54720702ef17d2ad0abc3 -
Trigger Event:
push
-
Statement type: