Skip to content
316 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Docs: a workspace knowledge base people and agents write together1/**
2 * Who may do what in a space: pure, so the rules are tested apart from the
3 * service (docs/WORKSPACE.md, "The asker's access caps the agent").
4 *
5 * - `workspace` spaces give every member `default_role`; `team` spaces give
6 * the team's members `default_role`; `private` spaces give nobody
7 * anything by default.
8 * - Listed members (`user:<id>`, `agent:<id>`, `team:<slug>`) add to that:
9 * a person's role is the highest that applies to them.
10 * - Workspace owners manage every workspace and team space. A private
11 * space is its members' alone, owners included.
12 * - An agent acts for a person: it reads what they read, suggests where
13 * they can comment, and edits directly only where they can edit and the
14 * space lets agents edit.
15 */
16import type { DocAgentAbilities, DocAgentMode, DocRole, DocSpaceKind } from "@g1t/contracts";
17
18export const RANK: Record<DocRole, number> = { view: 1, comment: 2, edit: 3, manage: 4 };
19
20export function isRole(value: unknown): value is DocRole {
21 return value === "view" || value === "comment" || value === "edit" || value === "manage";
22}
23
24export function atLeast(role: DocRole | null | undefined, need: DocRole): boolean {
25 return !!role && RANK[role] >= RANK[need];
26}
27
28export function higher(a: DocRole | null, b: DocRole | null): DocRole | null {
29 if (!a) return b;
30 if (!b) return a;
31 return RANK[a] >= RANK[b] ? a : b;
32}
33
34export function lower(a: DocRole | null, b: DocRole | null): DocRole | null {
35 if (!a || !b) return null;
36 return RANK[a] <= RANK[b] ? a : b;
37}
38
39/** The parts of a space access depends on. */
40export type SpaceRules = {
41 kind: DocSpaceKind;
42 team: string | null;
43 default_role: DocRole | null;
44 members: { principal: string; role: DocRole }[];
45};
46
47/** A person, as access needs them. */
48export type Person = {
49 user_id: string;
50 /** Owner of the workspace. */
51 owner: boolean;
52 /** The slugs of their teams in the workspace, lowercased. */
53 teams: ReadonlySet<string>;
54};
55
56/** A person's role in a space, or null when they can't read it. */
57export function roleOf(space: SpaceRules, person: Person): DocRole | null {
58 let role: DocRole | null = null;
59 if (space.kind === "workspace") role = space.default_role;
60 if (space.kind === "team" && space.team && person.teams.has(space.team.toLowerCase())) role = space.default_role;
61 for (const m of space.members) {
62 if (m.principal === `user:${person.user_id}`) role = higher(role, m.role);
63 else if (m.principal.startsWith("team:") && person.teams.has(m.principal.slice(5).toLowerCase())) role = higher(role, m.role);
64 }
65 if (person.owner && space.kind !== "private") role = "manage";
66 return role;
67}
68
69/** Whether every one of `people` can read the space; for an agent answering to an audience. */
70export function readableByAll(space: SpaceRules, people: Person[]): boolean {
71 return people.every((person) => atLeast(roleOf(space, person), "view"));
72}
73
74/**
75 * Whether a space is readable by "everyone in the workspace": a public
76 * channel's audience. Only a workspace space with a base role is.
77 */
78export function readableByWorkspace(space: SpaceRules): boolean {
79 return space.kind === "workspace" && atLeast(space.default_role, "view");
80}
81
82/** What an agent may do for a person whose role is `asker`, in a space whose agents `mode`. */
83export function agentAbilities(asker: DocRole | null, mode: DocAgentMode): DocAgentAbilities {
84 return {
85 read: atLeast(asker, "view"),
86 suggest: atLeast(asker, "comment"),
87 edit: atLeast(asker, "edit") && mode === "edit",
88 };
89}
90
91/** Whether `role` holders may change who is in a space and its settings. */
92export function mayManage(role: DocRole | null): boolean {
93 return atLeast(role, "manage");
94}
95
96/**
97 * Whether removing or lowering `member` would leave a private space with
98 * no one to manage it. Workspace and team spaces always have the owners.
99 */
100export function leavesNoManager(kind: DocSpaceKind, members: { principal: string; role: DocRole }[], member: string, role: DocRole | null): boolean {
101 if (kind !== "private") return false;
102 const after = members.filter((m) => m.principal !== member).map((m) => m.role);
103 if (role) after.push(role);
104 return !after.includes("manage");
105}
106
107/** A member key's parts, or null when it is not one. */
108export function memberKey(key: string): { kind: "user" | "agent" | "team"; id: string } | null {
109 const at = key.indexOf(":");
110 if (at < 0) return null;
111 const kind = key.slice(0, at);
112 const id = key.slice(at + 1);
113 if (!id || (kind !== "user" && kind !== "agent" && kind !== "team")) return null;
114 return { kind, id };
115}
The docs service has tables for artifacts beside Docs' pages, and one tested rule for who can read and change an artifact: its owner, shares on it or above it, its space, and everyone in the workspace or with the link.116
117// ── Folios (Artifacts mode, docs/ARTIFACTS_MODE.md section 2.1) ─────────
118//
119// A person's role on a folio is the highest of:
120// 1. its owner → `manage` (and the owner of every ancestor it inherits
121// from, as if that owner held a `manage` grant there);
122// 2. grants on it or on an ancestor it inherits from, to their `user:` key
123// or one of their `team:` keys;
124// 3. their space role, when the chain reaches the top of a space without a
125// restriction (`inheritsSpace`): workspace owners manage those, as for
126// pages, through `roleOf`;
127// 4. the access root's general access: `workspace` gives every member its
128// role, `link` gives it to members who opened the link (a visit).
129//
130// A restricted folio (`inherit` false) is its own access root: grants
131// above it, its space and its parent's general access stop there. An
132// agent never has more than the person it acts for, narrowed to what
133// everyone it is talking to can read.
134
135/** One folio as access needs it. */
136export type FolioAclNode = {
137 id: string;
138 /** `user:<id>`. */
139 owner: string;
140 parent_id: string | null;
141 space_id: string | null;
142 /** False: "Only people invited", its own access root. */
143 inherit: boolean;
144 general_access: "none" | "workspace" | "link";
145 general_role: DocRole | null;
146 created_at?: string;
147};
148
149/** An explicit share, as set. */
150export type FolioGrant = { principal: string; role: DocRole; granted_at?: string };
151
152/** Grants by folio id. */
153export type FolioGrants = ReadonlyMap<string, readonly FolioGrant[]>;
154
155/** The deepest a chain is followed: the tree's depth cap, with room. */
156const MAX_CHAIN = 32;
157
158/** The keys a person's grants can name: `user:<id>` and each `team:<slug>`. */
159export function personKeys(person: Person): string[] {
160 return [`user:${person.user_id}`, ...[...person.teams].map((t) => `team:${t.toLowerCase()}`)];
161}
162
163/** Where a folio's access comes from: itself when restricted or at the top, else its parent's access root. */
164export function aclRootOf(node: { id: string; inherit: boolean; parent_id: string | null }, parentAclRoot: string | null): string {
165 return !node.inherit || !node.parent_id || !parentAclRoot ? node.id : parentAclRoot;
166}
167
168/** A folio's path: `/<top id>/…/<id>/`. */
169export function folioPathOf(id: string, parentPath: string | null): string {
170 return parentPath ? `${parentPath}${id}/` : `/${id}/`;
171}
172
173/**
174 * The chain access is read along: the folio, then each ancestor it
175 * inherits from, up to and including its access root. A missing parent
176 * ends the chain there.
177 */
178export function aclChain(id: string, byId: ReadonlyMap<string, FolioAclNode>): FolioAclNode[] {
179 const out: FolioAclNode[] = [];
180 let at = byId.get(id);
181 const seen = new Set<string>();
182 while (at && !seen.has(at.id) && out.length < MAX_CHAIN) {
183 out.push(at);
184 seen.add(at.id);
185 if (!at.inherit || !at.parent_id) break;
186 at = byId.get(at.parent_id);
187 }
188 return out;
189}
190
191/** Whether a chain's access root takes its space's access: at the top of a space, not restricted. */
192export function inheritsSpace(chain: readonly FolioAclNode[]): boolean {
193 const root = chain[chain.length - 1];
194 return !!root && !!root.space_id && root.inherit && !root.parent_id;
195}
196
197/** General access never gives `manage`. */
198export function generalRoleCap(role: DocRole | null | undefined): DocRole {
199 if (!role) return "view";
200 return role === "manage" ? "edit" : role;
201}
202
203/** One principal's explicit access to a folio: its role, whose grant or ownership it is, and since when. */
204export type FolioAccessEntry = { role: DocRole; via: string; since: string };
205
206/**
207 * Explicit access along a chain: the folio's owner (`via` "owner"), the
208 * owners of the ancestors it inherits from, and every grant up to the
209 * access root; the highest role per principal, the nearest on a tie.
210 */
211export function explicitAccess(chain: readonly FolioAclNode[], grants: FolioGrants): Map<string, FolioAccessEntry> {
212 const out = new Map<string, FolioAccessEntry>();
213 const put = (principal: string, role: DocRole, via: string, since: string) => {
214 const was = out.get(principal);
215 if (!was || RANK[role] > RANK[was.role]) out.set(principal, { role, via, since });
216 };
217 chain.forEach((node, i) => {
218 put(node.owner, "manage", i === 0 ? "owner" : node.id, node.created_at ?? "");
219 for (const g of grants.get(node.id) ?? []) put(g.principal, g.role, node.id, g.granted_at ?? "");
220 });
221 return out;
222}
223
224/**
225 * A person's role on a folio, or null when they can't read it. `chain` is
226 * `aclChain`'s; `spaceRole` is their role in the folio's space (`roleOf`),
227 * used only when the chain inherits it; `visited` says they opened the
228 * folio's link (or its access root's).
229 */
230export function effectiveRole(chain: readonly FolioAclNode[], grants: FolioGrants, spaceRole: DocRole | null, person: Person, options: { visited?: boolean } = {}): DocRole | null {
231 const root = chain[chain.length - 1];
232 if (!root) return null;
233 const keys = new Set(personKeys(person));
234 let role: DocRole | null = null;
235 for (const [principal, entry] of explicitAccess(chain, grants)) if (keys.has(principal)) role = higher(role, entry.role);
236 if (inheritsSpace(chain)) role = higher(role, spaceRole);
237 if (root.general_access === "workspace") role = higher(role, generalRoleCap(root.general_role));
238 if (root.general_access === "link" && options.visited) role = higher(role, generalRoleCap(root.general_role));
239 return role;
240}
241
242/** The lock: only the folio's owner can read it (no other owners or grants, no general access, no space it inherits). */
243export function isPrivateFolio(chain: readonly FolioAclNode[], grants: FolioGrants): boolean {
244 const root = chain[chain.length - 1];
245 const self = chain[0];
246 if (!root || !self) return true;
247 if (root.general_access !== "none" || inheritsSpace(chain)) return false;
248 for (const principal of explicitAccess(chain, grants).keys()) if (principal !== self.owner) return false;
249 return true;
250}
251
252/**
253 * Whether "everyone in the workspace" can read a folio: a public
254 * channel's audience. Only through an open space it inherits (with a base
255 * role) or general access `workspace`; never a link, grants or Private.
256 */
257export function folioReadableByWorkspace(chain: readonly FolioAclNode[], space: SpaceRules | null): boolean {
258 const root = chain[chain.length - 1];
259 if (!root) return false;
260 if (root.general_access === "workspace") return true;
261 return inheritsSpace(chain) && !!space && readableByWorkspace(space);
262}
263
264/** Whether every one of `people` can read a folio. `visited` says whether a person opened its link. */
265export function folioReadableByAll(
266 chain: readonly FolioAclNode[],
267 grants: FolioGrants,
268 space: SpaceRules | null,
269 people: readonly Person[],
270 visited: (person: Person) => boolean = () => false,
271): boolean {
272 return people.every((person) => atLeast(effectiveRole(chain, grants, space ? roleOf(space, person) : null, person, { visited: visited(person) }), "view"));
273}
274
275/**
276 * The index scope a folio's passages are filed under: its space's when
277 * its access is exactly the space's (inherits to the top, nothing shared
278 * beyond its owners, no general access); otherwise its access root's.
279 */
280export function folioScope(chain: readonly FolioAclNode[], grants: FolioGrants): string {
281 const root = chain[chain.length - 1];
282 if (!root) return "folio:unknown";
283 if (inheritsSpace(chain) && root.general_access === "none") {
284 const owners = new Set(chain.map((n) => n.owner));
285 const shared = [...explicitAccess(chain, grants).keys()].some((p) => !owners.has(p));
286 if (!shared) return `space:${root.space_id}`;
287 }
288 return `folio:${root.id}`;
289}
290
The docs service answers every artifacts call: docs can be made, listed, shared, moved, trashed, restored, searched, versioned and edited live in their own rooms, agents read, write and recall them only where their person and everyone in the conversation can, and folio events go out on the bus, while Docs' pages keep working as before.291export type FolioAccessRecord = { folio_id: string; principal: string; role: DocRole; via: string; since: string };
The docs service has tables for artifacts beside Docs' pages, and one tested rule for who can read and change an artifact: its owner, shares on it or above it, its space, and everyone in the workspace or with the link.292
293/** The rows of `folio_access` for these folios: everything `explicitAccess` finds along each one's chain. */
The docs service answers every artifacts call: docs can be made, listed, shared, moved, trashed, restored, searched, versioned and edited live in their own rooms, agents read, write and recall them only where their person and everyone in the conversation can, and folio events go out on the bus, while Docs' pages keep working as before.294export function materialize(ids: readonly string[], byId: ReadonlyMap<string, FolioAclNode>, grants: FolioGrants): FolioAccessRecord[] {
295 const out: FolioAccessRecord[] = [];
The docs service has tables for artifacts beside Docs' pages, and one tested rule for who can read and change an artifact: its owner, shares on it or above it, its space, and everyone in the workspace or with the link.296 for (const id of ids) {
297 const chain = aclChain(id, byId);
298 if (!chain.length) continue;
299 for (const [principal, entry] of explicitAccess(chain, grants)) out.push({ folio_id: id, principal, role: entry.role, via: entry.via, since: entry.since });
300 }
301 return out;
302}
303
304/** Whether `role` may change who a folio is shared with: `manage`, or `edit` where editors may share. */
305export function canShare(role: DocRole | null, editorsCanShare = false): boolean {
306 return atLeast(role, "manage") || (editorsCanShare && atLeast(role, "edit"));
307}
308
309/**
310 * An agent's role on a folio: never more than its asker's, and nothing
311 * when someone it is talking to can't read the folio. The agent's own
312 * grants never widen this; they make it a participant, nothing more.
313 */
314export function agentFolioRole(askerRole: DocRole | null, audienceCanRead: boolean): DocRole | null {
315 return audienceCanRead ? askerRole : null;
316}