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 put AI over a firm's documents 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:acmeSubjects 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.
Who a workspace connection is shared with
Some sources publish no membership Louvain can mirror. An administrator's API
key for an issue tracker, a CRM or an HR system reads the whole workspace, and
nothing in what it returns says which colleague may see which record. Such a
connection files everything into ONE container, <connector>/<workspace>/all,
and fail-closed means that container is readable by nobody until someone
decides otherwise.
That decision is the connection's audience, chosen by an admin when the connection is created and changeable at any time:
| Audience | Who can read the connection's all container |
|---|---|
| Everyone | Every current member of the workspace, including people who join later |
| Specific people | The members named, and nobody else |
| Follow the source | Each project, repository or team is readable by its members at the source, resolved through linked identities. Offered only where the source publishes membership |
Four things the audience never does:
- It grants one container. Never a per-project container (those follow the source's own membership), and never the restricted container — where a record the source marks as narrower than its project (a Jira security level, a Confluence read restriction) lands when its audience cannot be computed. Nothing grants that container, so such records are stored and counted, and readable by nobody.
- It names members, not strings. The grant is recomputed from current membership: a person removed from the workspace loses it at once, and a name in Specific people who is no longer a member is not granted.
- It revokes only what it wrote. Switching to Follow the source withdraws the audience's grant; a grant the source or a personal sign-in made is not the audience's to remove. Deleting the connection is different: it revokes every grant the connection wrote, the audience's and the source's own, so what it imported is readable by nobody unless you connect that workspace again (see Removing a connection).
- It never widens a personal connection. A connection someone makes for themselves is theirs, whatever its source.
A change is written to the audit log as the mode and a count — never the names. Tidy, joinable data is not evidence about who may read it: a deal value is one of the most sensitive rows a company holds, which is why Everyone is a choice an administrator makes on purpose and not a default.
When someone can see it in the source but not in Louvain
Follow the source grants what the source grants to its own users — a GitHub login, a Linear user id. That grant reaches a person in Louvain only through an identity linked to their account, and linking is strict: the address the source states for them must be one Louvain knows is theirs — the address they sign in with, or an address that was verified for their account — or, for GitHub, the account they signed in to themselves under Link your GitHub. A similar name, or a second address they typed under their own settings, does not link — either would be a guess, and a guess here would widen what somebody may read. So a person the source lists on a project who has no account, or whose source address Louvain has never seen verified for them, sees nothing from it. That is fail-closed and correct, and it would otherwise be silent.
Connecting your own account once links your identity even when your sign-in email
differs. When you connect Linear, Jira or Confluence for yourself, Louvain
asks the provider which address the account you signed in with uses — only where the
provider guarantees that address is verified — and records it as yours. A workspace
connection's record of that address then links to you. An address verified for you
never links for anyone else, never links when another member holds the same address,
and never links a shared mailbox such as support@; a workspace connection's
credential never reports one; and disconnecting your connection takes back the links
that rested on its address. The identity provider's verified addresses and the
addresses a connected mailbox sends as count the same way.
What a personal connection says about a source user never carries a workspace grant. Its owner controls the workspace behind it, so an identity it records links only to that owner, never changes an identity a workspace connection recorded, and never receives what a workspace connection grants to that source user id. When a workspace connection later states a different address for an identity a personal connection recorded first, the workspace connection's record replaces it and the old link is removed at once.
Two workspace connections to the same kind of source — two Pipedrive
accounts, say — share that source's user ids, and user 42 in one may be a
different person from user 42 in the other. The connection that recorded an
identity keeps it: another connection stating a different address for the same id
changes neither the address nor who it links to. A record whose writer is unknown
(recorded before this rule existed, or by a connection since deleted) is treated
the same way while more than one connection of that kind exists.
The connection's settings say it instead: "N people with access in Linear can't see it in Louvain yet", with each person and the reason — no account (with Invite), already invited, a member signed in under a different address, or no address at all from the source. Only admins see the list; it names people from the source's own directory and how many of the connection's projects, repositories or teams each is granted, never which ones. Connections shared with Everyone or Specific people never show it, because those grant accounts directly.
The authorization graph
SpiceDB (an implementation of Google's Zanzibar model) is the runtime authority. The part of the schema that decides a read is small enough to quote:
definition user {}
definition workspace {
relation member: user
relation admin: user
}
definition container {
relation workspace: workspace
relation parent: container
relation member: user
relation editor: user
relation manager: user
permission view = member + editor + manager + workspace->admin + parent->view
permission edit = editor + manager + parent->edit
permission manage = manager + parent->manage
}That is the whole of it. container.view grants view to a container's members,
its editors and managers, 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). parent is written only by Louvain, for a
file shared on its own: it gets a container of its own under its collection's, so the
collection's readers, editors and managers keep exactly their sight of it, and the
people it is shared with are granted on that file alone. editor and manager are an
upload collection's roles: they let a person change what the
collection holds, and they imply view, so granting one refreshes what that person
can read and removing one narrows it at once, like any other revocation.
Nothing Louvain derives has an object of its own in the authorization graph. A statement, an excerpt, an entity and an identity decision are all reached through the container of the record they came from. There is no per-statement tuple to write, to keep in step, or to get wrong — which is also why the authorization store's size is a function of your channels and your people rather than of how much your company has said.
Reads do not call SpiceDB per row. The set of containers a person can view is
materialized into Postgres as a projection (readable_container), so the query
path is plain SQL. SpiceDB remains the authority: the relay records required
projection refreshes in the same transaction as its delivery stamp. Failed
refreshes retry; a newer request survives an older attempt. Claimed container
membership and workspace-admin revocations narrow saved access even while
SpiceDB is unavailable. Lookups and revocations serialize per person so an
older lookup cannot put revoked access back. Confirmed empty results are cached
briefly; an unavailable permission store returns a distinct 503 when no saved
projection exists. 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. What a person can read is the union of what their Louvain account 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.
A statement inherits its record's container
The atomic unit of knowledge is a statement: one record, in one container, saying one thing, at a span in its own text. It has no audience of its own. The writer stamps the container from the record, and a proposal coming out of a reading has no field for an org or a container at all — there is nothing for a step to state and nothing for a caller to override.
Everything else follows from that, including the property this design exists for.
The union holds by construction
A value that "is current" is not a row. It is folded, when you ask, over the
statements you can read (Time and truth). So a
fact stated in slack/sales and echoed later in slack/exec-private is two
statements, and a sales member folds the one they can see:
statement A slack/sales "Acme moved to legal review"
statement B slack/exec-private "legal review on Acme, FYI"
Priya (slack/sales, slack/exec-private) → legal review, 2 sources
Marcus (slack/sales only) → legal review, 1 source
Tom (neither) → nothing; the question finds no AcmeAn echo can only ever widen who has a source for something; it can never revoke it. That is not a rule enforced by a visibility algebra — there is no merged row whose audience could have been computed as the intersection of its sources, which is how an earlier design made every high-clearance echo of a public fact invisible to the people working on it. The more important a fact became, the fewer people could see it. A fold over what you can read cannot have that bug.
It also means corroboration counts are per viewer without anything extra being done to make them so: Priya's answer says two sources and Marcus's says one — not two with one redacted. Returning an invisible statement's container, even as a count or a badge, would itself be a leak, because it would tell Marcus that somewhere he cannot see, someone said this too.
A conclusion re-checks every source it rests on
Some statements are not read from a record but derived from other statements. Those carry the inputs they were derived from, and the writer refuses any input outside the record's own container, so a conclusion can never be assembled across the fence in the first place.
At read time the rule is stricter still: a derived statement reads as retracted the moment any input it rests on is retracted, out of force, or withdrawn with its record. A conclusion is only as visible and as live as the whole of its evidence, checked on every read rather than at the moment it was written.
Nothing in the shipped pipelines writes a derived statement today — the older
design's transitive miner is gone, and stored inference with it. The rule stands
because it is enforced by the one writer rather than by whoever writes the next
deriver, and because the middle grey you do see on an answer is a different
thing: a single never-stored inferred field, drawn fresh on each question from
rows the asker can read, and evidence for nothing.
Existence is visibility
The sharpest property of this model is not about values at all. A thing exists for you only through evidence you can read.
An entity — a person, a company, a deal, a project — is visible to you when a statement you can read binds it, or when a record you can read stated one of its identifiers. Otherwise it is not "hidden" or "redacted"; it is absent, and every surface that could have named it behaves as though the name were one nobody has ever mentioned:
- Resolving a name returns no candidate, so no label, no kind, no id and no count reaches you. A name only your directory knows resolves to nothing.
- A refusal cannot name it. The refusal you get for a rumoured colleague is the refusal you get for an invented one — a name that resolved to nothing is the same story either way. An earlier design streamed the question's resolved entity and its kind on the answer trace before any evidence, which turned a refusal into a directory lookup through the safest-looking path in the product (go-live review 2026-09-14, P0-2). The trace now carries a shape and vocabulary frame keys until the fence has admitted something, and only then a name.
- A merge is a read too. Two entities are one for you only when you could see the decision that joined them — an administrator's, a directory's or a member's own account's, made outside any record, or one whose record or evidence statements you can read. Neither a folded value nor a canonical id reveals a merge decided in a room you cannot read.
- A suggestion to merge is a read too. Louvain may suggest that two entries are one person (for example, when a message header gives an address the name your directory uses for someone), but it never merges them itself, and an administrator sees the suggestion only if they can read the message or record it came from. Deleting that record removes the suggestion.
- A label rides with its evidence. A name a record taught an entity is visible only to a reader of that record; one that came from outside any record travels with the entity. Two entities answering to the same word stay two candidates.
- An identity proposal is only yours to decide if you can see both sides. An administrator is shown a possible duplicate only when both entities exist for them and the grounds are readable — because a role gates administration, never visibility.
Every one of those is the same fenced read as a row read, written once, in one place. An entity read that is wider than the row read is a directory lookup through the safest-looking path in the product, which is exactly why it is the same code.
Fail closed, everywhere
Visibility fails closed at every step. Each of these means invisible, not degraded and not logged and allowed:
- No visible statement. A value nothing you can read states does not stand for you, and a thing nothing you can read mentions does not exist for you.
- Unsynced tuples. Access granted in the source system but not yet relayed into the graph is access not yet held.
- Unknown people. No account, or an account whose linked identities can read nothing, sees nothing.
- Wrong tenant.
org_idis checked in SQL before any visibility algebra runs. Container strings and person 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
A message like "no, not yet" means nothing on its own, so a reading is shown context: the participants, and the earlier messages of the conversation the message's own source names. That context is the leak nobody sees coming, and it is fenced three ways.
The conversation comes from the same container. Earlier messages are read
from the same workspace and the same container as the message being read, never
from the organization at large. Context drawn wider would let something said in
slack/exec-private be restated while reading slack/sales, and the statement
would carry slack/sales — a private fact laundered into a public container. No
read-path check can ever catch that, because by then the statement genuinely does
belong there. The leak happens on the write path or not at all.
The words have to be the record's own. A statement's span is located in the record's own text and checked to lie inside it before the row is written. A reading that carries a figure forward out of the context it was shown, out of a signature or out of quoted history writes nothing: there is no span for it, so there is no statement. That is also what makes every citation honest — the source shown for a statement is a source that states it.
A proposal cannot name a container. The writer stamps the org and the container from the record and the step. A reading has no way to say where its output belongs, so there is no field in which it could be wrong.
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 fence or the fold, and it must never be allowed to: an owner sees exactly what their own 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 — true since 2026-08-01
(Priya Shah in exec-private)"
as Marcus (slack/sales, drive/renewals)
"Acme Corp — Deal stage: legal review — true since 2026-07-02
(Marcus Webb in sales)"Neither answer is wrong, and neither was retrieved from a row somebody wrote as current. Each is a fold over the statements that person can read, which is why the two can differ without anything anywhere having to reconcile them. 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.
The same holds when a value is ended rather than replaced. If a departure is announced in a room Marcus is not in, the intervals it closes — Dana leads the Falcon project, Dana owns the Acme account — stay current for him, with no end date, at every as-of. An end date on a value he can read would tell him who is leaving, and when, from a room he cannot read. See Time and truth for the fold, the effects that close an interval, and why an ending follows the statement that wrote it.
One question, many readers
A discussion on a shared document is read by everyone who can open its collection, or, for a connected source's document, everyone who can open the document itself: its comments follow the document, so moving it or sharing it more widely shows the earlier discussion to the people it now reaches. If Louvain answered there with the asker's access, Priya could ask a question in a thread Marcus can read, and the reply would carry what only Priya can see. So in a discussion Louvain answers under the intersection: the containers that the asker and every reader of the room can all read (ADR-040).
The narrowing happens where what a person can read is resolved, not in each query. Inside a discussion reply, every lookup of the asker's set returns the intersection. Looking up anyone else's set there is refused as an error. The reply is then checked again for each person who opens it. A reader who cannot open every source a reply rests on sees none of it and no count, because a count of withheld parts would be a count of hidden evidence. That covers someone who joined later or lost a source since. A reply someone shared into the discussion from their own conversation is different, and is described next. See Library.
Sharing what you wrote is your decision; the documents stay fenced
An answer Louvain wrote for you was written from everything you can read. When you share it with people, or into a discussion, its prose goes as written: what you wrote with Louvain is yours to share, and you are shown, before you confirm, which cited sources not everyone can open and how many people can't (never who, though shared with one person it can't help saying whether they can). The documents themselves stay behind the fence: each reader sees the sources they can open, and every other one only as "Source redacted", with no title, text or date. This is checked every time they read it, and a deleted source, or one you have lost access to, removes the sentences resting on it from every copy. Someone you shared with can share it on; a source they can't open goes on your consent, so it still disappears everywhere if you lose it. Shared into Slack, the answer is ONE message written for the narrowest reader in the channel — anyone there who can't open a source, or who isn't in Louvain, means it goes as "source redacted" — and it is a snapshot: it is not checked again on reading, though a source deleted later is still taken out of it (Louvain edits the message), and you can remove it. Sharing is audited. See Share a conversation (ADR-061).
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" 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 frame is named because it is vocabulary: "Deal stage" is a word your workspace's own knowledge model uses, and saying which question was asked discloses nothing about whether Northwind exists. The name in the sentence is one the asker wrote. A name that resolved to nothing is never named back, and a refusal about a name nothing resolved is refused before any model runs — which is also why it is quick.
The same applies through the reader: an unverified answer is refused rather than
hedged, and a search shape's list of loosely related rows 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 part of a read does not run, the
response carries an explicit degradation list in the primitives' own names —
vector:embedding when a dense leg could not answer at all, keyword:bm25 when
the ranking sidecar did not reply and the same rows arrived in a worse order,
keyword:narrow, keyword:wide or recent:cut when a bounded pass was cut by
its own timeout. So "a leg 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 and database test suites assert the properties individually: that
a sales member keeps a sales-sourced value whatever a room they cannot read
echoed; that the fold, the count and the corroboration counts are per viewer;
that an ending follows the statement that wrote it, in either direction (a
departure in a room you cannot read neither dates nor hides a value you can);
that a name only a directory knows resolves to nothing, and a refusal never
names it; that a merge is followed only by someone who could see the decision;
that a read given a container list can only ever narrow, never widen, what the
projection grants; and that the org wall holds in both directions
(test/kg-core-reads-db.test.ts, test/kg-core-platform-db.test.ts,
test/kg-read-db.test.ts, test/planner-fence-db.test.ts,
test/kg-core-w0-db.test.ts).
Quickstart — hosted
Sign in to a hosted Louvain deployment, connect a source, and get your first answer with receipts.
Plate
What your sources put on you — asks of you, asks you sent, promises made to you, commitments and deadlines — judged from the messages themselves and closed by evidence, with the receipt shown.