Nacre

Self-hosted · Apache 2.0 core

Strength from layers.

Permission-aware context layer for agents. Your index, your access rules, your perimeter.

Nacre shimmers, but you cannot see through it. Its light comes from interference between layers — and it stays opaque. Every agent and every user sees the cross-section they are cleared for, and not one line more.

One index, one query

Who is asking?

3 layers indexed

POST /v1/search"when do new hires get access"
Bearer token

The difference that matters

The filter runs inside the index, not after it.

Most retrieval stacks fetch the top k results and then remove the ones the caller may not see. Ask for ten and you get ten minus whatever was stripped — silently, and worse the more restricted the reader is. Nacre puts the access filter inside the traversal, so top_k returns k permitted results.

Pre-filter, structurally

The query builder takes a resolved access plan and has no parameter for a raw filter. There is no method a caller can reach that skips the permission model.

Invisible is not forbidden

A document you may not see and a document that does not exist return the same status, the same body, and the same wording. Otherwise identifiers can be enumerated.

Failure denies

If permissions cannot be evaluated, access is refused. There is no path that shrugs and lets a request through because a cache was cold.


The question a security engineer actually asks

What happens when you revoke someone's access?

Not "eventually". Here is the sequence, and where the boundary of "eventually" actually sits.

t + 0

The grant is deleted

A database trigger increments the organization's permission version in the same transaction. Not the application — one writer, so a version bumped by whichever service remembered cannot lie.

the very next request

The revoked grant can no longer be served

The resolver's cache is keyed on that version, so the next request composes a different key and misses. The stale entry is never invalidated — it is simply never asked for again. The cache TTL is a memory bound, not the correctness mechanism.

within the SLA

The denormalized tags catch up

Each chunk in the vector index carries a cached copy of who may read it. A background pass rewrites them, and until it finishes the layer bound from Postgres applies too — both filters, every query. The window is bounded by NACRE_ACL_PROPAGATION_SLA.

continuously

You can watch it

nacre_acl_propagation_lag_seconds reports the age of the oldest document whose tags predate the current version, per organization. Alert on max(). It is the external evidence that the promise above still holds — an unmeasured backlog is indistinguishable from no backlog.


Six rules · breaking one is an incident, not a bug

The invariants, in the open.

They are numbered because the code and the tests refer to them by number. Each one has cases in a suite that runs on its own CI job, against a real database and a real vector store.

I1
The organization comes from the token Never from a body, a path, or a header — not even as an admin override.
I2
Filtering is a pre-filter, never a post-filter The filter goes inside the index traversal, so k means k permitted.
I3
A failure to evaluate denies There is no "couldn't compute it, let it through" path.
I4
No permission and no such object are the same answer 404, never 403 — including the wording of the message.
I5
A deleted document is never returned Including before garbage collection has taken its vectors.
I6
Write does not imply read Admin implies both. The opposite of most systems, and deliberate.

Two surfaces, one authorization service

Agents over MCP. Applications over REST.

The MCP server is not a wrapper with its own rules — it resolves permission through the same code the REST API uses, per call. Two implementations would be two places for the rules to drift.

MCP · Streamable HTTP and STDIO

For an agent

The search tool's description is generated from the layers that caller can actually see, so the model knows what is worth asking for.

$ NACRE_SERVICE_KEY=nacre_sk_… npx @nacre.work/mcp

→ tools/list
  search · list_layers · get_document
  ingest_document · delete_document

→ tools/call search
  "when do new hires get access"
  ← 1 result · layer=handbook

REST · OpenAPI

For an application

Ingest is idempotent on the layer and your own identifier, so the retry every client makes after a timeout costs nothing.

$ curl -X POST localhost:8080/v1/documents \
    -H "Authorization: Bearer $NACRE_TOKEN" \
    -d '{"layer":"handbook",
         "external_id":"onboarding-2026",
         "content":"New engineers get…"}'

  ← 202 { "job_id": "…", "status": "queued" }

Where it runs

Inside your network, with no phone-home.

Docker Compose

PostgreSQL, Qdrant, a worker, the API, the MCP server and a parser sidecar. Three profiles: minimal, full, and air-gapped.

Your embeddings

Any OpenAI-compatible endpoint, bound per layer, swappable with a reindex that keeps search available throughout.

Apache 2.0

The core is open, including the permission model and the whole test suite that guards it. Commercial modules are a separate repository.

State, plainly

Early, and it runs. The whole loop has been driven against a real PostgreSQL and a real Qdrant: create an organization, set up access, ingest, and search — over REST and over both MCP transports. Revoking a grant drops a document from results while its vectors are still in the index.

Since written: email and password sign-in with rotating refresh tokens, cross-encoder reranking, garbage collection for tombstoned vectors, rate limiting, idempotency keys, cursor pagination, and the TypeScript SDK and admin UI.

Not built yet: reindexing a layer onto a different model, reading the audit log back over the API, OAuth discovery and dynamic client registration, and multipart upload on ingest. Commercial modules — multi-tenancy, SSO, document-level deny rules — are planned and none is written; there is nothing to buy today. The specifications in docs/ are normative and in places still ahead of the code, which is the honest way round.