Agience Mantle
The lattice — an encrypted artifact store and search engine where authorization is the encryption.
Every artifact carries its identity, version history and provenance inside itself, so audit is the data structure for everything written through the system. 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. The cell key 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. 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.
Grants are keys, not metadata.
What is encrypted, and what is not. Content is encrypted per principal and bound cryptographically to the collection it was written for. 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 closes exactly that, and covers 5.9% of our reference corpus today, with the remainder served by a plaintext lexical index.
Durability is an operator responsibility — Backing a node up is the procedure.
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.
pip install 'agience-mantle[service]'
# A fresh KEYS_DIR does not boot: the lifespan loads key material. This writes a
# throwaway development keyset.
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
An install puts four commands on the path:
| command | what it does |
|---|---|
mantle-serve |
wraps uvicorn, so the app object, the default port and the log config travel with the package. Needs [service] |
mantle-init-keys |
writes a development keyset — the signing key and the trust anchor that verifies it, so a node trusts its own key from first boot |
mantle-token |
mints a user token against that keyset, offline, over KEYS_DIR |
mantle-wal-checkpoint |
reclaims the write-ahead log without a restart, for a node that has been up for weeks and cannot be bounced. It needs the readers quiet and exits non-zero when they were not |
Set AGIENCE_BASE_DIR on a pip-installed node. It is the root every derived default hangs off —
the SSE index, the encrypted cells, the embeddings cache, KEYS_DIR and MANTLE_LATTICE_PATH.
Unset, an installed node derives it from the directory it was started in, so starting the same
node from elsewhere brings it up healthy on an empty universe. A checkout keeps deriving the repo
root, so .data/ stays beside src/ when developing here.
Mantle boots as a pure database layer with an empty type registry; an application on top provisions data through the API.
Connect a client
A standalone node is a complete node — one keyset, one file, no bootstrap step. In a second shell:
mantle-token --keys-dir ./.data/keys
Minted a user token, signed by ./.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..."
Three values in that block are read from the code rather than chosen by the command. audience
is config.AUTHORITY_ISSUER, the same attribute the verifier compares against — a name a token
must carry, not a host anything dials. expires is
services/auth_service.ACCESS_TOKEN_EXPIRE_HOURS. subject is
uuid5(instance.uuid, "mantle/local-user"), derived from the keyset, so re-running mints for the
same person and everything the last token stored stays reachable. --subject <label> names a second
identity on the same keyset.
Paste the claude mcp add line and the client is connected. The same thing over curl:
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":{
"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.
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.
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. Without identity the id is a
fresh uuid4 per call, so a client must remember the id it was given, and a write whose reply is
lost still succeeds — leaving the next write to create a second root.
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. identity is top-level only; combining it with
container_id is a 400 naming the reason, because a member of a collection has a draft/committed
lifecycle with more than one live version.
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}}}
Results are under hits. ordering: "coverage" is the lexical arm answering, and score is the
count of distinct query stems that hit carries. title is null because name and title are
different fields — title comes from the context JSON.
KEYS_DIR is the root credential of a standalone node. Read access to it is full access to the
store, bounded by no grant, no revocation and no expiry. mantle-token names that exposure rather
than creating it. Back the directory up separately and under different custody.
Authenticate with a static Authorization header. A standalone node serves one document of the
OAuth surface, /.well-known/oauth-protected-resource, and config.authorization_servers() names
an authority 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. Point a node at a real issuer and the document names it.
Semantic recall, and seeding an AnchorSet
A node you just installed answers POST /artifacts/recall from the lexical arm. 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.
The 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 —
space_idmust 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.
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.
An anchor id is content-addressed over (label, model_id, embedding), so anchors fitted to whatever
corpus a node happens to hold would mint region ids no other node computes — two deployments each
routing confidently into disjoint cells. Vectors therefore arrive from a caller; this process runs
no model and fits no projection between spaces.
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", score is null |
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 message names both ways out: seed the set, or send the recall without vector |
| 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, since 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
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. --dry-run verifies the file
and reports its fingerprint without writing. Reindex afterwards so already-stored artifacts reach
the vector cells.
Load anchors with 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. The write path
assigns a fresh uuid4, so posting anchors one at a time replaces exactly the value that makes two
nodes' cells comparable: queries route to regions the writer never produced, cells miss, 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 against its own content, refusing a file whole if any
anchor disagrees.
| 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 |
the width is not the set's dim, or space_id is not its 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 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 |
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, and POST /artifacts/recall
for ranked and candidate-set retrieval), /grants (grants, invites, grant keys and key bundles),
/events, /system (the admin namespace behind one predicate — issuers, users, seed, admin grants,
and POST /system/erasure/{person_id}), and /mcp.
/mcp is Model Context Protocol over Streamable HTTP, and the surface is round: create_artifact
stores and recall finds, beside update_artifact, delete_artifact, list_artifacts,
get_artifact and get_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 one POST /artifacts makes and
the search is the one POST /artifacts/recall makes, including the field filters, the coverage
ordering, and every 400 and 401 verbatim.
/docs and /openapi.json are always served: the schema is the API's contract, and every route
behind it enforces its own authorization. A secret is a vnd.agience.credential+json artifact 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.py fans 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
/artifacts like anything else. event_backplane.py adds optional Redis or MQTT distribution across
processes — unconfigured is a supported configuration, 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;
UNCONFINED is a named opt-out for callers wanting the lattice's shape rather than an authorization
answer, and there is no default. It is additive: cell-key derivation stays
HKDF(master, collection_id ‖ cluster_id) and no ciphertext moves. services/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. Two answers to "what may this principal reach" that disagree is a key issued
for an artifact the gate would then 404.
Vector ingress. An artifact create or update may carry an optional vector plus the space_id
it belongs to; space_id is required whenever vector is present, because two vectors are
comparable only within a named space. api/vectors.py validates shape — finite numbers, a bounded
positive dimension, a non-zero norm. POST /artifacts/recall takes the same pair as the reader's
half of that seam.
Query syntax. query_text carries terms and filters together. Terms: +term (required),
!term (excluded), ~term (selects what gets embedded), ="phrase" (exact).
| Filterable | id, root_id, collection_id, content_type (alias type), owner_id, title, description, tags (alias tag), created_at, updated_at |
| Operators | field:value (case-insensitive; a,b is any-of) · field:="Exact Value" (case-sensitive, whole) · !field:value · field:>value / field:<value on created_at / updated_at only |
| Combining | filters conjoin; each additional one narrows further |
| Refused, with a 400 naming it and why | state:, content:, size:, filename: · field:~value · a range on an unordered field · a query of nothing but filters |
| Not a filter at all | any other word — it searches as an ordinary term |
word:value is a filter only when word is a field on one of those two rows, so
https://example.com, meeting at 3:30, C:\Users\example and ratio 16:9 are ordinary searches.
The parser holds no field list of its own — it asks search/field_filters.is_filter_field, the same
roster the resolver resolves against. The cost is that a misspelled field is a search term:
titel:foo searches for the literal text and finds nothing, so check the Filterable row when a
field:value query 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 total and pagination count filtered matches. It
can only narrow: the predicate is shown docs of authorized artifacts only, so a filter naming an
unreadable artifact is indistinguishable from one matching nothing. Filterable is everything a doc
plainly carries; content is encrypted at rest, and state selects the index segment — a
separately keyed tree chosen before the query runs. Filter tokens never reach the index, and
applied_filters on the response lists what actually narrowed the result. Quoting forces a term
either way, which is how to 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. 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. Access is default-deny, and even the creator holds an explicit, revocable grant. Two
tighter statements hold inside this one, both structural:
- 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 grant on one artifact means one artifact. Recall cuts twice: a posting entry must clear the collection cut and the artifact-granular cut from the same resolve. An empty authorized set and an absent one are distinct — empty 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 one operator 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.
On the data plane Mantle serves and verifies rather than asserts: it never fabricates provenance and
never embeds on its own behalf. It signs exactly one thing
(services/peer_signing.py): a short-lived, audience-scoped
service JWT saying "Mantle is calling", used on its one outbound peer call. RFC 8693 delegation is
inbound-only — 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.
Layout
| path | what it is |
|---|---|
db/ |
the 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 object-storage 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, and peer_signing.py. content_crypto.py is the per-principal content envelope; acting_principal.py answers who is acting, principal.py what artifact a principal is |
api/ |
Pydantic request/response models by domain, including vectors.py |
entities/ |
entity models and serialization. A collection is an artifact; collection.py says so literally, and context.py and subscription.py are the same move |
search/ |
retrieval. embeddings.py and embeddings_cache.py are the vector arm's provider facade and cache; mantle/sse/ is the encrypted lexical arm; mantle/lightcone.py is authorization as reachability; anchors/ and beacon/ are the semantic arm and its result cut; ingest/ is the indexing queue |
attenuation.py |
the one authorization meet — CRUDEASIO masks, deny absorbing, composed along every path |
events/ |
the change feed: in-process fan-out with a durable log, plus the optional back-plane |
system/ |
boot and operations — logging, runner_hooks.py, and the manage_*.py bootstrap, seed, addon and anchor operations |
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 |
shard/ |
persistence beneath the store: local cache and regions, content tiering, curation, erasure |
clients/ |
the wire outward. origin_client.py is the one outbound peer client; artifact_helpers.py is the consumer's side, mapping content_type ⇄ mimeType |
ui/ |
server-rendered browse pages |
scripts/ |
the console-script implementations plus manage_erasure.py, CAS rekey and usage snapshots |
tools/ |
one-shot migrations |
tests/e2e/ |
the blackbox HTTP suite, driving a live stack over the wire — see tests/e2e/README.md |
.env.example |
config template. Only MANTLE_LATTICE_PATH and KEYS_DIR are set outright, so an untouched copy runs on defaults |
Backing a node up
This is a runbook. Nothing here runs on a schedule, verifies a copy, or notices if you never make one — 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 really three (.db, -wal, -shm) |
| key material | KEYS_DIR |
without this the rest is unreadable ciphertext |
| 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 write contention, so treat them as data |
Copy the lattice with VACUUM INTO. 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: 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.
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. 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.
Star history
Security issues: email connect@agience.ai rather than opening a public issue.
Licensed under Apache-2.0 — see LICENSE and NOTICE.
Declaration of generative AI use
The author used Anthropic's Claude Opus (versions 4.8 and 5) in the preparation of this work. Its contribution was to write code, and to generate and validate content. The ideas, the construction and the claims are the author's. No other generative AI tool was used. The author reviewed and edited all output and takes full responsibility for the content of this publication.
Metadata
Release files for agience-mantle 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| agience_mantle-0.1.1.tar.gz | 2.0 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| agience_mantle-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 3.2 MB
Release files / agience_mantle-0.1.1.tar.gz
| Download URL | agience_mantle-0.1.1.tar.gz |
|---|---|
| Size | 2.0 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
51372fd05c6395e5dca97762bc1d3c582bd5c4d538626c2dea95d8ce1cb94655
|
|
BLAKE2b-256 checksum How to use checksums |
6d0b6aee691f9e7dd52d09268787ad788f94fac4f86b82a2aafa320c18f8db32
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.
Transparency logRelease files / agience_mantle-0.1.1-py3-none-any.whl
| Download URL | agience_mantle-0.1.1-py3-none-any.whl |
|---|---|
| Size | 1.2 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
50fa851fd68fcb55b72f7ea1a0bb42bf961e9cc9aa5d9cf17634447f5b78132e
|
|
BLAKE2b-256 checksum How to use checksums |
5b20908e438802ef614ffb9644363375f6d6bda512d653b702a3fc3b02bc4ed0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.
Transparency log