Permissions

How louvain computes what one person is allowed to see, why two people can correctly get different answers to the same question, and why a refusal looks exactly like a missing record.

Most systems that add memory to AI build one index and hope the prompt keeps secrets. louvain resolves permissions before anything is ranked. A question is answered against the subset of the corpus the asker could already see, and the rest of the corpus is not filtered out of the answer — it never enters the computation at all.

This page is the mechanism. It is the part of the product worth auditing.

Containers are the unit of access

A container is the place in a source system where something was said: a Slack channel, a document folder, an issue-tracker project, a CRM object. It is the unit that already carries an access-control list in the system of record, which is why louvain adopts it rather than inventing a scheme of its own.

Membership arrives as ACL events on the same connectors that carry content, so you do not re-model your permissions inside louvain — it mirrors what the source system already enforces. Those events become relationship tuples:

container:slack/acme/sales  member       user:slack/U042EXAMPLE
container:slack/acme/sales  workspace    workspace:acme

Subjects are namespaced by connector — user:slack/U042…, never a bare user:U042… — because an unprefixed subject syncs successfully and is then never looked up again, which fails open in the one place that must not.

A container's identity is <connector>/<workspace>/<name>. The workspace belongs in it because a channel name is only unique within a workspace: an organization that connects two Slack workspaces has two different #generals, and keying them by name alone would silently merge their audiences.

Object ids also carry the organization, stamped server-side from the authenticated connection rather than taken from the payload. So the full id of the first tuple's resource is container:<org-uuid>/slack/acme/sales. Two customers who both have a #general, or two source users who happen to share an id, address genuinely different objects — the tenant boundary is structural in the authorization store itself, not only in the SQL fence described below. Shorter forms appear throughout these docs as readable abbreviations.

The authorization graph

