Changelog#
All notable changes are recorded here. The format follows Keep a Changelog and the project uses Semantic Versioning. From 1.0 on, the public API is stable.
Unreleased#
Documentation#
A 47-second film on the documentation’s front page, linked from the README: what wardowl does, shown with an AI assistant that many companies share.
2.1.0 - 2026-10-24#
Fixes found by real-world acceptance scenarios, run against real servers, a real identity provider
and real models: a SpiceDB setting that let a banned user read through their group, a permission
service’s error text reaching models, Ory Keto’s batch limit, stale redaction spans failing every
retrieval, Chroma’s batch limit, and the example gateway’s streaming and OpenAI content parts. The
scenarios join the project and run weekly against the release on PyPI. SpiceDB’s
group_subjects and Keto’s batch_size change their defaults; see Security and Fixed.
Added#
Acceptance scenarios (
acceptance/): the applications a company builds with wardowl, run from the documentation against real servers, a real identity provider (Keycloak), real embedding and reranking models, and a local LLM, with every result checked against an oracle of who may read what that does not use wardowl. They cover 14 stores, seven permission systems, LangChain, LlamaIndex, Haystack and MCP, the HTTP service, day-2 operations with servers stopped mid-traffic, redaction and reranking, a team’s own store and policy, and the example gateway: 3,912 checks, run weekly against the release on PyPI. A Kaggle notebook runs the assistant at scale on GPUs.wardowl.InvalidQuery, aValueErrorfor a query that cannot be searched through its own fault, raised byQuery.embedded,Query.keywords, the retriever’s checks ofk, Vespa’s keyword mode andFaissBackend(a query vector whose length is notdim). Code that catchesValueErrorkeeps working; a backend of your own can raise it.OIDCAuthenticator(ssl_context=...)fetches the identity provider’s keys with that context, for a provider whose certificate a private CA signed, instead of a process-wideSSL_CERT_FILE.InMemoryBackend.delete(ids), as every other backend has; ids not stored are ignored.ChromaBackend(batch_size=...).
Security#
Example gateway (
examples/gateway): the audit record links the records of one question by an HMAC-SHA256 under a key derived fromRAMPART_KEY_SECRET(question_hmac) instead of an unkeyed SHA-256 (question_sha256), which anyone who could read the log could reverse when the only unknown in a question was a masked phone or ID number, by hashing every candidate.rampart ingestandrampart verify-auditnow needRAMPART_KEY_SECRET(orRAMPART_DEMO=1), asrampart servealready did. A request from a disabled principal is now audited, without anything it carried, and counted asaccount_disabled; it was refused without a record, like a request with an unknown key. Every read of the audit log, through/v1/audit,/v1/audit/verifyor the dashboard, is audited too.SpiceDBAuthorizerchecks only the user by default (group_subjects=False, wasTrue), and SpiceDB follows the group memberships it stores. With groups checked as subject sets of their own, a chunk was allowed when the user or any group had the permission, so under an exclusion such aspermission view = viewer - banneda banned user still read a document granted to their group (the group is not banned), and under an intersection a grant through a group was refused. If your group memberships live only in the token and your permissions neither exclude nor intersect, passgroup_subjects=Trueto keep the old behaviour; otherwise write the memberships to SpiceDB (group:<name>#member@user:<id>). A real-SpiceDB test checks that a banned member of a permitted group is denied.AuthorizationErrormessages are fixed and never carry the text of the exception behind them, which stays the error’s__cause__(and so in logged tracebacks;RetrievalEvent.causestill names its type). The retriever wrapped an authorizer’s or redactor’s exception asauthorizer failed: <its text>, so a failure such ascould not reach opa.internal:8181 as svc-wardowl (password hunter2)reached a model through Haystack’sAgent, which shows tool errors to it, and through LangChain and LlamaIndex agents. The same holds for the LlamaIndex postprocessor, the HTTP client the permission services share (which now names the exception’s type, not its text), Casbin, Cedar, Cerbosroles, OPA’s filter, SpiceDB’scaveat_contextand the OIDC key fetch.
Fixed#
Example gateway:
/v1/chat/completionsaccepts a message’scontentas OpenAI’s list of text parts, joined with newlines, as well as a string; it refused the list with 422, so SDKs and chat UIs that send it got no answer. The 4,000-character limit applies to the joined text, a message holds at most 50 parts, and a part that is not text (an image, audio, a file) is refused with 422 naming its type.Example gateway:
stream: trueon/v1/chat/completionsanswers in OpenAI’s server-sent events (chat.completion.chunkobjects, a last chunk withfinish_reasonand therampartblock, a usage chunk withstream_options.include_usage, thendata: [DONE]). It was ignored and answered with one JSON body, which streaming clients could not read. The answer is checked in full first and then streamed in pieces, so the policy, masking, audit and metrics are those of any other answer, and a denied request is the usual 403.Example gateway: denied ingests and audit reads are counted in
rampart_policy_denials_total, asingest_not_allowedandaudit_not_allowed; only denied questions were, although the reason codes existed. The dashboard’s denials by reason count every action too.Example gateway: Compose passes the gateway’s settings through, with the gateway’s defaults.
RAMPART_LLM_API_KEY, needed for a model server that wants a key,RAMPART_MAX_BODY_BYTES, the embedding model and dimension and the tuning settings could not be set.RAMPART_LOCAL_MODELmakes thelocal-llmprofile pull a smaller chat model and serve it under the name the policy approves.RAMPART_DEFAULT_MODELis gone: nothing read it, since the policy chooses the model.KetoAuthorizersends 10 tuples per batch check by default (batch_size=10, was 100). Keto refuses more thanlimit.max_batch_check_sizetuples, 10 unless configured, with HTTP 400, so with both defaults every check of more than 10 chunks failed, closed. Raise both together for larger batches. The real-Keto CI tests now send more than 10 tuples.SpanRedactorhides a chunk whole when one of its spans ends past the text, as when the text was shortened after its spans were written, as it does for asha256mismatch. It used to raise, so every retrieval that reached the chunk failed, for its readers too. Spans that are wrong whatever the text (not two integers with0 <= start < end, and the other malformed cases) still fail the retrieval.ChromaBackendwrites in batches of at mostbatch_sizerecords: the client’s own maximum (get_max_batch_size(), about 5,400 for a local client) by default, or 1,000 when the collection reports none.add,deleteandset_metadatasent everything in one call, which Chroma refuses above its maximum. Every record is checked before the first batch is written.An open
CircuitBreakerraisesAuthorizationErrorcaused by the newwardowl.authorizers.CircuitOpenError, soRetrievalEvent.causeis"CircuitOpenError". It had no cause, which monitoring could not tell from a malformed answer.The HTTP service answers 422, echoing nothing, for a query it cannot search as sent (the new
wardowl.InvalidQuery):kover the retriever’smax_candidates, only a vector for a keyword backend, only text for a backend without an embed function, or a vector of the wrong length forFaissBackend. These answered 500. Other errors remain server faults and answer 500.
Documentation#
Concepts says what each strategy sends to the store: post-filtering searches inside the authorizer’s scope when it has one, and for an
AllOfwhose parts all give filters that scope is the whole filter, even withstrategy="postfilter". The authorizer table lists every built-in authorizer. Combining and the Chroma page’s speed advice say the same.Combining shows
TenantAuthorizer’sattributeas it is (None, meaning the field’s name) and how one chunk serves several tenants through a list-valuedtenantfield.Vespa:
namespaceonly names document paths, a search covers the whole schema, and tenants are kept apart by the permission filter or by a schema each. Redis: each index needs its own prefix, and clients need socket timeouts. Qdrant: indextenantin multi-tenant collections. Cassandra:create_tabledoes not create the keyspace. Chroma, Elasticsearch and OpenSearch: searches are approximate, with what tunes their recall.Hybrid search lists Vespa’s keyword mode, and Your own vectors covers LanceDB, FAISS, Vespa, Cassandra and Pinecone and what a query vector of the wrong size raises.
Redaction warns that a span’s
sha256of a short value can be recovered by guessing, so the spans must not reach callers.Extending says
check_authorizerproves consistency, not a correct policy, and shows a test of known cases.SpiceDB explains why
group_subjects=Truefails under an intersection or an exclusion, and what to do instead. Casbin says what a customrequestskips and must do itself. Keto and OpenFGA show how to keep tenants apart in one server. OPA recommends files or bundles over policies uploaded through its API.Operations, Security and the integrations overview name all five HTTP authorizers, and the shared cache’s Redis client gets timeouts. Revocation covers Keto, Cedar and Casbin.
HTTP service: mapping a real provider’s tokens, with Keycloak’s
sub, group paths, admin-only claims and audience mapper. LangChain: a per-callsubject=wins overfor_subject. Haystack: what a model can set on the agent tool,inputs_from_state, andoutputs_to_statefor getting the documents back. MCP: a stdio server for a signed-in user. Agent frameworks: themcp2 client takes its header on anhttpx2client, and the MCP adapters for LangChain, LlamaIndex and Haystack.Example gateway: the README says exactly which requests are audited (not
/v1/me, nor requests refused before the policy is asked with 401, 413 or 422), how the policy chooses the model and how to change it or serve a smaller one under the approved name, and how to stream. The quickstart waits for the models before ingesting (docker compose wait ollama-pull), usesdocker compose exec -T, and says the model server needs the models pulled. It gives the status and body of an over-limit request (422) and of a disabled principal (403,{"detail": "account disabled"}), lists theadmindemo principal, and explains that the express delivery question gets no answer because the vendor FAQ is a single chunk, quarantined whole. The threat model covers the keyed fingerprint, audited audit reads and disabled principals, and the limit on content parts.The Keto guide explains the batch limit and how to raise it; the example configuration keeps Keto’s default on purpose.
The HTTP service guide lists every status code, 500 included, and shows
ssl_context.The SpiceDB guide describes the new default and why
group_subjects=Truemust not be used with permissions that exclude or intersect; the README and the review checklist say the same.The security model, threat model, operations guide, runbook and Haystack guide describe fixed error messages and the
CircuitOpenErrorcause; the redaction guide says which spans hide a chunk and which fail the retrieval; the Chroma guide describes batched writes.OpenSearchBackend’s docstring said to setef_searchin the index settings; that applies to the Faiss engine only, and with the default Lucene engine the query’sksets the search breadth.The README’s Redis example sets
socket_timeoutandsocket_connect_timeout, so a Redis that stops answering fails the search instead of holding it.InMemoryBackend.addsays why it takes noembeddings: it matches words and keeps no vectors.
2.0.1 - 2026-10-24#
The wardowl logo arrives, on PyPI, in the README and in 3D on the documentation’s front page, and the build tools in the lock file move past seven advisories.
Documentation#
The README, PyPI page and documentation carry the wardowl logo, a midnight and gold owl guarding a document, with a favicon and a social card. The documentation’s front page shows it in 3D: the owl watches, and every ten seconds the redaction bar on its document lifts for a grant or stays for a denial. The model loads after the page, from a 1.5 MB file, and the still image stays for reduced motion, saved data or browsers without WebGL.
The security policy, the upgrade guide and the changelog describe needtoknow as it now is on PyPI: archived, with a last release that only installs wardowl.
Security#
The lock file moves filelock to 4.2.4 and setuptools to 84.2.1, past GHSA-2g95-wg33-c5x2, GHSA-567q-5qvw-pf92, GHSA-9vhv-jxww-xfm5, GHSA-crv4-6mhj-j5jh, GHSA-jqc4-5cvw-7w85, GHSA-v4x8-3wr5-3wq8 and GHSA-xmj3-9gf7-h52w. Both are build and test tools; the package does not depend on them.
2.0.0 - 2026-10-24#
The first release named wardowl: until 1.6.0 the project was needtoknow. It adds the Pinecone backend and the fixes from the project’s own security audit of every module, and it is a major release because several of those fixes change behaviour that 1.x code may rely on. Upgrading lists what to change.
Changed#
The project is renamed wardowl. Install it with
pip install wardowland importwardowl. The repository and the documentation moved togithub.com/muzakkirhussain011/wardowlandmuzakkirhussain011.github.io/wardowl(the old repository address redirects). Everything named after the project changes with it: classes (NeedToKnowRetrieveris nowWardowlRetriever), environment variables (NEEDTOKNOW_*is nowWARDOWL_*), the default cache namespace and metric names, and the field names the backends store (needtoknow_terms,needtoknow_idandneedtoknow_metadataare nowwardowl_terms,wardowl_idandwardowl_metadata), so collections written by needtoknow must be indexed again. needtoknow is archived on PyPI; its last release, needtoknow 2.0.0, has no code and only installs wardowl.Shared-cache keys changed, so entries written by an earlier version are not read after an upgrade, and during a rolling upgrade an
invalidate()made by one version does not clear the other’s entries: callinvalidate()once the upgrade is done, or allow forttl.The operations guide’s
set_metadataexample merges the stored metadata instead of replacing it with the changed permission alone.The public filter translators raise the new
wardowl.backends.NothingMatches(aValueError) for a filter that can match nothing, instead of returningNoneor{}, which store clients read as no filter at all:filter_to_query(Elasticsearch, OpenSearch, Redis),filter_to_where(Chroma),filter_to_qdrant,filter_to_mongo,filter_to_search,filter_to_expression(Milvus),filter_to_weaviate,filter_to_sql(LanceDB),filter_to_yqlandfilter_to_pinecone. Chroma’sNEVERconstant is removed. The backends still return no hits for such a filter without asking the store.A search with
kabove what the store returns in one query raisesValueErrorinstead of returning that many hits as if no more matched: 10,000 for Elasticsearch, OpenSearch, MongoDB Atlas Vector Search and Pinecone, and 1,000 for Cassandra.PineconeBackendwaits 30 seconds by default (wait=30.0, wasNone) for a search to see each write, so a revoked permission or a deleted chunk is out of searches when the call returns.wait=Nonekeeps the old behaviour.Haystack:
WardowlRetriever’ssubjectinput also accepts a function returning theSubject, called on every run. Because the input accepts any type, a connection to the component by component name alone is ambiguous: name the sockets, as inpipeline.connect("embedder.embedding", "retriever.query_embedding").
Added#
PineconeBackend(thepineconeextra): dense vector search on a Pinecone index through its data plane API (2026-07), with the permission filter as the query’s metadata filter:$inon hex-encoded, type-tagged terms, joined with$andand$or, so Pinecone applies it inside the search.wait=makes writes wait until a search sees them, for serverless indexes’ eventual consistency; ids that Pinecone cannot store are kept under their SHA-256; the API key never appears in errors. Tested with the conformance kit against a Pinecone serverless index.
Security#
CachingAuthorizer(shared=...)requires an explicitnamespace. Shared-cache keys held only the namespace, subject, kind, action and chunk, and the namespace defaulted to"wardowl", so two caching authorizers sharing one Redis answered for each other:AllOf(CachingAuthorizer(TenantAuthorizer(), shared=r), CachingAuthorizer(OpenFGAAuthorizer(...), shared=r))skipped OpenFGA. A process also refuses, withValueError, a second kind of authorizer under a Redis client (orSharedCache) and namespace it already uses.Cache keys are exact. Their parts were joined with NUL and no lengths, values were encoded with
json.dumps(default=repr), and this process’s keys compared subjects with==, so{1}and"{1}", the metadata keys1and"1", lists and tuples, the attributes1,Trueand1.0, and an action and chunk id containing NUL could share a cached decision. Keys are now digests of a length-prefixed, type-tagged encoding, and a subject or chunk holding a value of a type that has no exact encoding is not cached: the authorizer is asked every time.A shared-cache entry whose expiry is NaN or infinite is a miss.
float()accepted both, and a NaN expiry gave a local copy that never expired. Non-finite or non-positive lifetimes are never stored, andttlandlocal_ttlmust be finite.SpanRedactorhides the whole text of a chunk without its spans field (required=Trueby default;[]still means nothing to hide, andrequired=Falserestores the old behaviour). A missing field showed the chunk in full, andset_metadata, which replaces the metadata, dropped the field whenever permissions were updated without it. A span may now carrysha256(wardowl.redaction.span_digestof the text it covers); a chunk whose text no longer matches is hidden whole.Haystack:
BoundWardowlRetrievertakes the subject fromsubject_provider, a function such as one reading a contextvar the web layer sets, and has nosubjectinput.WardowlRetrievertakes the subject as a run input, soComponentTooloffered it to an agent’s model, which could search as anyone; agents must use the bound component.The LangChain, LlamaIndex and Haystack retrievers return no chunk metadata unless named in
metadata_fields, as the HTTP service and the MCP tool already did. They passed every key,allowed_users,allowed_groupsandtenantincluded, into the documents a model, prompts, callbacks and traces receive. LangChain documents keeprelevance_score.Subject.attributesis a deep copy that cannot be changed (read-only dicts, tuples, frozensets), and a subject’s id and groups must be strings. Changing the attributes of a subject already used as a cache key, directly or through a list the caller still held, could hand a later subject the decision cached for the original.FaissBackend.addkeeps only the last document of an id repeated in one call. Each copy used to get its own slot, and the earlier copies stayed in the index, out of reach ofdeleteandset_metadata, so searches kept returning them with their old metadata after a revocation.QdrantBackendandWeaviateBackendderive every point or object id from the SHA-256 of the chunk id (point_id), a chunk id that is already a UUID included. A UUID used to be kept as it was, so another spelling of it (upper case, braces,urn:uuid:), or an id chosen to equal the UUID derived from another, landed on the same point.set_metadataanddeletenow act only on a point whose storedwardowl_idis the id asked for (Qdrant: a filter on the payload; Weaviate: the property, checked before each update and required by each delete), so they never change or remove a chunk that holds another id. A point or object written by another tool, withoutwardowl_id, is reached by its UUID written exactly as search results report it. Collections must be indexed again, as the rename already requires.PineconeBackendstores a chunk id that starts withsha256-under its SHA-256, like ids Pinecone cannot hold, so no chunk id can share a record with another’s hashed id.set_metadataanddeletefetch the records first (GET /vectors/fetch) and leave alone any record that holds another chunk id.MilvusBackend.deleteandset_metadataselect rows withid in {ids}and the ids as a filter parameter. pymilvus’sget(ids=...)anddelete(ids=...)write string ids into the expression between quotes, so an id such asa','bselected the chunksaandbas well.set_metadatanever writes a whole chunk back. Milvus updates only the metadata and terms with a partial update (Milvus 2.6.2 or newer), LanceDB with an update-only merge of those columns, Chroma with an update (removing stale list fields in the same call), and Qdrant sets only the payload’s metadata key. Before, a chunk deleted whileset_metadataran could be inserted again with its old text, and a concurrentaddcould have its new text replaced by the old.CassandraBackend.set_metadatauses plain writes, likeaddanddelete, instead of a lightweight transaction. Cassandra orders writes to a row by timestamp, and the transaction’s Paxos timestamp does not order with the client timestamps of plain writes, so a permission change could be applied over a later one. Ids are looked up first, so an id that is not stored still creates no row; a chunk deleted between the lookup and the update is left without text or vector, which an ANN search never returns.PgVectorBackendmatches an integer value only where the stored number reads as that integer, as the reference semantics do: jsonb compares numbers by value, soIn("level", [1])used to match{"level": 1.0}. Metadata keys that are not strings are refused, since JSON turned the key1into"1", which a filter on the field"1"then matched.Chroma’s
filter_to_whererefuses field names that contain::or equalwardowl_metadata, which addressed the stored list fields or the JSON copy rather than a metadata field.CedarAuthorizerno longer leaves out metadata values Cedar cannot hold. A float, None or an integer beyond 64 bits used to vanish from a chunk’s attributes, so aforbid ... when { resource has x && ... }stopped applying and apermitcould let the chunk through (asensitivityof3.0from a store that returns every number as a float, say). Integers, and floats without a fraction, now become Cedar Longs; a chunk whose metadata holds any other such value, directly or in a list or record, is denied without asking Cedar and counted with the evaluation errors in the log; a subject attribute of that kind raisesAuthorizationError.CasbinAuthorizer(group_subjects=True)sends each group asgroup_prefix+ the group ("group:eng"; the prefix defaults to"group:"and cannot be empty). Groups used to be sent as the bare subject, so a group named like a user or a role ("admin","ceo@example.com") got that user’s or role’s access. Policies that grant to groups must now name them with the prefix, and a subject id that starts with the prefix raisesAuthorizationError.GoogleDriveACLanddrive_accessno longer count a Drive grant that has an expiry time. Such a grant was kept without its expiry, so the copy on the chunks outlived the share. Passexpiring_grants=Trueto count it until it expires:Access.expires_atis then the earliest expiry among the grants that count, andwith_accesswrites it to the chunks asaccess_expires_at(UTC, to the second), so the file can be synced again by then. A later sync without one removes the field.GoogleDriveACL.accessreturnsAccess(), readable by nobody, for a file that is gone or no longer visible to the credentials (HTTP 404, or 403 with the reasonnotFoundorinsufficientFilePermissions). It used to raise, so a deleted or unshared file kept its old grants for good. Rate limits, server errors and every other error are still raised, keeping the grants until a sync succeeds.The HTTP authorizers (OPA, OpenFGA, SpiceDB, Cerbos, Keto) never use a kept-alive connection in a process other than the one that opened it. A worker forked after a request (gunicorn
--preloadwith a request at start-up, for example) used to share its parent’s socket, and the processes could read each other’s permission answers.OpenFGAAuthorizerrefuses, withAuthorizationError, a subject id or group name that is empty or contains:,#,*or whitespace. They are written into OpenFGA’stype:idstrings, wherebob#managerwould be a userset and*the wildcard. Keto, SpiceDB and Cedar send ids in separate fields and are unaffected.The HTTP authorizers refuse
ssl_contextwith anhttp://url, where it was silently ignored, and refuse anAuthorizationheader (SpiceDB’stoken, for one) overhttp://unless the url names this machine and no proxy applies. Passallow_insecure=Trueto send it over a network you trust. Both raiseValueErrorwhen the authorizer is created.The HTTP authorizers’
timeoutnow bounds a whole call except the DNS lookup of the host: connecting to each of its addresses, the TLS handshake, sending, every read of the answer, and any retry. The standard library has no timeout for the lookup, so the system resolver’s own settings bound it, and the time it takes counts againsttimeout. It used to apply to each socket operation, so a server that sent its answer slowly could hold a retrieval for much longer. Answers larger than the newmax_response_bytes(10 MiB by default) are refused withAuthorizationError.wardowl.server.create_appruns a synchronous authenticator, such asOIDCAuthenticator, in a worker thread, and awaits an async one. It used to call it on the event loop, so one slow fetch of the identity provider’s keys stalled every request the service was handling. The MCP search tool does the same for a synchronoussubject_for.OIDCAuthenticatorkeeps the provider’s key set itself instead of inPyJWKClient’s per-key cache, which never expired. The set is fetched with a 5-secondtimeout(it was 30) and kept forlifespan(300 seconds), so a key the provider rotates out stops verifying within that time. A token naming a key that is not in the set fetches the set again at most once everycooldown(30 seconds), and until then is refused at once, so tokens with made-up key ids can no longer make the service fetch the keys on every request. A key endpoint that fails answers 503 and is not asked again before the cooldown ends.lifespan,cooldownandtimeoutare new keyword arguments.OIDCAuthenticatorverifies a token with its key’s own algorithm, which must be one ofalgorithms, rather than the algorithm the token’s header names. A token whose header names another algorithm than its key’s (ES256 over an RSA key, say), a shared-secret key in the published set, a key set with no usable keys, and any other PyJWT error now answer 401 or 503 instead of an internal server error with a traceback in the log. A token must name its key (kid).wardowl.serverrefuses a request body overmax_body_bytes(256 KiB by default) with 413 before it is parsed or the caller is authenticated, by itsContent-Lengthand by the bytes received. Avectorholds at mostmax_vector_lengthnumbers (4,096 by default), andNaNand infinities are refused with 422; they used to reach the retriever and fail with a 500.The MCP search tool’s
queryholds at mostmax_query_lengthcharacters (4,000 by default), a new argument ofadd_search_tool.The
serverextra needs PyJWT 2.14 or later (it allowed 2.10.0, whose issuer check matched substrings, CVE-2024-53861; 2.14.0 also fixes the later advisories in the codewardowl.serveruses), and thelangchainextra needs langchain-core 1.3.3 or later (it allowed 1.0 to 1.2.4, open to serialization injection indumpsandloads, CVE-2025-68664, and releases before the fixes for the later prompt template andload()advisories).The Lock workflow resolves in a job with a read-only token and no stored credentials, never builds a source distribution, and refuses a version uploaded less than 7 days ago unless it was named in
upgrade; only a second job, which runs no package code, can push, and it starts CI on the commit it pushes. The Pinecone tests, the only ones that receive an API key, run in a job of their own with only the packages they need and pytest’s plugin autoloading off, onmainor by hand, never for a pull request.Example gateway (
examples/gateway):/v1/chat/completionsaccepts at most 20 messages of at most 4,000 characters, and both question endpoints cap model and collection names; the email pattern and the markdown image rule, which run before the policy decides, now take linear time (they were quadratic on inputs such asa.a.a.a...). API keys are stored as HMAC-SHA256 digests under a secret the gateway holds (RAMPART_KEY_SECRET) instead of scrypt hashes, so a request with a made-up key no longer costs 16 MiB and tens of milliseconds before authentication; existing principals files must be hashed again withrampart key-hashingandrampart hash-key. The demo principals, whose keys are published, are refused unlessRAMPART_DEMO=1; the image no longer contains a principals file, and Compose mounts it and publishes the API on127.0.0.1only. Prometheus metrics moved from/metricson the API to their own port (9464, not published by Compose), and policy denials are counted by fixed reason codes, so a caller’s model name can no longer add label values.CedarAuthorizerno longer leaves out metadata keys that start with__or are not strings. A chunk holding{"__restricted": true}lost the key, soforbid ... when { resource has "__restricted" }never applied and a broadpermitlet the chunk through. Cedar’s JSON reserves only__entity,__extnand__expr, for its escapes, so other keys that start with__now reach Cedar (withschema, declare them like any other attribute). A chunk whose metadata holds a reserved key or a key that is not a string, at any depth, is denied without asking Cedar and counted in the log, like a value Cedar cannot hold; a subject attribute holding one raisesAuthorizationError. The entity references wardowl writes itself, and theentitiesyou pass, are unchanged.PgVectorBackendwrites a float without a fraction with one (10000000000000000.0), in lists and nested objects too.json.dumpswrites every float of magnitude 1e16 or more with an exponent (1.2345678901234568e+16), which jsonb stores as an integer, so the value came back as an int andIn("owner", [12345678901234568])matched it, in SQL and in the retriever’s re-check, thoughmatchesdoes not. A pandas integer column with a missing value holds such floats. Rows written before keep the integer until they are written again withaddorset_metadata.FaissBackendkeeps its own copy of each chunk’s metadata and returns copies, asInMemoryBackenddoes.addkept the caller’sDocument,set_metadataa shallow copy of the dict, andsearchreturned the stored dict, sohits[0].metadata["allowed_groups"].append("eng"), or a change to a dict after passing it in, grantedengfrom the next search.ChromaBackend.searchdrops a hit whose metadata, rebuilt from the JSON copy, the filter does not match.set_metadatareads the stored fields and updates them in two calls, so anaddbetween them could leave list fields the update did not remove, and thewherefilter matched the chunk on a group its metadata no longer granted.WeaviateBackend.set_metadataanddeletecount an object as written by another tool only when it has no stringwardowl_id, as search results do, not when the id is empty. A chunk stored with the id""counted as another tool’s, so a call naming its object’s UUID as the chunk id changed or removed it.VespaBackendrefuses the chunk ids.and..withValueErrorinadd,set_metadataanddelete, before sending any request. httpx drops such a path segment, so the request went to another path, where a 404 passed for “not stored”: adeleteorset_metadataof..changed nothing and returned as if done. Vespa’s/document/v1refuses these ids percent-encoded too.Haystack: a model calling
WardowlRetrieverthroughComponentToolcan no longer choose the subject.ComponentToolofferedsubjectas a tool parameter, and it passes on every argument a model sends, even one it does not offer, converting it with pydantic, which built aSubjectfrom a JSON object. Thesubjectinput is now typedCallable[[], Subject] | Any:ComponentToolleaves out an input whose type holds a function, and a value a model sends anyway arrives as JSON, which the component refuses with nothing retrieved. In an agent, give the tool the subject from the agent’s state withinputs_from_state={"subject": "subject"}.BoundWardowlRetrieveris unchanged.LlamaIndex’s
PermissionNodePostprocessorreturns copies of the nodes it keeps, with only the metadata keys named in the newmetadata_fields, none by default, in their relationships too, as the retrievers already did. It returned the nodes unchanged, and LlamaIndex’s defaultMetadataMode.LLMputallowed_users,allowed_groupsandtenantinto prompts and traces. The nodes passed in are not changed, and the authorizer still sees all the metadata.Example gateway (
examples/gateway): a request body overRAMPART_MAX_BODY_BYTES(1 MiB by default) is refused with 413 before it is parsed or the caller is authenticated, by itsContent-Lengthand by the bytes received. Neither the app nor uvicorn set a limit, so a 1 GB POST to/v1/askwas read and parsed as JSON before the API key was checked. A principal markeddisabledis refused with 403 where its key is checked, before its question is masked and scored; only the policy refused it before, after that work. The phone pattern and the “send data out” injection rule, which run before the policy decides, now take linear time (a run of 4,000 zeros took most of a second, andsend.to.repeated over 4,000 characters about a second): a phone number starts only where a number starts, so digits glued to a preceding digit or+are no longer read as one, and the rule’s address starts only where a run of address characters starts, with at most 64 characters before the@and 63 in the domain label. Compose pins every image by digest (ollama/ollamawaslatest), and Dependabot keeps those pins current.
Fixed#
max_candidatesbounds every retrieval: akabove it raisesValueError(pre-filtering searched for, and re-checked, anyk), the rerank depth is capped at it, and post-filtering checks no more candidates than the fetch it asked for, whatever the backend returns.In(field, "12")raisesTypeErrorinstead of holding the values"1"and"2"; so do bytes and a mapping.CachingAuthorizerno longer caches afilter_fororscope_foranswer that is notNoneor a well-formed filter, as documented.aretrieveruns the redactor in a thread, not on the event loop.PermissionedRetriever(audit_key=...)refuses a key shorter than 16 bytes, asRedisCachedoes forsigning_key.InMemoryBackendstores and returns copies of the metadata: a caller changing a returned hit’s metadata, or the dict it added, changed the permissions of the stored chunk.The conformance kit checks the async paths: every
asearchresult againstsearch(ids, order, text, metadata and scores, with and without a filter), retrieval througharetrieveas well asretrieve, and an authorizer’sacheck,afilter_forandascope_foragainst the sync methods.check_authorizerempties everyCachingAuthorizerbefore each call, so a cache cannot hide an authorizer whose answers depend on the batch.The conformance kit’s data holds what stores tend to confuse:
"1"next to1, case and Unicode normalisation variants, and field names with a dot or letters outside ASCII next to nested objects. A backend may refuse a filter on such a field name withValueError.VespaBackend.searchraises when the result does not cover every document (coverage.fullfalse, orcoverage.degradedset), instead of returning partial results as complete.RedisBackend.searchraises when a RESP3 reply carries the Query Engine’s timeout warning, which marks partial results. The Redis guide now recommendssearch-on-timeout fail, the only protection for RESP2 clients.The email pattern in the redaction guide could take quadratic time on a long run of word characters and dots; it now starts a match only where such a run starts.
OpenFGAAuthorizerandSpiceDBAuthorizerdeny, without sending it, a chunk whose resource id the service would refuse: for OpenFGA an id with:,#, a space or a control character, the id*, or one that makes the object (document:<id>) longer than 256 characters; for SpiceDB anything but 1 to 1024 letters, digits and/_|-=+. One such id used to make the service refuse the whole request, so every retrieval that found that chunk returned nothing, for every user.ElasticsearchBackendandOpenSearchBackendsendallow_partial_search_results=falsewith every search, and raiseRuntimeErrorfor an answer that reports a failed shard (_shards.failedabove 0) ortimed_out: true. Such an answer is HTTP 200 and carries only the other shards’ hits, which were returned as complete.PermissionedRetriever(audit_key=...)raisesTypeErrorfor a key that is notbytesorbytearray. A string passed the length check, then made every event fail to build: the error was logged, no observer received an event, and retrieval went on unaudited.RedisCache(signing_key=...)raisesTypeErrorfor a key that is notbytesorbytearray, for the same reason: a string passed the length check, then failed every read and write of the shared cache.A shared-cache entry whose expiry is an integer too large for a float is a miss.
float()raisedOverflowError, which escaped the read, so every retrieval for that subject failed.The conformance kit requires
asearchto refuse, withValueError, every filtersearchrefuses. Whensearchrefused a filter on an odd field name such asteam.name, the kit skippedasearch, so an async path that dropped the filter and returned everything went unseen.
Documentation#
The OPA, OpenFGA, SpiceDB and Cerbos pages say what
recheck=Falserequires: the filter alone decides, andInmatches a list field by any element (and Cerbos’s translatedinandhasIntersectionmatch a single value), whilecheckdenies those shapes. Every chunk must store each field in the shape the policy assumes; the default re-check drops the others as violations.OpenFGAAuthorizerandSpiceDBAuthorizerdeny, without asking, a chunk whose resource field is not one string.The Cassandra page says when a search falls back to one ANN query filtered in the process (a filter that needs more than
max_queriesANDs, such as a subject in more than 254 groups at the default), that the search can then return fewer thankhits, or none, though more chunks match, with nothing in the result saying so, and how to avoid it: raisemax_queries, send fewer groups, or put a term every match needs, such as a tenant, in the filter.The Cassandra page says the vector index is approximate, so a search can now and then return fewer than
khits when more match, and the conformance tests check Cassandra as an approximate index. The filter stays exact.
1.6.0 - 2026-10-23#
Vespa and Apache Cassandra join the stores, each tested with the conformance kit against a real
server in CI. Vespa applies the permission filter inside its nearest-neighbor and BM25 search;
Cassandra applies it in the WHERE of each ANN query, with every value bound as a parameter.
Added#
CassandraBackend(thecassandraextra): vector search on Apache Cassandra 5.0 with Storage-Attached Indexes. The filter becomes an OR of ANDs on type-tagged terms in an indexedset<text>; each AND is one ANN query with boundCONTAINSparameters, run concurrently, and the bestkare kept; pastmax_queries, one query on the terms every match needs is filtered in the process.set_metadatauses a lightweight transaction so an id that is not stored creates no row;create_tablecreates the table and indexes. Tested with the conformance kit against Cassandra 5.0.9. Writes go 4 at a time (write_concurrency), as Cassandra 5.0’s vector index accepts few concurrent writes, and a refused write is retried once.VespaBackend(thevespaextra): nearest-neighbor or BM25 search on Vespa through its HTTP APIs, with the permission filter in the YQL query as conditions on hex-encoded, type-tagged terms, so Vespa applies it inside the search and no metadata value can change the query. HNSW or exact search;schema(dim)writes the schema andexamples/vespais a complete application package. Tested with the conformance kit against Vespa 8.763.13, in vector, keyword and hybrid search.
1.5.0 - 2026-10-23#
Applications in any language can now use wardowl through an HTTP service that takes the user from a verified OIDC token. Redaction hides the parts of a permitted chunk a user may not see, before a reranker or a model reads it. Ory Keto and Casbin join the permission systems, and FAISS joins the stores, with exact pre-filtering in the process.
Added#
FaissBackend(thefaissextra): vector search with FAISS in the process. The permission filter is matched against the chunks’ metadata and passed to FAISS as an ID selector, so pre-filtering is exact. Exact search by default, or any trained index that supports ID selectors;add,delete,set_metadata, andsaveandloadto a directory. Passes the conformance kit.CasbinAuthorizer(thecasbinextra): decisions from the Casbin enforcer you already use, in the process, withbatch_enforce. Requests are(subject id, object, action), or your own for ABAC models;group_subjects=Truealso asks with each of the subject’s groups, for allow-only models. An enforcer that raises or answers with anything but booleans raisesAuthorizationError. Tested against the reference answer on generated corpora and with the conformance kit.KetoAuthorizer: relationship checks against Ory Keto (POST /relation-tuples/batch/check), with no extra dependency. Each chunk’s document is a Keto object;group_subjects=Truealso checks the subject’s groups as subject sets. Keto has no reverse lookup, so the retriever post-filters. A result carrying an error, a malformed answer or a wrong number of results raisesAuthorizationError. Tested against Keto 26.2.0 and with the conformance kit.An HTTP service,
wardowl.server(theserverextra), so applications in any language get permission-aware retrieval:create_app(retriever, authenticate)servesPOST /v1/retrieve,GET /healthzand an OpenAPI description. The subject comes from the verified bearer token alone;OIDCAuthenticatorverifies JWTs against the identity provider’s published keys (asymmetric algorithms only, issuer, audience, expiry, an optional tenant claim). Unknown request fields are refused, a failed permission check answers 503 without details, and hits carry no metadata unless named.Redaction:
PermissionedRetriever(redactor=...)hides parts of the chunks a subject may read, after the permission check and before a reranker or a model sees them.SpanRedactorhides spans listed in a chunk’s metadata, each with its own readers;PatternRedactorhides text matching patterns unless the subject is in an exempt group. A redactor may change text and metadata but not which hits are returned, and one that fails makes the retrieval raiseAuthorizationError.
1.4.0 - 2026-10-22#
Nine of the ten stores now serve both halves of hybrid search under the permission filter: keyword search (BM25) arrives on Qdrant, Milvus, MongoDB Atlas, Weaviate, Redis and LanceDB (Chroma has none). Cedar joins OPA, OpenFGA, SpiceDB and Cerbos as a permission system, evaluated in the process. Permissions can be synced from Google Drive. Operators get the cause of each failure, cache statistics and an incident runbook; security reviewers get a threat model, the invariants with the tests that hold them, and a compliance mapping. Several fail-closed gaps found by review are fixed, and from this release every package carries SLSA build provenance.
Python 3.11 or newer is required. Python 3.10 reaches its end of life on 2026-10-31, and its last dependency releases (Pillow 12.3 among them) no longer receive security fixes.
Added#
CedarAuthorizer(thecedarextra): Cedar policies evaluated in the process with cedarpy. The principal carries the subject’s id, groups and attributes, with its groups as parent entities; each chunk is a resource with its metadata as attributes;entitiesadds hierarchies. Cedar skips a policy whose evaluation errors, aforbidincluded, so a chunk whose evaluation reports an error is denied. No pre-filter: the retriever post-filters. Compared with the reference answer on generated corpora and checked with the conformance kit.mode="keyword"on the Qdrant backend: BM25 under the samequery_filter, with no model. A collection created withcreate_collection(..., text_search=True)gets a sparse vector with Qdrant’s IDF modifier; the backend writes each chunk’s BM25 term weights there (the newbm25_terms), and Qdrant applies the inverse document frequency at search time. A vector backend giventext_search=Truewrites both vectors, so one collection serves both halves of hybrid search. Tested with the conformance kit, alone and fused with vector search, in local mode and against Qdrant 1.19.2.mode="keyword"on the MongoDB backend: Atlas Search’s BM25 ($search) on the text, under the same permission filter written with Atlas Search’s own operators, in an index that the newcreate_text_indexcreates, so one collection serves both halves of hybrid search. A backend giventext_indexalso waits for that index after each write. Tested with the conformance kit, alone and fused with vector search, against Atlas Local 8.0.30.RetrievalEvent.cause, and the span attributewardowl.error.cause: the type of the exception behind a failure, such asTimeoutErrororConnectionRefusedErrorfor a permission service that is slow or unreachable, andNonefor an error status or a malformed answer. The HTTP authorizers now keep the connection error as the cause of theAuthorizationErrorthey raise after retrying.CachingAuthorizer.stats(): aCacheStatswith answers served from this process and from the shared cache, answers the authorizer was asked for, shared cache failures, and entries held, for watching the hit rate and the shared cache. The operations guide lists the new alerts.Documentation for security review: a threat model of the library (assets, trust boundaries, seventeen threats with their mitigations, remaining risks and tests), the security invariants S1 to S10 with the tests that hold each one, and a guide to revocation and consistency, from bounded staleness to strict revocation.
Tests of tenant and context isolation against an oracle written without the library’s filter code: tenants that share user ids, group names and document ids (including look-alike tenant values such as
"acme","Acme","acme"spelt with a Cyrillic a,1and"1") through one shared cache; concurrent threads and asyncio tasks for different subjects, every returned chunk checked; and a cancelled retrieval.OpenFGAAuthorizer(consistency=...):"HIGHER_CONSISTENCY"makes OpenFGA skip its cache for every check and list, so a tuple just written counts at once;"MINIMIZE_LATENCY"is OpenFGA’s default. Tested against OpenFGA 1.22 in each mode.create_index(..., algorithm="FLAT")on the Redis backend: an exact vector index, for indexes small enough to compare every vector. HNSW stays the default; the docs now say that Redis re-adds a chunk’s vector to the graph whenset_metadatachanges it, so a small, often-changed HNSW graph can miss a chunk.mode="keyword"on the Weaviate backend: Weaviate’s BM25 on the text, under the same permission filter as its vector search, with no embedding function needed, so one collection serves both halves of hybrid search withRankFusion. Tested with the conformance kit, alone and fused with vector search, against Weaviate 1.39.12.mode="keyword"on the Redis backend: the Query Engine’s BM25 on the text, under the same permission filter, in an index created withcreate_index(..., content_field="content"), so one index serves both halves of hybrid search. Each word of the query is sent as a query parameter, never as query syntax.create_indexnow creates indexes without stop words. Tested with the conformance kit, alone and fused with vector search, against Redis 8.10.2.mode="keyword"on the Milvus backend: BM25 on a sparse vector Milvus derives from the text, under the same permission filter and parameters, in a collection created withcreate_collection(..., text_search=True), so one collection serves both halves of hybrid search. Tested with the conformance kit, alone and fused with vector search, against Milvus 3.0.2.mode="keyword"on the LanceDB backend: LanceDB’s full-text index (BM25) on the text, under the same permission filter, applied before ranking, so one table serves both halves of hybrid search.create_indexes(table, content_field="content")builds the index, keeping every word; rows added after it are found by a scan untiltable.optimize(). Tested with the conformance kit, alone and fused with vector search, with LanceDB 0.40.Permission sync from Google Drive.
wardowl.sync.google_drive.GoogleDriveACLreads who may read a Drive file, in My Drive or a shared drive, through your Drive v3 service, andwardowl.sync.with_accesswrites the answer onto the file’s chunks forMetadataACLAuthorizer, keeping their other metadata. Users and groups map to their addresses, a domain to the group"domain:<domain>"and anyone to everyone when findable in search. Grants that do not open the file’s content grant nothing: sharing by link alone (unlesslink_sharing=True), deleted accounts, expired grants, view grants such as a limited-access folder’s metadata view, inherited grants on limited-access items, and grants a shared drive’s settings shut out (members only, domain users only).domains=leaves out grants outside your organization’s domains.wardowl.sync.Accessis the source-neutral answer that later connectors return too.Documentation for operators and reviewers: an incident runbook (a document seen by the wrong person, rising
violations,AuthorizationError, a failing or untrusted shared cache, an advisory affecting your version), a starting point for security reviewers, the configurations CI tests and what it does not, and a FastAPI service inexamples/fastapi_ragthat binds the caller from authentication. The threat model adds T17: in keyword search, BM25 statistics count chunks the subject may not read, so scores and order can hint at what they contain.CI runs the library tests on macOS and Windows as well as Linux, and fails when branch coverage drops below 90% for the package or 95% for the code that decides access. The release check installs each release from PyPI and runs the tests taken from its attested sdist.
Benchmarks: the load benchmark checks every chunk returned under concurrent load,
--repeatreports the median and range of several throughput runs, and the permissions benchmark adds a baseline that enforces the same permissions with PostgreSQL row-level security, as a role that does not bypass it.
Fixed#
A retrieval cancelled while it ran (an asyncio task cancelled, a
KeyboardInterrupt) produced noRetrievalEvent, though every call is meant to produce one. Observers now receive it, witherror="CancelledError"(or the exception’s name) and nothing returned; the exception is raised as before.OpenFGAAuthorizer.filter_forno longer treats a ListObjects answer that may have been cut short by the server’s deadline as complete. OpenFGA stops atOPENFGA_LIST_OBJECTS_DEADLINE(3 s by default) and sends what it found without saying so; a call that tooklist_objects_deadlineseconds or more (new, default 3) now gives no filter, so the retriever post-filters. Such a list never let anyone read more, but it could hide documents they may read while the result reportedcomplete=True.
Security#
The example gateway (
examples/gateway) stores API keys as scrypt hashes (n=16384, r=8) with a random salt per principals file, instead of unsalted SHA-256, so a leaked principals file costs one scrypt per guessed key.rampart new-keyissues random keys,rampart key-hashingstarts a file with a new salt, and SHA-256 entries are refused rather than accepted as a fallback.recheck=Falseno longer trusts the backend completely. The authorizer is still not asked a second time, but each pre-filtered hit is matched against the filter in the process (no network call), so a backend that ignores or mistranslates the filter cannot leak; such hits are dropped and counted inviolations, as withrecheck=True. A custom backend withrecheck=Falsemust now return each chunk’s metadata as stored, as the conformance kit already requires.CerbosAuthorizerrefuses a subject with an attribute namedgroups. It was merged into the principal’s attributes after the subject’s own groups, so it replaced them in every decision.Filter parsing, and the pgvector, LanceDB and Redis translations that write query text, are fuzzed with ClusterFuzzLite (Atheris) on pull requests and every week. The fuzz target checks that parsing either round-trips or raises
ValueError, that pgvector binds every field name and value as a parameter, and that LanceDB and Redis queries carry only hex-encoded terms. The test suite runs the same checks on generated filters.
1.3.3 - 2026-10-22#
Closes three gaps that remained in CachingAuthorizer.invalidate() after 1.3.2, found by a
review of the code. Upgrade if you use a shared cache and call invalidate().
Security#
Advisory GHSA-prfv-2pwr-5rx2 covers this fix.
In rare interleavings, a decision revoked with
invalidate()could still be used afterwards, for up tottl. Aninvalidate()of everyone deleted every id’s epoch, including one that a concurrentinvalidate()of that id had just written, so a retrieval in flight could store its earlier answer where every replica accepted it. And in the process callinginvalidate(), a read of the global epoch already in flight could put back the epoch it replaced, for up tolocal_ttl. Epochs now live outside the keys that invalidation deletes, and a read never replaces a newer epoch.An
invalidate()of everyone reached other replicas within twicelocal_ttl, not withinlocal_ttlas documented: an entry accepted against a copy of the global epoch was kept for anotherlocal_ttl. Copies now expire with that epoch.
Fixed#
Epochs are stored as hex text. Random bytes broke shared caches whose Redis client decodes responses (
decode_responses=True): reads raisedUnicodeDecodeError, so every lookup asked the permission service and logged a warning.invalidate()raisesTypeErrorfor an id that is not a string:invalidate(42)for the id"42"missed this process’s own entries.A retrieval event that cannot be built is logged and dropped, instead of replacing the retrieval’s result or error, and query fingerprints accept a lone surrogate.
Changed#
An id’s epoch has a new key in the shared cache. During a rolling upgrade, an
invalidate()of an id made by one version does not reach the other version’s entries: callinvalidate()once every replica runs 1.3.3, or allow forttl.
1.3.2 - 2026-10-19#
Completes the 1.3.1 fix for CachingAuthorizer.invalidate(). Upgrade if you use a shared cache.
Security#
Advisory GHSA-prfv-2pwr-5rx2 covers this fix.
With a shared cache,
invalidate()could still leave the revoked decision in its own process for up tolocal_ttl(1 second by default): a retrieval running during the call could copy the old entry from the shared cache after the process dropped its own entries and before the new epoch was written. The process now drops its entries again once the new epoch is in place. Found by the 1.3.1 revocation test under concurrent traffic; a test now forces a retrieval into that moment.
1.3.1 - 2026-10-19#
A security fix for permission caching. Upgrade if you use CachingAuthorizer, and above all if
you call invalidate() to revoke access.
Security#
Advisory GHSA-prfv-2pwr-5rx2 covers this fix.
CachingAuthorizer.invalidate()could be undone by a retrieval already in flight. An answer the permission service gave just before the revocation, to a call that finished just after it, was cached afterwards and used for up tottl(30 seconds by default), in this process and, through a shared cache, in every replica. Now each process discards answers obtained by calls that began before aninvalidate(), andinvalidate()stores a new epoch in the shared cache, under which entries written before it are refused. Tests revoke a permission under concurrent retrievals, with one and three replicas, and check that no retrieval starting afterinvalidate()returns sees the document (other replicas withinlocal_ttl).
Changed#
CachingAuthorizer.invalidate(subject)takes aSubjector a subject id, and drops the answers for every subject with that id, whatever groups and attributes they came with. Before, it dropped only those of a subject equal in every field, so a revocation hook that knew only the user id (invalidate(Subject("bob"))) cleared nothing forSubject("bob", ["eng"], {"tenant": "acme"}).Shared cache entries have new keys and carry an epoch; entries written by earlier versions are not read, so the first requests after the upgrade ask the permission service. During a rolling upgrade, call
invalidate()once every replica runs 1.3.1, or allow forttl.
Fixed#
filter_from_dictrefuses a filter nested more than 100 levels deep withValueError. Before, a deep enough document from a policy engine or a shared cache recursed until Python raisedRecursionError. The shared cache treats such an entry, and any entry too deep to decode, as a miss.
1.3.0 - 2026-10-17#
Retrieval as production RAG is built now, with permissions applied at every step. Hybrid search
fuses keyword and vector results under the same permission filter (BM25 on Elasticsearch and
OpenSearch, full-text search on pgvector). A reranker orders the results after the permission
check, so a cross-encoder or a hosted rerank API never sees a chunk the user may not read. Searches
can use vectors you computed, from an embedding service or another modality. AI agents get an MCP
search tool whose permissions come from the verified access token, never from the model, and
Haystack pipelines get a retriever component. Four more stores: MongoDB Atlas Vector Search,
Weaviate, Redis and LanceDB. set_metadata changes a document’s permissions without embedding it
again, and the conformance kit checks it, like everything else, against every store.
Python 3.10 is still supported. It reaches its end of life on 2026-10-31, after this release, so under the support policy the first minor release after that date, 1.4, drops it, not 1.3 as the 1.2.0 notes said.
Added#
set_metadata({chunk_id: metadata})on every built-in backend: replaces stored chunks’ metadata without embedding them again, so a document’s permission change reaches search without the cost of re-embedding. The whole metadata is replaced (no old permission key survives, including where the store’s own partial updates would merge), ids that are not stored are ignored, and it returns once searches see the change, waiting for MongoDB’s search index. The conformance kit checks it for any backend that has it, against each real store in CI. Guide: “Changing a document’s permissions” in Operations.Reranking after the permission check:
PermissionedRetriever(..., reranker=..., rerank_depth=50)finds the toprerank_depthchunks the subject may read, lets a cross-encoder or hosted rerank API order them, and returns the first k. The reranker never sees a chunk the subject may not read, and can reorder, rescore or drop hits but not add one; returned hits keep their stored text and metadata. Any function or object withrerank(and optionallyarerank) works. Events reportrerankedandrerank_ms, and OpenTelemetry records awardowl.rerank.durationhistogram. Guide: “Reranking”.wardowl.backends.lancedb: LanceDB, the embedded vector database, with the permission filter applied before the vector search ranks (LanceDB’s pre-filtering). Permission terms are stored hex-encoded in a list column and matched witharray_has_any, and deletes match a hex-encoded id, so no group name, user id or document id is ever part of the SQL.addupserts, keeping the last version of a repeated id; metadata is stored as JSON and returned exactly.create_tableandcreate_indexes(aLABEL_LISTindex on the terms) set the table up. Tested with the conformance kit, with text and vector queries, on LanceDB 0.40. Extra:wardowl[lancedb].wardowl.mcp.add_search_tool: a permission-aware search tool for MCP servers (MCP Python SDK 2.3 or later), so AI assistants and agents search with the calling user’s permissions. The user comes from the access token the server verified for the request, through yoursubject_forfunction, never from the tool’s arguments; a call without a verified token is refused, and a fixedSubjectserves single-user stdio servers. The read-only tool takesqueryand a boundedlimit, returns structured results without metadata unless keys are named, and reports a permission service failure to the model without internal details. Extra:wardowl[mcp].Query: search with a vector you computed, alone or with the text.retrieve(Query(vector=...), subject)searches an image’s or another model’s embedding, an embedding from your own service or cache, or one shared by every backend inRankFusion(vector backends use the vector, keyword backends the text). Permissions apply exactly as for text. Every vector backend accepts it, andembedis now optional on all of them: build a backend without one, write withadd(documents, embeddings=...)and search with vectors. The LangChain and LlamaIndex store wrappers search by vector, the Haystack component takes aquery_embeddinginput, and the LlamaIndex retriever forwards aQueryBundle’s embedding. Audit fingerprints use the query’s text when there is text and its vector otherwise; events never contain the vector.check_backend(..., query=...)runs the conformance kit with any query, and CI runs it with vector queries against every store. Guide: “Your own vectors”.RankFusion: hybrid search. A backend that searches several backends with the same permission filter and fuses their rankings by reciprocal rank fusion, with weights, a rank constant and a per-backend depth;aretrievesearches them concurrently. A chunk two backends return with different metadata is left out and logged, since its permissions are uncertain. Guide: “Hybrid search”.mode="keyword"on the pgvector backend: PostgreSQL full-text search (websearch_to_tsquery, ranked byts_rank_cd) under the same permission condition, so one table serves hybrid search.create_schema(..., text_search="english")adds the full-text GIN index, and CI checks that the query plan uses it. Tested with the conformance kit, alone and fused with vector search.mode="keyword"on the Elasticsearch and OpenSearch backends: BM25 on the text under the same permission filter, with no embedding function needed. Tested with the conformance kit, alone and fused with vector search, against both engines.wardowl.backends.redis: vector search on the Redis Query Engine (Redis 8 or later, or Redis Stack), with the permission filter in the same KNN query. Permission terms are stored hex-encoded in a TAG field, so no group name or user id can add query syntax and TAG matching’s case folding never merges two values; metadata is stored as JSON and returned exactly.create_indexsets up the index, andredis.asyncioclients give a nativearetrieve. Tested with the conformance kit against Redis 8.10.2. Extra:wardowl[redis], as for the shared cache.wardowl.haystack.WardowlRetriever: a Haystack pipeline component over any wardowl backend and authorizer. The authenticated user is a run input, so one pipeline serves every user, and the component refuses to run without aSubject. It outputs HaystackDocuments andcomplete, and hasrun_async. The extra requireshaystack-ai2.12 or later; CI tests the current Haystack 3 release. Extra:wardowl[haystack].wardowl.backends.mongodb: MongoDB Atlas Vector Search, with the permission filter as the$vectorSearchpre-filter on a type-tagged array field (a BSON filter, so values are never query syntax). Atlas indexes writes shortly after they happen, soaddanddeletewait until the index shows the change, including a replaced chunk’s new permissions (wait=Falseskips it).create_search_indexsets up the index, andAsyncMongoClientcollections give a nativearetrieve. Tested with the conformance kit againstmongodb/mongodb-atlas-local8.0.30. Extra:wardowl[mongodb].wardowl.backends.weaviate: Weaviate, with the permission filter applied inside the vector search. Permissions are matched on a type-taggedtext[]property withfieldtokenization, each term guarded at both ends so case and whitespace never blur two values, through the client’s filter objects; metadata is stored as unindexed JSON and returned exactly. Document ids become UUIDs, with the original kept on the object.create_collectionsets up a self-provided cosine vector, andWeaviateAsyncClientgives a nativearetrieve. Tested with the conformance kit against Weaviate 1.39.12. Extra:wardowl[weaviate].
Changed#
SearchBackend.searchreceives the query exactly as it was given toretrieve, a string or aQuery, and itsqueryparameter is typedAny, so backends typed for strings still satisfy the protocol and keep working with string queries.retrieveandaretrieveraiseTypeErrorfor a query that is neither a string nor aQuery, instead of passing it to the backend.
Fixed#
Chroma:
adddeletes and re-adds a replaced chunk, so none of its old metadata keys can remain and match a permission filter; it also checks that there is one embedding per document.
1.2.2 - 2026-10-22#
Closes three gaps that remained in CachingAuthorizer.invalidate() after 1.2.1, found by a
review of the code. Upgrade if you use a shared cache and call invalidate().
Security#
Advisory GHSA-prfv-2pwr-5rx2 covers this fix.
In rare interleavings, a decision revoked with
invalidate()could still be used afterwards, for up tottl. Aninvalidate()of everyone deleted every id’s epoch, including one that a concurrentinvalidate()of that id had just written, so a retrieval in flight could store its earlier answer where every replica accepted it. And in the process callinginvalidate(), a read of the global epoch already in flight could put back the epoch it replaced, for up tolocal_ttl. Epochs now live outside the keys that invalidation deletes, and a read never replaces a newer epoch.An
invalidate()of everyone reached other replicas within twicelocal_ttl, not withinlocal_ttlas documented: an entry accepted against a copy of the global epoch was kept for anotherlocal_ttl. Copies now expire with that epoch.
Fixed#
Epochs are stored as hex text. Random bytes broke shared caches whose Redis client decodes responses (
decode_responses=True): reads raisedUnicodeDecodeError, so every lookup asked the permission service and logged a warning.invalidate()raisesTypeErrorfor an id that is not a string:invalidate(42)for the id"42"missed this process’s own entries.
Changed#
An id’s epoch has a new key in the shared cache. During a rolling upgrade, an
invalidate()of an id made by one version does not reach the other version’s entries: callinvalidate()once every replica runs 1.2.2, or allow forttl.
1.2.1 - 2026-10-19#
The security fix of 1.3.1, backported to 1.2 as the security policy promises for the previous minor
release. Upgrade if you use CachingAuthorizer, and above all if you call invalidate() to revoke
access; or move to 1.3.1.
Security#
Advisory GHSA-prfv-2pwr-5rx2 covers this fix.
CachingAuthorizer.invalidate()could be undone by a retrieval already in flight. An answer the permission service gave just before the revocation, to a call that finished just after it, was cached afterwards and used for up tottl(30 seconds by default), in this process and, through a shared cache, in every replica. Now each process discards answers obtained by calls that began before aninvalidate(), andinvalidate()stores a new epoch in the shared cache, under which entries written before it are refused.
Changed#
CachingAuthorizer.invalidate(subject)takes aSubjector a subject id, and drops the answers for every subject with that id, whatever groups and attributes they came with. Before, it dropped only those of a subject equal in every field.Shared cache entries have new keys and carry an epoch; entries written by earlier versions are not read, so the first requests after the upgrade ask the permission service. During a rolling upgrade, call
invalidate()once every replica runs 1.2.1, or allow forttl.
Fixed#
filter_from_dictrefuses a filter nested more than 100 levels deep withValueErrorinstead of recursing untilRecursionError, and the shared cache treats such an entry as a miss.
1.2.0 - 2026-10-17#
Breadth and scale. wardowl now works with six more systems: the Qdrant, Milvus, Elasticsearch
and OpenSearch vector stores, and the SpiceDB and Cerbos permission systems. Authorizers combine
(AllOf, AnyOf, TenantAuthorizer), with scoped post-filtering for multi-tenant indexes; the
permission cache can be shared across replicas through Redis; OpenTelemetry metrics show where each
retrieval spends its time; and a subject can be in tens of thousands of groups. A conformance kit,
wardowl.testing, checks your own backends and authorizers against the reference semantics, as
CI does for every built-in one. One behaviour is stricter: an authorizer answer that is not a
filter now fails closed instead of reaching the backend.
Python 3.10 is still supported. 1.2 comes out before 3.10’s end of life on 2026-10-31, and the support policy drops a version in the first minor release after that, so 1.3 will be the first release without it, not 1.2 as the 1.1.0 notes said.
Added#
A project logo. The documentation’s front page shows it as a live 3D model (three.js, vendored, so the site makes no third-party requests), with a still render as the fallback when WebGL is unavailable or the reader prefers reduced motion; it also appears in the sidebar, as the favicon and at the top of the README.
A redesigned documentation site on the Shibuya theme: a product-style front page (feature cards, how it works, code in tabs, integrations, measured results), a top navigation bar with menus, copy buttons on code, edit links on every page, and link previews. It uses each platform’s own font and no third-party services, and its buttons meet WCAG AA contrast in light and dark mode.
wardowl.testing: a conformance kit for your own backends and authorizers.check_backendcompares a backend with the reference filter semantics on generated documents and nested filters (exact results,k, ordering, unchanged metadata,asearch, no leaks through the retriever);check_authorizerchecks thatcheckandfilter_foragree. Every built-in backend, and the example OPA policy against a real OPA, runs it in CI.HashingEmbeddergives vector stores deterministic embeddings without a model.wardowl.backends.qdrant: a Qdrant backend. The filter becomes Qdrant’squery_filter(MatchAnyper value type, an emptyIndecided before the query),create_collectionadds keyword payload indexes on the permission fields, points use LangChain’s Qdrant layout so existing collections work, andAsyncQdrantClientgives a nativearetrieve. Tested with the conformance kit against a real Qdrant server and Qdrant’s local mode. Extra:wardowl[qdrant].wardowl.backends.elasticsearchandwardowl.backends.opensearch: kNN search with the permission filter applied during the search. Permissions are matched on one type-tagged keyword field (wardowl_terms), because keyword fields compare strings and would otherwise match the integer1against"1"; metadata is stored unindexed and returned exactly, and never adds mappings.create_indexsets up the mapping,permission_termslets other ingestion pipelines write the field, and async clients give a nativearetrieve. Tested with the conformance kit against Elasticsearch 9.5.5 and OpenSearch 3.9.0. Extras:wardowl[elasticsearch],wardowl[opensearch].wardowl.backends.milvus: a Milvus backend, with the permission filter applied inside the vector search. Permissions are matched on a type-tagged array field, as on the search engines, and the values are passed as Milvus filter parameters, never written into the expression, so a group name cannot change what a filter selects.create_collectionsets up the schema and indexes, searches use strong consistency by default, andAsyncMilvusClientgives a nativearetrieve. Tested with the conformance kit against Milvus 3.0.2. Extra:wardowl[milvus].wardowl.authorizers.spicedb.SpiceDBAuthorizer: SpiceDB over its HTTP API, with no extra dependency. CheckBulkPermissions in batches (answers matched by the request they echo), LookupResources for the pre-filter (post-filtering when the list may be truncated or is conditional on a caveat), the subject’s groups asgroup:<name>#membersubject sets, caveat context, a configurable consistency, and conditional answers treated as failures, not decisions. Tested against a real SpiceDB for random relationship graphs and with the conformance kit.wardowl.authorizers.cerbos.CerbosAuthorizer: Cerbos over its HTTP API, with no extra dependency. CheckResources per chunk (metadata as resource attributes, the subject’s groups and attributes on the principal), and pre-filters translated from PlanResources query plans:and,or,hasIntersection,inand==on resource attributes become exact filters, anything else falls back to post-filtering. Example policy inexamples/cerbos. Tested against Cerbos 0.56.0.AllOfandAnyOfcombine authorizers (each part asked only about the chunks still undecided), andTenantAuthorizerkeeps every subject inside its tenant; a subject without a tenant gets nothing. Guide: “Combining authorizers and multi-tenancy”.Scoped post-filtering: an authorizer may offer
scope_for(subject, action), a filter every readable chunk matches. When there is no exact filter, the retriever post-filters inside the scope (for example only the subject’s tenant) and still checks every candidate, so a tenant with a small share of the index keeps its recall.AllOfandAnyOfprovide scopes, andCachingAuthorizercaches them.CachingAuthorizer(shared=...): a second cache tier shared by every process, withwardowl.caching.RedisCache(Redis, Valkey, Dragonfly, Redis Cluster; extrawardowl[redis]). Replicas reuse each other’s answers, each keeps its own copy for at mostlocal_ttlseconds (1 by default), andinvalidate()reaches every replica withinlocal_ttl. Shared entries carry their expiry time, so the revocation bound holds across replicas; key names are digests; values can be signed with HMAC-SHA256 (signing_key) so planted entries are ignored; and a failing shared cache is only ever a miss.The conformance kit also generates nested lists and objects in metadata, filters of 300 values, and subjects in hundreds of groups.
wardowl.otel.OpenTelemetryMetrics: retrieval metrics for dashboards and alerts. Thegen_ai.client.operation.durationhistogram from the OpenTelemetry GenAI conventions, retrievals by outcome, time spent in the backend and in the authorizer, chunks returned, examined and denied, and violations. Attributes have a handful of values each; subjects, queries and chunks are never recorded.RetrievalEvent.search_ms,authorize_msandsearches: where the time of each retrieval went, also on spans and in audit lines. Spans also carry the GenAI conventions’gen_ai.operation.name,gen_ai.retrieval.top_kanderror.type; their name is unchanged.
Changed#
The retriever rejects a
filter_for(orscope_for) answer that is not a filter withAuthorizationError, instead of handing it to the backend.pgvector: an
Inwith more than 100 values (a subject in many groups) is sent as two array parameters instead of two per value. A subject in more than about 32,000 groups used to exceed PostgreSQL’s limit of 65,535 parameters, and retrieval failed; CI now serves one in 40,000. The operations guide has a section on subjects in very many groups for every backend.
Documentation#
A production checklist: what to settle before launch, and what happens when each part of the system fails, with what the operator sees.
The security model covers the shared cache’s trust boundary, what a wrong
scope_forcan and cannot do, how each backend returns metadata, and the new authorizers.
1.1.2 - 2026-10-16#
The README, and so the PyPI page, opens with the logo lockup, centred, with a dark version for dark themes. The library is unchanged from 1.1.0.
1.1.1 - 2026-10-16#
The README, and so the project’s PyPI page, shows the project logo. The library is unchanged from 1.1.0.
1.1.0 - 2026-10-16#
Hardening for production: resilient connections to remote permission services, releases that can be verified, and the first measurements at a million chunks. The one change in behaviour is that the authorizers no longer follow HTTP redirects. Python 3.10 reaches its end of life on 2026-10-31; following the support policy, 1.2 will be the first release without it.
Added#
The OPA and OpenFGA authorizers reuse HTTP connections (one per thread) instead of opening one per request, and replace a connection the server closed while idle.
Transient failures are retried once by default (
retries=): refused or reset connections, missing responses, HTTP 502, 503 and 504. Timeouts and other errors are not retried.wardowl.authorizers.CircuitBreaker: pauses requests to a permission service that keeps failing, then probes; pass it ascircuit_breaker=.ssl_context=on both authorizers, for your own CA or mutual TLS.PgVectorBackend(ef_search=...)sets pgvector’s HNSW search depth for each search, trading latency for recall, without changing the setting for other users of the connection pool.A load benchmark (
benchmarks/load_bench.py): a million MS MARCO passages on pgvector, recall and latency by selectivity, throughput with concurrent users, and the cost of OPA as the permission service with and without caching. CI runs a small version against real services. The first results are on the Benchmarks page of the documentation.Fault-injection tests: under any sequence of injected faults, retrieval through OPA returns exactly the permitted top-k or raises
AuthorizationError.
Security#
Releases attach the published files to the GitHub release with a Sigstore signature for each and a CycloneDX SBOM; SECURITY.md shows how to verify a download.
Every GitHub Action is pinned to a commit, checkouts no longer keep credentials, CodeQL scans the Python code and the workflows, and OpenSSF Scorecard publishes the project’s security score.
Changed#
The authorizers no longer follow HTTP redirects; a redirect is an error, so retrieval fails closed.
1.0.0 - 2026-10-16#
The first stable release. The public API in the API reference now follows semantic versioning. Everything in 1.0.0rc1 below is included; these are the changes since the release candidate.
Added#
Tested on Python 3.14. Every Python version CPython still supports is supported, and a version is dropped in the first minor release after its end of life (Python 3.10’s is 2026-10-31).
A release procedure for maintainers in CONTRIBUTING.
Removed#
RetrievalEvent.extra, a field nothing ever set. Audit lines no longer carry an empty"extra". Removed before 1.0 so that it is not frozen into the stable API; adding a field later is compatible, removing one is not.
1.0.0rc1 - 2026-10-16#
The first release candidate for 1.0. The public API in the
API reference is what 1.0.0 will ship:
changes before then are fixes, unless testing the release candidate shows that a breaking change is
needed, in which case it will be announced in a second release candidate. Install it with
pip install --pre wardowl.
Added#
PermissionedRetrieverwithprefilter,postfilterandautostrategies, sync (retrieve) and async (aretrieve) entry points sharing one implementation.Failing closed: any authorizer error, wrong number of decisions or non-boolean decision raises
AuthorizationErrorand returns nothing.Re-check of pre-filtered results, with
RetrievalResult.violationscounting hits that a backend returned despite the filter.A backend-neutral filter language (
In,And,Or) with reference semantics inmatches; emptyAnd/Orare rejected because an empty conjunction would mean “allow everything”.MetadataACLAuthorizer(permissions stored on each chunk) andFunctionAuthorizer(your own logic).InMemoryBackendwith deterministic keyword scoring, for tests and small corpora.Property-based tests (Hypothesis) for the security guarantees on Python 3.10 to 3.13.
Contributor documentation: CONTRIBUTING, GOVERNANCE, CODE_OF_CONDUCT, SECURITY.
wardowl.backends.pgvector: PostgreSQL + pgvector backend with the permission filter in the query as JSON containment tests (GIN-indexable), iterative HNSW scans,create_schema, and a native async search when given an async pool. Extra:wardowl[pgvector].wardowl.backends.chroma: Chroma backend translating filters intowhereclauses. It never stores Chroma array metadata: in chromadb 1.5.x (local mode) a deleted collection’s array metadata surfaces on new records in other collections and is used bywherefilters (chroma-core/chroma#7594). List values become one boolean field each, and search results are rebuilt from an exact JSON copy of the metadata, so the re-check always sees the true permissions. Extra:wardowl[chroma].wardowl.authorizers.opa: Open Policy Agent authorizer using only the standard library, with per-chunk decisions and optional policy-provided filters; every failure fails closed. Example policy inexamples/opa.filter_to_dict/filter_from_dictfor a JSON form of filters.wardowl.authorizers.openfga: OpenFGA authorizer over the HTTP API (no extra dependency): batched BatchCheck, ListObjects pre-filtering that falls back to post-filtering when the list may be truncated, and the subject’s groups sent as contextual tuples. Tested against a real OpenFGA for random relationship graphs.wardowl.langchain:WardowlRetriever(a LangChain retriever; refuses to run without a subject) andLangChainVectorStoreBackend(any LangChain vector store, post-filtered). Extra:wardowl[langchain].wardowl.llamaindex:WardowlRetriever,PermissionNodePostprocessorandLlamaIndexBackend. Extra:wardowl[llamaindex].Runnable examples for LangChain and LlamaIndex, executed in CI.
CachingAuthorizer: caches pre-filters and per-chunk decisions (keyed by chunk id and metadata) per subject forttlseconds, with that as the documented revocation bound; errors and malformed answers are never cached;invalidate(); sync and async.Retrieval events: every call, including failed ones, produces a
RetrievalEventforobservers(subject id, strategy, returned ids, denied and violation counts, duration, error type, optional HMAC of the query). Never query or chunk text.JsonLinesAuditwrites them as JSON lines; observer failures are logged and never affect retrieval.wardowl.otel.OpenTelemetryObserver: one span per retrieval with counts and outcomes, the subject id only on request. Extra:wardowl[otel].Documentation site (Sphinx, MyST, Furo) in
docs/: getting started, concepts, a security model, operations, one page per integration, extending wardowl, benchmarks and an API reference. CI builds it with warnings as errors, and every push tomainpublishes it to https://muzakkirhussain011.github.io/wardowl/. Extra:wardowl[docs].Benchmark harness (
benchmarks/): recall@k against exact ground truth, leaks, completeness and latency for exact search, Chroma and pgvector across permission selectivity and layouts, with a Kaggle notebook for real embeddings. CI runs a small version against real Chroma and PostgreSQL and fails on any leak.Conformance tests: every backend must return exactly the documents the reference semantics select, for random metadata and nested filters, and never leak through the retriever. CI runs them against real PostgreSQL, Chroma and OPA.
Changed#
RetrievalResult.completeis documented precisely: it isFalsewhen wardowl knows results may be missing, and it trusts the backend’s ranking, so it cannot see results an approximate index failed to return. The benchmarks measure how often that happens.Filter values must be strings or integers, and metadata values of other types (booleans, floats) never match. This keeps every backend’s answer identical to the reference.
The project is now
wardowl, a permission-aware retrieval library (ADR 0005). The earlier Rampart gateway lives on as an example deployment inexamples/gateway.