Page ACLs
Once an instance is claimed, every page has an access policy.
Grants live in exactly one place, kb.page_acl, never in page metadata or frontmatter, so
an OKF dump-and-restore or a GitOps edit can never
accidentally change who can see a page. Reads, search, RAG, and live WebSocket
subscriptions all filter through the same grant table.
The grant model
Section titled “The grant model”A grant is a row: grantee (user:<uuid> or everyone), level (read or
write), on a specific page. Write is a superset of read, holding write never requires a
separate read grant.
A page is in one of two ACL modes:
explicit: the page carries its own grants.inherit(the default): the page has no grants of its own and defers to the nearest explicit ancestor directory. With no explicit ancestor anywhere up the tree, the root default applies: the page’s owner plus every admin.
Effective access is one join
Section titled “Effective access is one join”Walking the inherit chain on every read would be slow, so it’s precomputed. Each page
carries a denormalized pointer, kb.pages.acl_source_id, at the page whose grants
actually govern it (itself, if explicit; the nearest explicit ancestor, otherwise). A
recursive CTE recomputes this pointer whenever a page moves or an ancestor flips
acl_mode, and a verify-and-repair pass runs at boot to catch anything that drifted. The
result: every read path answers “can this principal see this page” with a single indexed
join against kb.page_acl, not a tree walk.
Ownership
Section titled “Ownership”kb.pages.owner_id and kb.files.owner_id name the creator. createPage stamps the
issuing user; the claim transaction backfills every pre-existing
page and file to the claiming admin. Claim also flips the shared operational surfaces,
inbox, task queue, reminders, calendar, mail, IM, memory, into explicit mode with an
everyone: write grant, so the surfaces every user needs to see stay visible to the whole
team by default.
Unauthorized read is NOT_FOUND
Section titled “Unauthorized read is NOT_FOUND”Subspace never tells you a page exists and you can’t see it. An unreadable page behaves
exactly like a missing one: NOT_FOUND, not a FORBIDDEN-shaped error. This applies
uniformly, resolving a slug, following a link, hitting cmd-K search, or subscribing to a
page’s WebSocket topic all fail the same way whether the page never existed or you simply
lack access. It also means revoking access mid-session is silent from the client’s point
of view: a refetch just 404s.
The write command matrix
Section titled “The write command matrix”Every mutating command re-checks access inside the write transaction
(applyKbCommandAs), not just at the API boundary:
| Command | Requirement |
|---|---|
createPage |
Write on the target directory. At the root, any member can create; the creator becomes owner. |
| Node/page edits | Write on the page. |
movePage |
Write on the page and on both the source and destination directories. |
mergePage |
Write on both pages. |
| Any command against a page you can read but not write | forbidden (not NOT_FOUND, since you already know it exists). |
kb.kb_commands.user_id records who issued every landed command, so the command log
stays attributable per user even though the outliner itself has no per-node author field.
Enforcement across read paths
Section titled “Enforcement across read paths”ACL filtering is threaded through every way a page becomes visible, not bolted onto one entry point:
Page view
Backlinks, references, embeds, and breadcrumb chains all elide unreadable targets rather than erroring the whole page render.
Directory views and the sidebar tree
An unreadable ancestor directory on the path to a directory you do have a grant on still renders, as a title-only stub, so the tree stays navigable without leaking the stub’s own content.
Resolve and lexical search
readableWhere filters both. Slug resolution and full-text search
can’t be used to probe for a page’s existence.
Vector search / RAG
The vector arm over-fetches 4x candidates and drops unreadable hits before RRF fusion with the lexical arm, so ranking quality doesn’t degrade just because some high-scoring neighbors are invisible to you. RAG memory retrieval is cut off at the same memory-surface ACL.
Files and blobs
A file is readable by its owner, or through any readable page that references it
(tracked in kb.file_refs, maintained from the node-element diff on every edit).
WebSocket page topics
Each subscriber is gated per topic through a fail-closed cache, wholesale invalidated on any ACL or page change, so a live subscription can’t outlive a revoked grant.
Administration
Section titled “Administration”| tRPC procedure | Who |
|---|---|
kb.acl.get |
Owner or admin |
kb.acl.setGrant |
Owner or admin (audited in kb.acl_audit) |
kb.acl.setMode |
Owner or admin (audited) |
kb.acl.audit |
Admin |
users.lookupByEmail |
Any authenticated user (used to resolve a grantee by email in the share dialog) |
The share UI
Section titled “The share UI”Every page shows a share chip. It opens a dialog with:
- The owner, shown as a fixed line.
- An inherit / explicit toggle. When inherited, it names which ancestor’s grants are in effect.
- Per-user grant rows, add someone by email (this auto-flips the page to
explicitmode if it wasn’t already), each row a read/write toggle. - An everyone toggle for read or write access by anyone in the instance.
Changing a grant invalidates every live subscription to the affected pages immediately: a
user who just lost access gets NOT_FOUND on their next refetch, not a stale cached view.
Agents inherit their owner’s ACLs
Section titled “Agents inherit their owner’s ACLs”An agent run doesn’t get its own access policy. It acts as its owner, so every page
read, search hit, or edit it performs is filtered by the same kb.page_acl grants the
owner would see if they did it by hand, including revocations that land mid-run. The full
mechanics, the authorize seam, tool-by-tool scoping, and how task/approval cards route to
the right inbox, live on per-user agents & comms.