Skip to content

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.

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.

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.

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.

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.

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.

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.

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)

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 explicit mode 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.

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.