Decision record
ADR-0013: A group decides which pages, a role decides what you may do; a restriction covers the branch; the repository is the source
ADR-0013: A group decides which pages, a role decides what you may do; a restriction covers the branch; the repository is the source
Status: accepted · 2026-09-16
Context
Phase 16 asked three questions that each had a cheap answer and a right one.
Who may read a page. Phase 1 built canReadPage principal-based — a
caller is a user plus, eventually, group ids — and the page record already
carried visibility: 'restricted' and a restrictedTo list. Nothing
populated the group ids and nothing wrote the list. The cheap answer was
permissions on groups: a group with a role, the shape Confluence and
Notion teams expect. The right answer had to fit the roles that already
exist and the six surfaces that answer “which pages”: the tree, a single
read, comments, the review queue, MCP, and search — the last with an index
that cannot ask Firestore at query time.
Whether a restriction reaches a page’s children. A page is a node in a tree. Restricting only the node leaves its children at the root of the tree for anyone outside the list, parent missing; restricting the branch means a child inherits a list it does not carry, which the search index has to know.
Which direction a repository connection runs. docs/ in a repository
and pages in a workspace are the same documents in two places. Two-way sync
is what a demo wants. It is also what the Google Docs rule (CLAUDE.md:
never bidirectional) exists to refuse, for the same reason: two writers,
two histories, one merge nobody asked for.
Decision
A group is a principal, not a role. A group is a named set of members and nothing else. It appears where a person appears — in a page’s allow-list — and a member’s role still says what they may do on the pages the list lets them read. Groups are managed by whoever may invite, because putting someone in a group is the same class of decision as putting them in the workspace.
A restriction covers the branch. A page under a restricted page reads
by the nearest restricted ancestor’s list unless it carries a list of its
own (effectiveRestriction). The tree hides a branch whole. The search
index stores the effective list per row, plus the creator and a role:admin
principal, so that its filter says yes in exactly the three ways a read
does; restricting or moving a page reindexes its descendants. The creator
and every admin always keep access; the person restricting is always on
the list; a public or link-shared page is unpublished first, and a
restricted page cannot be published.
The repository is the source. A GitHub connection is one way: pushes
to the branch upsert pages by github:owner/repo:path under a service
member the connection creates; files gone from the tree archive their
pages; nothing writes back. The connection reuses the folder importer’s
planner, so a repository’s docs/ and a zip of the same folder produce
the same tree.
Assumptions
- Teams restrict a handful of branches, not hundreds of scattered pages; walking a page’s ancestors one read at a time is cheaper than loading the tree, and reindexing a branch on restrict is rare enough to be synchronous task dispatch.
- Roles on memberships are enough authority granularity for the first year; the ask is “who can read this”, not “who can edit this”.
- A repository’s documentation is authored in the repository. People and agents read, comment, review and run it here; they do not edit it here and expect it back in git.
Invalidation triggers
- A workspace with more restricted pages than open ones, or restrictions changing many times a day, makes the per-read ancestor walk and the branch reindex the wrong shape.
- A customer whose model is “the docs team edits in Bladbase and engineers read in the repo” — then the direction, not the mechanism, is wrong, and the Google Docs rule has to be argued again rather than assumed.
- A need for a group to carry a role (“contractors are commenters everywhere”) that cannot be met by setting roles on the memberships.
Blast radius
packages/domain/src/policies.ts(principalsForMember,effectiveRestriction,readablePages,allowedPrincipalsFor), every read path that calls them,packages/search(row ACL), the MCP context (principals per request),apps/worker/src/github.ts.- Changing the branch rule means reindexing every workspace.
Alternatives rejected
- Roles on groups. Paints over the permissions model: a page’s allow-list would have to be joined with a role table on every read, and “what may this token do” would depend on which page it asks about.
- Restriction on the node only. Children orphaned in the tree; a restricted parent with readable children is a page that hides its title and shows its contents.
- Two-way GitHub sync. Refused for the reason the Google Docs rule gives; also, every synced page here is attributed to a service member, and a service member cannot author a commit.
Escape plan
- Groups can gain roles additively: a
rolefield on the group and amax(role)inprincipalsForMember, with the allow-list untouched. - The branch rule is one function; reverting to node-only restriction is
a change to
effectiveRestrictionand a workspace reindex. - A write-back would be a separate connection type with its own service member and its own ADR.
Consequences
- Restricted pages are “not found” rather than “not allowed” everywhere, because their existence is part of what is restricted.
- Deleting a group does not widen access to the pages that named it; the id simply matches nobody. Leaving the workspace leaves every group.
- Deploys run from a separate
mainworktree, after a build picked up half-written step-2 files from the working tree.