SpiceDB (an implementation of Google's Zanzibar model) is the runtime authority. The schema is small enough to read in full:

definition user {}

definition workspace {
  relation member: user
  relation admin: user
}

definition container {
  relation workspace: workspace
  relation member: user
  permission view = member + workspace->admin
}

definition claim {
  relation provenance: container
  // Derived facts: the synthesis exists only because sources were combined.
  permission view = provenance.all(view)
  // Asserted facts: independently said in each container — any one suffices.
  permission view_any = provenance->view
}

Two things are worth reading twice. container.view grants view to a container's members plus admins of its workspace — a workspace-level grant exists in the graph, but it is granted by an explicit permission tuple, never by someone's product role (see below). And claim carries both permissions, because visibility depends on how the claim came to exist.

Reads do not call SpiceDB per row. The set of containers a principal can view is materialized into Postgres as a projection (readable_container), so the query path is plain SQL. SpiceDB remains the authority: the relay refreshes the affected principals after every tuple sync, and the projection can be rebuilt in full at any time. SpiceDB and Postgres never share a transaction — tuple intents commit to an outbox and are relayed — so nothing anywhere claims atomicity it does not have.

The projection is identity-aware. A person's readable set is the union of what their louvain principal and every linked source identity can view, so a member whose Slack account is linked by verified email inherits exactly the channels they are in.

Attestations, not rows

The atomic unit of knowledge is not a fact — it is an attestation: one source event, in one container, saying one thing. A "claim" is a read-time aggregation over attestations, computed per principal.

That distinction is the whole design. An earlier version merged identical claims from several containers into one row and required visibility of all of them. The consequence was a defect worth naming: a fact stated in slack/sales and later echoed in slack/exec-private became invisible to the sales channel's members. Every high-clearance echo of a public fact revoked it from the people working on it. The more important a fact became, the fewer people could see it.

Corroboration is not derivation. Hence two algebras.

Asserted claims: ANY visible attestation (union)

An asserted claim was independently said in each of its containers. Seeing any one of those containers is sufficient. An echo can only ever widen who has a source for a fact; it can never revoke it.

claim:  Acme Corp | deal.stage = legal review
        attestation A  slack/sales          "Acme moved to legal review"
        attestation B  slack/exec-private   "legal review on Acme, FYI"

Priya  (slack/sales, slack/exec-private)  → visible, 2 sources
Marcus (slack/sales only)                 → visible, 1 source
Tom    (neither)                          → does not exist

Derived claims: a complete evidence path (OR of ANDs)

A derived claim is a conclusion that exists only because sources were combined — there is nothing single-source to fall back on. It is visible when at least one complete supporting path is visible, and every source inside that path is.

A conclusion can rest on several independent arguments, and they are kept separate. If louvain concludes the same thing from A→B₁→C and from A→B₂→C, those are two paths, not one four-source requirement. Merging them would make the conclusion harder to see than either argument on its own — visible only to someone who can follow both — which is both misleading and needlessly restrictive. Keeping them separate is also what stops someone who can see one hop of each path, but no whole path, from seeing the conclusion.

Fail closed, as everywhere else: a derived claim with no complete visible path is invisible, exactly like a claim with no visible attestation.

A derived claim is marked derivation: "derived" in every response and is never promoted to asserted — the distinction between what a source said and what louvain concluded is permanent, because it is what lets you weigh the two differently. derivedFrom returns the paths you can see, so a conclusion is auditable rather than something you have to take on trust. Today's derivations are deterministic and typed; a model does not decide what is true, so a derived fact can be wrong only if a source was wrong.

Attestations are filtered per viewer too

The response never contains an attestation the viewer cannot see. In the example above, Priya's answer carries two sources and Marcus's carries one — not two with one redacted. Returning an invisible attestation's container, even as a count or a badge, is itself a leak: it would tell Marcus that somewhere he cannot see, someone said this too.

Fail closed, everywhere

Visibility fails closed at every step. Each of these means invisible, not degraded and not logged and allowed:

  • Zero attestations. A claim with no surviving source is visible to nobody.
  • Unsynced tuples. Access granted in the source system but not yet relayed into the graph is access not yet held.
  • Unknown principals. No principal, or a principal with an empty readable set, sees nothing.
  • Wrong tenant. org_id is checked in SQL before any visibility algebra runs. Container strings and principal ids can look alike across organizations, so the org fence is structural and comes first. An unknown org is a 401. The authorization objects themselves are org-namespaced too, so this is defence-in-depth rather than the only wall: a query that forgot the fence still could not match another tenant's grants.

One asymmetry is deliberate and worth stating, because it looks like an inconsistency: the admission gate — the filter that decides whether a message is worth extracting — fails open. Admitting junk costs a fraction of a cent; dropping a decision costs the product its reason to exist. The costs run the other way for visibility, so visibility fails closed. Nothing else in the system gets to choose.

The fence is on the write path too

When louvain extracts a message, it shows the model the current state of the container it is reading — the values that container already believes — so a message like "no, not yet" can be understood as a delta rather than as nothing.

That state is drawn from the container, never from the organization. State drawn org-wide would let a fact from slack/exec-private be restated while reading slack/sales, and the resulting claim would attest to slack/sales — a private fact laundered into a public container. No read-path check can ever catch that, because by then the claim genuinely does belong there. The leak happens on the write path or not at all.

The state block is also excluded from the texts a quote may ground against, so a model that copies it forward produces claims that fail grounding and are dropped rather than accruing false provenance.

Roles gate administration, never visibility

An org membership carries a role: owner, admin or member. Owners and admins manage connections, members, invites, tokens and audit. Members get the product's read surface.

Roles never widen data visibility. Nothing about a role reaches the visibility algebras, and it must never be allowed to: an owner sees exactly what their principal's containers allow, which is often less than a support engineer who happens to be in more channels. The only thing that changes what a person can see is their membership in the source systems that hold the content.

If an owner needs to know what the corpus contains in the aggregate, that is an administrative surface (access insights, audit) with its own role gate — not a widened read.

What this feels like in practice

Two people, one question, two correct answers

POST /v1/answer  { "question": "What stage is the Acme renewal at?" }

as Priya  (slack/sales, slack/exec-private, drive/renewals)
  "Acme Corp deal.stage: closed lost (priya in slack/exec-private)"

as Marcus (slack/sales, drive/renewals)
  "Acme Corp deal.stage: legal review (marcus in slack/sales)"

Neither answer is wrong. Marcus is being told the truth as it exists in the world he has access to. The correction was made in a channel he is not in, and the system does not hint that a correction exists — see Time and truth for why supersession follows the supersessor.

A refusal is indistinguishable from an absence

When there is nothing visible, louvain says so in words that do not disclose which case it is:

No visible deal.stage claim for Northwind.
(Either it isn't known, or you don't have access to where it was said.)

This is deliberate, and it is the harder engineering choice. A system that says "you don't have permission to see this" has just confirmed that this exists — that there is an Acme deal, that a person by that name works here, that an incident happened last Tuesday. Existence is frequently the sensitive part. Anything other than an identical response leaks the shape of what it is hiding.

The same applies through the reader: an unverified answer is refused rather than hedged, and a search plan's list of loosely related facts is never substituted for a refusal. A confident non-answer is worse than silence, and much worse here, because "I cannot say" is also what a permission boundary sounds like.

Operators are not left blind by this. When a retrieval stage genuinely fails — a broken reranker, a second hop that threw — the response carries an explicit degradation list, so "the second hop failed" stays distinguishable from "nothing matched" and from "you may not see it" for the person running the system. Only one of those three is the system working, and from the outside they must look the same.

How this is verified

The boundary is measured, not asserted. bench:permission puts a real corpus in one tenant, has an insider answer a set of questions, then has a separate tenant with a valid account and a valid token ask the identical questions against the identical deployment. Any shared content word, or any evidence row at all, fails the run. The bar is zero, because anything above zero is a headline. It also refuses to score when the insider answered nothing, since an empty deployment leaks nothing and would otherwise pass trivially.

The end-to-end suite asserts the properties individually: that union visibility holds (sales members keep sales-sourced facts regardless of exec echoes), that attestations are filtered per viewer, that supersession follows the supersessor, that an empty provenance set denies, and that the org wall holds in both directions.