Skip to content
820 linesCodeBlameRaw
1# Artifacts mode: plan
2
3Status: plan, 2026-10-09. This replaces Docs mode (docs/WORKSPACE.md, "Docs") with **Artifacts**: one mode for four kinds of collaborative work: **Docs, Slides, Design and Dashboards**. Each has private, shared, space and workspace access, and each is edited live by people and agents together.
4
5This document is about the product mode. `docs/ARTIFACTS.md` is something else: the research on the Cloudflare Artifacts git store. The two must not share code names (see section 0).
6
7---
8
9## 0. Naming: keep "Artifacts" the mode apart from Artifacts the git store
10
11The word "artifact" already has three meanings in this repository:
12
13| Meaning | Where |
14|---|---|
15| The Cloudflare Artifacts git store | `services/repos` binding `ARTIFACTS`, `ArtifactsStore`, `ARTIFACTS_NAMESPACES`, docs/ARTIFACTS.md |
16| Workflow run artifacts | `apps/api/src/artifacts.rs` (`ArtifactsOp`), `crates/contracts` `actions::Artifact`, `apps/web/app/lib/artifacts.ts` (`formatBytes`, `expiresIn`), the `workflow` MCP tool's `*_artifact` actions, REST `/repos/:o/:n/actions/artifacts` |
17| **The new mode** | this plan |
18
19**Decision: user-facing words say "artifact", and code says "folio".**
20
21- **Users see "artifact".** That covers UI text, `apps/docs`, URLs (`/<ws>/-/artifacts/...`), the MCP tool name (`artifact`), REST paths (`/workspaces/{ws}/artifacts`), scope names (`artifacts:read`, `artifacts:write`, `artifacts:admin`) and the agents' tool names (`search_artifacts`, ...).
22- **Code identifiers say "folio".** A folio is one artifact of any kind:
23- tables `folios`, `folio_grants`, ...
24- types `Folio`, `FolioKind`, `FolioRole`
25- the Durable Object `FolioRoom`
26- events `folio.created`, ...
27- files `packages/contracts/src/folios.ts`, `crates/contracts/src/folios.rs`, `apps/api/src/folios.rs` (`FoliosOp`, next to the untouched `artifacts.rs`), `apps/web/app/components/folios/*`, `apps/web/app/routes/workspace/folios/*`, `apps/web/app/lib/folios.ts`
28- "folio" is not used anywhere in the repository today, so grep stays clean.
29- **The service and its infrastructure keep their names:** `services/docs`, Worker `g1t-docs-service`, D1 `g1t-docs`, R2 `g1t-docs-files`, binding `DOCS`. Renaming them would force new resources for every installation, including self-hosters, and buys nothing. The service header comment and docs/SELF_HOSTING.md say "the docs service hosts folios (Artifacts mode)". The semantic index is new (`g1t-folios`, section 5) because its metadata changes.
30- The kind of the old Docs pages is `doc` (`FolioKind = "doc" | "slides" | "design" | "dashboard"`). In UI text the tile is "Docs" and a single item is "a doc".
31- **Rule for reviewers:** a new identifier containing `artifact` outside the git-store and workflow code is a bug.
32
33---
34
35## 1. What exists today (the base we build on)
36
37- **`services/docs`** (TypeScript Worker):
38- D1 tables: `spaces` (kind workspace/team/private, default_role, agent_mode), `space_members`, `pages` (tree in `parent_id` + `position`, `markdown` rendition), `page_versions` (Yjs state + Markdown), `suggestions`, `templates`, `files`, `pages_fts`, `citations`, `page_changes`, `repo_spaces`, `repo_files`, `doc_chunks(+_fts)`, `doc_embed_usage`, `doc_index_runs`. Migrations are 0001 to 0003.
39- `src/room.ts`: `PageRoom` is a Durable Object that owns one Yjs document. It speaks y-protocols sync and awareness over hibernating WebSockets, enforces the socket's role, saves through `persist.ts` four seconds after edits and indexes through `indexer.ts` thirty seconds after.
40- `src/access.ts`: pure role rules (`RANK`, `roleOf`, `readableByAll`, `readableByWorkspace`, `agentAbilities`).
41- Recall: `recallForAgent` uses Vectorize `g1t-docs` with a `space_id` `$in` filter, then re-checks against D1. Hybrid search uses RRF.
42- `src/index.ts`: about 2,750 lines with a `/rpc/<method>` switch.
43- **Web:**
44- Routes: `apps/web/app/routes/workspace/docs/*`, registered in `apps/web/app/routes.ts` under `-/docs`.
45- Components: `apps/web/app/components/docs/*` (BlockNote `editor.tsx`, custom `blocks.tsx`, the generic Yjs socket client `provider.ts`, `sidebar.tsx` with a drag tree, `page-parts.tsx` with history and suggestions).
46- The mode is wired through `ModeKey "docs"` in `apps/web/app/lib/workspace-nav.ts`, the rail in `components/rail.tsx`, `sidebarFor` and `MODE_MENU` in `components/shell.tsx`, and the phone tab bar in `components/mobile.tsx`.
47- **Agents:**
48- `services/agents/src/tools.ts` has `DOCS_TOOLS` and `DOCS_WRITE_TOOLS` (`search_docs`, `read_page`, `edit_page`, `create_page`, `stale_pages`, `list_doc_spaces`).
49- `services/agents/src/ports.ts` holds `docsPorts`.
50- "Write this up" is in `components/chat/write-up.tsx` and `lib/write-up.ts`.
51- **External MCP:** `apps/api/src/tools.rs` defines a few resource tools with an `action` field, and the scopes are in `crates/contracts/src/scopes.rs`. There is **no docs tool and no docs scope today**, and apps/api has no `DOCS` binding.
52- **Events:** `doc.page.*` (`packages/contracts/src/events.ts`, `crates/contracts/src/events.rs` `DOC_PAGE_EVENTS`, `subscribers.rs`).
53- **Libraries already present:** BlockNote 0.55, yjs, y-protocols, y-prosemirror, lib0, mermaid, katex, shiki, radix-ui, cmdk, lucide.
54- There is **no chart library**. Charts are hand-rolled SVG (`components/usage.tsx` `UsageChart`, `Sparkline`, `AllowanceRing`; `routes/repo/contributors.tsx`).
55- Hover hints are `components/ui/hint.tsx`, on the shadcn Tooltip.
56- Relative time is `TimeAgo` in `components/ui/index.tsx`, and the viewer's zone is `lib/time-zone.ts`.
57- The people picker is `components/people-picker.tsx`. Tabs are `ui/tab-strip.tsx` and `ui/tabs.tsx`.
58
59---
60
61## 2. Data model
62
63### 2.1 Concepts
64
65- **Folio** (shown as an artifact): one item with a `kind`. Every folio has:
66- one **owner**, always a person. When an agent makes one, the owner is the person it acts for.
67- a **location**: a **space**, or none, which means it is in the owner's **Private** section.
68- an optional **parent**. Only a `doc` can have children, so a doc is the page that holds sub-pages. Slides, Design and Dashboards are leaves. There is **no folder kind in v1**, because a doc with children is the folder.
69- **Space** (a teamspace) keeps the existing `spaces` table and roles:
70- `workspace`: shown as "Open". Every member gets `default_role`. Members **join** it to see it in their sidebar.
71- `team`: the team's members get `default_role`.
72- `private`: shown as "**Members only**", so it can't be confused with the Private section. Only listed members.
73- **Roles** stay as they are: `view < comment < edit < manage` (`RANK` in access.ts). A folio's owner always has `manage`.
74- **A person's effective role on a folio** is the highest of:
751. owner → `manage`;
762. explicit **grants** on the folio or on an ancestor it inherits from, given to a `user:`, `agent:` or `team:` key;
773. **space access**, when the folio inherits (`inherit = 1`) up to a folio in a space: the person's space role (`roleOf`);
784. **general access** of the folio's access root:
79 - `workspace`: every member gets `general_role`;
80 - `link`: a member who has opened the link gets `general_role`.
81
82Workspace owners get `manage` on folios in open and team spaces, as today. They get nothing on someone's Private folio or on a Members-only space.
83- **Restricting.** A doc's child, or a folio in a space, can set `inherit = 0` ("Only people invited"). It then becomes its own **access root**: space access and the parent's grants stop at it.
84- **"Private" (the lock icon)** is computed, not stored. It means the effective readers are only the owner: no grants, `general_access = 'none'`, and either no space or `inherit = 0`.
85- **Agents.** An agent's role on a folio is never higher than its asker's. Its grants (`agent:<id>`) make it a participant for notifications and @-mentions. They never widen what it can read for someone who can't read the folio (section 4.3).
86- **Link access.** "Anyone in the workspace with the link" is not discoverable: it is not in lists or search, and not in recall for a workspace audience. It becomes readable for a person once they open the link, which records a row in `folio_visits`. A **public link** (people outside the workspace) is deferred to Phase 8 and off by default per workspace.
87- **Favorites, recent, trash, versions and templates** are per folio and work for every kind.
88
89### 2.2 D1 schema: `services/docs/migrations/0004_folios.sql`
90
91This migration only adds tables. Old tables stay until Phase 7.
92
93```sql
94CREATE TABLE folios (
95id TEXT PRIMARY KEY, -- fol_…
96workspace_id TEXT NOT NULL,
97kind TEXT NOT NULL CHECK (kind IN ('doc','slides','design','dashboard')),
98title TEXT NOT NULL DEFAULT '',
99icon TEXT, cover TEXT,
100owner TEXT NOT NULL, -- user:<id>
101space_id TEXT REFERENCES spaces (id), -- NULL: owner's Private
102parent_id TEXT REFERENCES folios (id), -- only a doc may be a parent
103position REAL NOT NULL,
104inherit INTEGER NOT NULL DEFAULT 1, -- from parent, or from the space at the top
105acl_root TEXT NOT NULL, -- nearest self-or-ancestor with inherit=0 or no parent (denormalized)
106path TEXT NOT NULL, -- '/<root id>/…/<id>/' for subtree updates
107general_access TEXT NOT NULL DEFAULT 'none' CHECK (general_access IN ('none','workspace','link')),
108general_role TEXT CHECK (general_role IN ('view','comment','edit')),
109agent_mode TEXT CHECK (agent_mode IN ('suggest','edit')), -- NULL: the space's, or 'suggest' in Private
110text TEXT NOT NULL DEFAULT '', -- derived rendition (§3): search, recall, read view, export
111excerpt TEXT NOT NULL DEFAULT '',
112preview TEXT, -- small JSON for cards (§6.2), never data values
113source TEXT, -- e.g. the chat thread link it was written up from
114mentioned TEXT NOT NULL DEFAULT '[]',
115created_by TEXT NOT NULL, -- user:/agent:
116created_at TEXT NOT NULL,
117updated_by TEXT, updated_at TEXT NOT NULL, -- any change (rename, move, share)
118edited_by TEXT, edited_at TEXT NOT NULL, -- content changes: "Edited 45m ago"
119trashed_at TEXT, trashed_by TEXT
120);
121CREATE INDEX folios_tree ON folios (workspace_id, space_id, parent_id, position);
122CREATE INDEX folios_owner ON folios (owner, edited_at) WHERE trashed_at IS NULL;
123CREATE INDEX folios_recent ON folios (workspace_id, edited_at) WHERE trashed_at IS NULL;
124CREATE INDEX folios_root ON folios (acl_root);
125CREATE INDEX folios_path ON folios (path);
126
127CREATE TABLE folio_grants ( -- explicit shares, as set
128folio_id TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE,
129principal TEXT NOT NULL, -- user:/agent:/team:
130role TEXT NOT NULL CHECK (role IN ('view','comment','edit','manage')),
131granted_by TEXT NOT NULL, granted_at TEXT NOT NULL,
132PRIMARY KEY (folio_id, principal)
133);
134-- Effective explicit access, materialized per folio for list/search SQL:
135-- the owner and every grant from the folio up to its acl_root, highest role.
136-- Rebuilt for a subtree (by `path`) on grant, move, restrict, ownership change.
137CREATE TABLE folio_access (
138folio_id TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE,
139principal TEXT NOT NULL,
140role TEXT NOT NULL,
141via TEXT NOT NULL, -- the folio whose grant this is (self or ancestor), or 'owner'
142since TEXT NOT NULL, -- for "Shared with you" ordering
143PRIMARY KEY (folio_id, principal)
144);
145CREATE INDEX folio_access_principal ON folio_access (principal, since);
146
147CREATE TABLE folio_visits ( -- recent, and link access once opened
148folio_id TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE,
149user_id TEXT NOT NULL,
150first_at TEXT NOT NULL, last_at TEXT NOT NULL,
151PRIMARY KEY (folio_id, user_id)
152);
153CREATE INDEX folio_visits_user ON folio_visits (user_id, last_at);
154
155CREATE TABLE folio_favorites (user_id TEXT NOT NULL, folio_id TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE,
156position REAL NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (user_id, folio_id));
157
158CREATE TABLE space_joins ( -- open spaces a member shows in their sidebar
159space_id TEXT NOT NULL REFERENCES spaces (id) ON DELETE CASCADE, user_id TEXT NOT NULL,
160position REAL NOT NULL, joined_at TEXT NOT NULL, PRIMARY KEY (space_id, user_id));
161
162CREATE TABLE folio_versions (
163id TEXT PRIMARY KEY, folio_id TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE,
164created_at TEXT NOT NULL,
165kind TEXT NOT NULL CHECK (kind IN ('created','edit','agent','suggestion','proposal','restore')),
166authors TEXT NOT NULL DEFAULT '[]', note TEXT,
167text TEXT NOT NULL,
168state BLOB, -- Yjs state when ≤ 1.5 MB
169state_key TEXT -- else in the file store (design decks with many nodes)
170);
171CREATE INDEX folio_versions_folio ON folio_versions (folio_id, created_at);
172
173-- Doc kind: inline tracked changes, as `suggestions` today.
174CREATE TABLE folio_suggestions ( …same columns as suggestions, page_id → folio_id, marks_current… );
175-- Other kinds: an agent's whole change as a Yjs update a person previews and applies.
176CREATE TABLE folio_proposals (
177id TEXT PRIMARY KEY, folio_id TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE,
178author TEXT NOT NULL, asked_by TEXT, note TEXT,
179base_vector BLOB NOT NULL, update_blob BLOB, update_key TEXT,
180summary TEXT NOT NULL, -- "Adds slides 4–6; rewrites the title slide"
181status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','accepted','rejected','stale')),
182created_at TEXT NOT NULL, decided_by TEXT, decided_at TEXT);
183
184CREATE TABLE folio_templates (
185id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL,
186kind TEXT NOT NULL CHECK (kind IN ('doc','slides','design','dashboard')),
187name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', icon TEXT,
188body TEXT NOT NULL, -- Markdown (doc, slides) or JSON spec (design, dashboard)
189created_by TEXT NOT NULL, created_at TEXT NOT NULL);
190CREATE INDEX folio_templates_ws ON folio_templates (workspace_id, kind);
191
192CREATE TABLE folio_files ( …as files, page_id → folio_id… );
193CREATE TABLE folio_links (from_folio TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE, to_folio TEXT NOT NULL, PRIMARY KEY (from_folio, to_folio));
194CREATE INDEX folio_links_to ON folio_links (to_folio);
195CREATE TABLE folio_projects (folio_id …, repo TEXT, PRIMARY KEY (folio_id, repo));
196CREATE TABLE folio_citations ( …as citations, page_id → folio_id… );
197CREATE TABLE folio_changes ( …as page_changes, page_id → folio_id… );
198
199CREATE VIRTUAL TABLE folios_fts USING fts5 (folio_id UNINDEXED, kind UNINDEXED, title, body, tokenize = 'unicode61 remove_diacritics 2');
200
201-- The index generalizes: doc_chunks keeps its shape for repo files; folio passages get their own table.
202CREATE TABLE folio_chunks (
203id TEXT PRIMARY KEY, -- <folio id>:<seq>
204workspace_id TEXT NOT NULL, folio_id TEXT NOT NULL, kind TEXT NOT NULL,
205scope TEXT NOT NULL, -- 'space:<id>' when the folio's access is its space's; else 'folio:<acl_root>'
206seq INTEGER NOT NULL, heading TEXT, text TEXT NOT NULL, hash TEXT NOT NULL, vector_hash TEXT, updated_at TEXT NOT NULL);
207CREATE VIRTUAL TABLE folio_chunks_fts USING fts5 (chunk_id UNINDEXED, scope UNINDEXED, folio_id UNINDEXED, heading, text, tokenize = 'unicode61 remove_diacritics 2');
208```
209
210`repo_spaces`, `repo_files`, `repo_files_fts`, the `doc_chunks` rows for repository files, `doc_embed_usage` and `doc_index_runs` are kept: projects' docs stay a read-only source in Artifacts. In Phase 7, migration `0005_drop_pages.sql` drops `pages`, `page_*`, `favorites`, `suggestions`, `templates`, `files`, `pages_fts`, `citations`, `page_changes` and the page rows of `doc_chunks`. It runs only after no deployed code reads them.
211
212### 2.3 Access code
213
214- **`services/docs/src/access.ts` (pure, tested)** gains:
215- `effectiveRole(folio, chain, grantsByFolio, spaceRole, person)`
216- `isPrivate(...)`
217- `aclRootOf(...)`
218- `materialize(subtree) → folio_access rows`
219- `readableByAllFolio(...)` for agent audiences
220- `generalRoleCap` (general access never gives `manage`)
221- `canShare(role)`: `manage`, or `edit` when the space allows editors to share. The default is `manage` only.
222- **Listing SQL ("All").** A folio is listed when any of these holds:
2231. a `folio_access` row matches one of the viewer's keys (`user:<id>` and `team:<slug>…`);
2242. its `space_id` is one of the viewer's readable spaces, `inherit = 1` and `acl_root` is the top of the space;
2253. its access root has `general_access = 'workspace'`;
2264. its root has `general_access = 'link'` and the viewer has a visit row.
227
228Results are paged by `(edited_at, id)`. Each page of rows is re-checked with `effectiveRole` before it is returned (defence in depth).
229- **Re-materializing.** Subtree changes are bounded at 2,000 folios inline; bigger ones go to a `folios.reacl` queue job on `JOBS`. Open rooms in the subtree get `setRole` so their sockets follow the change (as today).
230
231### 2.4 Contracts
232
233**`packages/contracts/src/folios.ts`** (new). Wire shapes are snake_case:
234
235- `FolioKind`, `FOLIO_KINDS`, `FOLIO_KIND_LABELS` (`Docs`, `Slides`, `Design`, `Dashboard`), `FolioRole` (an alias of `DocRole`, kept so access code is shared), `FolioGeneralAccess`.
236- `FolioRef { id, kind, title, icon, slug, path }`, where `path` is `/<ws>/-/artifacts/<title-slug>-<id>`.
237- `Folio`: `FolioRef` plus `space`, `parent_id`, `owner: MemberProfile`, `created_by`, `edited_by`, `edited_at`, `trashed_at`, `viewer_role`, `favorite`, `private`, `shared_count`, `general_access`, `general_role`, `inherited_from: { kind: "space" | "folio"; id; name } | null`, `excerpt`, `preview`, `stale`, `has_children`.
238- `FolioListQuery { tab: "all" | "yours" | "shared"; kinds?; space_id?; owner?; project?; q?; cursor?; limit? }` and `FolioList { items; next_cursor }`.
239- `FoliosSidebar { favorites; spaces: (DocSpace & { tree })[]; private_tree; shared; repos }`.
240- `NewFolio { kind; title?; space_id?; parent_id?; template_id?; content?: FolioContentInput; share_with?: { principal; role }[] }`.
241- `FolioAccessList` (for the share dialog: rows with their source), `FolioAccessChange`.
242- `FolioVersion`, `FolioProposal`, `FolioTemplate`.
243- `FoliosLiveEvent`, generalizing `DocsLiveEvent`.
244- `FolioPassage { folio: FolioRef | null; repo_file; space_name; heading; text; score; updated_at; stale }`.
245- `FolioAgentEdit`, discriminated by kind:
246- `doc`: `{ target: DocEditTarget; markdown }`
247- `slides`: `{ ops: SlidesOp[] }`
248- `design`: `{ ops: DesignOp[] }`
249- `dashboard`: `{ ops: DashboardOp[] }`
250- `foliosClient(binding)` with the RPC names in section 7.
251
252Each kind's own types live in its own file so parallel work doesn't collide: `folios-slides.ts`, `folios-design.ts`, `folios-dashboard.ts`.
253
254**`packages/contracts/src/datasets.ts`** (new, mirrored in **`crates/contracts/src/datasets.rs`**) holds the dashboard query layer (section 3.4).
255
256**`crates/contracts/src/folios.rs`** holds the subset the Rust API needs (`Folio`, `NewFolio`, `FolioAgentEdit` as `serde_json::Value`, the args structs) and `FOLIO_EVENTS`.
257
258**Events** (`events.ts`, `events.rs`, `subscribers.rs`): `folio.created`, `folio.updated` (a version: kind and authors), `folio.trashed`, `folio.restored`, `folio.shared` (who was granted, never content), `folio.stale`. They carry `{ workspaceId, folioId, kind, spaceId | null }` and no title for private folios. They never carry a `repoId`. `doc.page.*` keeps being published by the old code paths until Phase 7.
259
260---
261
262## 3. Each kind: content model and editor
263
264**One room for every kind.** `src/room.ts` becomes `FolioRoom`, which keeps the same socket protocol, hibernation, roles, save alarm and index alarm, with a **kind adapter**:
265
266```ts
267// services/docs/src/kinds/types.ts
268export interface KindModel {
269kind: FolioKind;
270seed(doc: Y.Doc, init: { text?: string; spec?: unknown }): void; // template / agent / blank
271render(doc: Y.Doc): { text: string; outline: unknown; mentions: string[]; links: string[]; citations: DocCitation[]; preview: unknown };
272chunks(text: string, title: string): Passage[]; // §5
273applyAgentEdit(doc: Y.Doc, edit: unknown, origin: Origin): { applied: boolean; summary: string };
274restore(doc: Y.Doc, old: Y.Doc, origin: Origin): void;
275validate(update: Y.Doc): string | null; // caps: node counts, sizes
276}
277// services/docs/src/kinds/index.ts: { doc, slides, design, dashboard } registry (one line per kind)
278```
279
280`FolioRoom` stores `kind` in `meta` and dispatches to the adapter. `persist.ts` becomes `saveFolio(env, { folio_id, text, preview, ... })`. Wrangler gets a DO migration `{ "tag": "v2", "new_sqlite_classes": ["FolioRoom"] }`. `PageRoom` stays exported until Phase 7 (`deleted_classes`).
281
282The web uses one socket client, `components/docs/provider.ts` moved to `components/folios/provider.ts` (`FolioProvider`), at `/<ws>/-/artifacts/live?folio=<id>`. Each kind's editor is registered in `components/folios/kinds.tsx` with `{ icon, label, Editor, Viewer, Thumbnail, mobileEditable }`.
283
284### 3.1 Docs (`kind: "doc"`)
285
286- **Content:** unchanged. BlockNote on `XmlFragment("document-store")`, comments in the `threads` map. `blocks.ts`, `markdown.ts`, `edits.ts`, `threads.ts` and `citations.ts` move under `src/kinds/doc/` without behaviour changes.
287- **Editor:** `components/docs/editor.tsx`, `blocks.tsx` and `page-parts.tsx` move to `components/folios/doc/`. Children, backlinks, citations, staleness, suggestions and history all stay.
288- **New:** sub-page blocks can be any kind (an embedded card linking to a child slides deck or dashboard). This adds a `folio` embed block, generalizing the page card.
289
290### 3.2 Slides (`kind: "slides"`)
291
292**Yjs model.**
293- `Y.Map("deck")`: `{ theme, aspect: "16:9" | "4:3", accent }`.
294- `Y.Array("slides")` of `Y.Map`: `{ id, layout, background, hidden, transition: "none" }`.
295- Layouts: `title`, `title-body`, `two-column`, `section`, `image`, `quote`, `big-number`, `blank`.
296- Each slide's regions are root `XmlFragment`s named `slide:<id>:<region>` (`title`, `body`, `left`, `right`, `notes`).
297- So every region is a **BlockNote editor bound to its own fragment**: BlockNote's collaboration option takes a `fragment`. Mentions, code, math and mermaid come for free.
298- Each region gets a narrower schema. For example, a title region allows a single heading and inline content only.
299
300**Markdown form,** used by agents, templates, export and the rendition:
301
302```markdown
303<!-- slide: title -->
304# Q4 roadmap
305Subtitle
306
307---
308<!-- slide: two-column -->
309## Pricing
310::: left
311- …
312::: right
313- …
314Note: speaker notes here
315```
316
317**`src/kinds/slides.ts`** parses and serializes this form, reusing `blocks.ts` `seed` per region. The text rendition is the deck in this form.
318
319**Editor:** `components/folios/slides/`.
320- A filmstrip of thumbnails on the left, drag to reorder. Thumbnails are a read-only render of each slide's regions. Only the current slide mounts live editors, which keeps BlockNote instances to 1–5.
321- A stage at 16:9 that scales to fit, a layout picker, a notes pane and a theme menu.
322
323**Present mode.**
324- Fullscreen API on the stage, inside the same route (`?present=1`). Arrow keys, space, Esc, `B` for black.
325- A presenter window (`?present=presenter`) shows notes, the next slide and a timer, kept in sync over `BroadcastChannel`.
326- "Follow the presenter": the presenter's current slide goes out in **awareness**, and viewers who opt in follow it. No new infrastructure.
327
328**Export.** Markdown now. "Print / Save as PDF" through a print stylesheet (`@page` landscape, one slide per page), which works self-hosted with no service. A server-rendered PDF through the og service's `Screenshots` entrypoint (Browser Rendering), behind a `Renderer` adapter, comes in Phase 8.
329
330**Agent ops (`SlidesOp`):**
331- `replace_deck { markdown }`
332- `insert_slides { after_slide_id | null, markdown }`
333- `replace_slide { slide_id, markdown }`
334- `delete_slides { slide_ids }`
335- `move_slide { slide_id, after_slide_id }`
336- `set_notes { slide_id, markdown }`
337- `set_theme { theme, accent }`
338
339### 3.3 Design (`kind: "design"`): what is realistic
340
341**Scope.** A frame-based layout canvas for mockups, screens, diagrams and one-pagers, which agents can produce structurally. It is not a full vector tool.
342
343**Yjs model.**
344- `Y.Map("canvas")`: `{ background, grid }`.
345- `Y.Map("nodes")`: `id → Y.Map`.
346- Node types: `frame`, `rect`, `ellipse`, `line`, `arrow` (with node-bound endpoints), `text` (with `Y.Text` content), `image` (a file key, never inline bytes), `html`, `component` (a definition frame) and `instance` (a `component_id` plus `overrides`).
347- Common fields: `parent`, `index` (a fractional-index string), `x`, `y`, `w`, `h`, `rotation`, `fill`, `stroke`, `stroke_width`, `radius`, `opacity`, `name`, `locked`, `hidden`.
348- Frames have `layout: "free" | "row" | "column"` with `gap`, `padding`, `align`. This is simple stack layout computed at render, so **agents don't compute coordinates**.
349- `html` nodes hold `Y.Text` with agent- or person-authored HTML and CSS. They render in a sandboxed iframe (`sandbox=""`, no scripts) served from the **usercontent origin**, sanitized server-side on save. This is the most agent-friendly way to get rich, realistic designs.
350- Comments pin to `{ node_id, x, y }` in the `threads` map.
351
352**Renderer and editor:** `components/folios/design/`.
353- React with SVG for shapes and positioned HTML for text.
354- Pointer tools: select, frame, rect, ellipse, line, arrow, text, image (upload through `folio_files`) and hand.
355- Pan and zoom, marquee and multi-select, snapping to frame edges and centres, keyboard nudge, and a layers panel with a properties panel.
356- Make component, and instances with overrides.
357- Other people's cursors and selections through awareness.
358- **Export:** a frame as SVG by serializing the DOM, and as PNG through a client canvas.
359
360**Library decision: build it on SVG; add no canvas dependency.**
361- Mature whiteboard libraries either carry licence terms unsuitable for a self-hostable MIT product (a production key or a watermark), or impose a hand-drawn whiteboard look and their own scene model that fights Yjs.
362- Canvas-2D libraries make text editing and accessibility harder.
363- An SVG scene graph on Yjs maps fits the stack: `yjs` is already in, and a small `fractional-indexing` helper is about 40 lines.
364- An MIT whiteboard library could later back a separate "Whiteboard" kind if one is wanted.
365
366**Caps** (enforced in `validate`): 5,000 nodes, 200 KB per `html` node, 25 MB total files per folio.
367
368**Agent ops (`DesignOp`):**
369- `upsert_nodes { nodes: NodeSpec[] }`: a nested spec, where children imply `parent` and `index`
370- `delete_nodes { ids }`
371- `replace_frame { frame_id, spec }`
372- `set_html { node_id, html }`
373- `move { ids, dx, dy }`
374
375**Text rendition:** frame names, then text layers in reading order, then the text content of `html` nodes, as headings per frame.
376
377### 3.4 Dashboards (`kind: "dashboard"`, beta)
378
379**Yjs model.**
380- `Y.Map("dashboard")`: `{ filters: { range: "7d" | "30d" | "90d" | { from, to }, project?, repos?, team? }, refresh: "manual" | "5m" | "1h" }`.
381- `Y.Array("tiles")` of `Y.Map`: `{ id, type, title, description, query: DatasetQuery | null, viz: {...}, grid: { x, y, w, h } }`.
382- Tile types: `stat`, `line`, `area`, `bar`, `stacked_bar`, `table`, `list`, `markdown`.
383- The grid is 12 columns.
384- **Only the definition is in Yjs. Data values never are**, nor in versions, `text`, `preview`, the index or templates.
385
386**The safe query layer** (`packages/contracts/src/datasets.ts`):
387
388```ts
389export type DatasetId = "issues" | "pull_requests" | "workflow_runs" | "deployments" | "spend" | "agent_sessions";
390export type DatasetQuery = {
391dataset: DatasetId;
392measure: { op: "count" | "sum" | "avg" | "p50" | "p95" | "rate"; field?: string };
393group_by?: string; // a declared dimension only
394interval?: "day" | "week" | "month"; // time series
395filters?: { field: string; op: "eq" | "neq" | "in" | "gte" | "lte"; value: string | number | string[] }[];
396range?: "7d" | "30d" | "90d" | { from: string; to: string }; // tile's, else the dashboard's
397limit?: number; // ≤ 100 rows
398};
399export type DatasetResult = { columns: { name: string; type: "string" | "number" | "time" | "money" }[]; rows: (string | number | null)[][]; truncated: boolean; as_of: string };
400export const DATASETS: Record<DatasetId, { service: "work" | "actions" | "deployments" | "billing" | "agents"; dimensions: string[]; measures: string[]; needs: "member" | "billing" }>;
401```
402
403- **Not SQL.** It is a declared catalog of fields, ops and dimensions per dataset. Each **owning service** implements one RPC, `query_dataset(viewer, workspace, query, audience?)`:
404- `services/work` (Rust): issues and pull requests (opened, closed, merged, cycle time, review time, by repo, label, author kind person or agent).
405- `services/actions` (Rust): runs (pass rate, duration, by workflow and repo).
406- `services/deployments` (TS): deployments (frequency, failure rate, time to restore).
407- `services/billing` (Rust): spend by product, project or person. Needs the billing role.
408- `services/agents` (TS): sessions, tasks and spend per agent.
409- Each service **applies its own read rules for the viewer**, using only repositories they can read. It aggregates in its own D1 with a fixed query per measure and dimension, and caps the rows. Validation of `DatasetQuery` against the catalog is shared code in contracts, run in both TS and Rust.
410- **Execution path:** browser → `/-/artifacts/query?folio=&tile=` → docs service `query_tile`. That checks the viewer can read the folio, reads the tile's query from the room, validates it, fans out to the owning service as the viewer, and caches per `(viewer, query hash)` for the refresh interval behind a `QueryCache` adapter (Cache API on Cloudflare, in-memory or Redis self-hosted).
411- **Two viewers of the same dashboard can see different numbers.** The tile footer says "Based on what you can see" when the viewer's access is partial: the service returns `partial: true` when the viewer cannot read some repositories.
412
413**Charts:** an own SVG chart kit, `components/charts/`, with `LineChart`, `AreaChart`, `BarChart` (stacked and grouped), `StatTile` (with `Sparkline`), `DataTable` and `Legend`.
414- Extract and generalize `UsageChart`, `Sparkline` and `AllowanceRing` from `components/usage.tsx` so usage pages and dashboards share it.
415- No new dependency. It is themed with `@g1t/theme` tokens, follows light and dark, and has accessible tables behind every chart ("View as table").
416- Lazy-loaded with the dashboard editor.
417
418**Editor:** `components/folios/dashboard/`.
419- A grid with pointer drag and resize (no grid library) and a tile inspector.
420- Query builder: dataset, then measure, group, interval and filters, with live preview.
421- A dashboard filter bar, a refresh button showing as-of time, and an auto-refresh interval.
422
423**Phone:** a single column ordered by `(y, x)`, read-only; editing a tile opens a sheet.
424
425**Agent ops (`DashboardOp`):**
426- `upsert_tile { tile }`
427- `delete_tiles { ids }`
428- `set_filters { filters }`
429- `set_layout { tiles: { id, grid }[] }`
430
431Agents read results through the agent tool `query_data` (section 4), never from the folio.
432
433**Text rendition:** the title, then each tile's title, description and query described in words ("Pull requests merged per week, by repository, last 90 days"). It never contains values.
434
435**Templates:**
436- "Engineering health": PR cycle time, merged per week, run pass rate, deploy frequency, open issues by label.
437- "Agent spend and output"
438- "Workspace spend" (billing role)
439- "Delivery" (DORA-style)
440
441`-/insights`, which is "soon" today, can later open the workspace's pinned dashboard.
442
443---
444
445## 4. Agents
446
447### 4.1 Workspace agents (`services/agents/src/tools.ts`, `ports.ts`)
448
449`DOCS_TOOLS` and `DOCS_WRITE_TOOLS` are replaced:
450
451| Tool | What |
452|---|---|
453| `search_artifacts` | `query`, optional `kind`, `space`, `project`. Hybrid search, audience-narrowed. |
454| `read_artifact` | id or link. Returns the kind's agent form: doc → Markdown and block ids (as now); slides → deck Markdown with slide ids; design → node spec JSON (frames, then children); dashboard → spec JSON (tiles and queries). Plus `can: { read, suggest, edit }` and `audience_can_read`. |
455| `create_artifact` | `kind`, `title`, `content` in the kind's agent form or `template`, `where: { space } \| "private" \| "conversation"`, optional `parent`. |
456| `edit_artifact` | `FolioAgentEdit`, plus `note`, `suggest_only`, `marks_current`. Doc → inline suggestion or edit (as now). Other kinds → a direct edit where allowed, else a **proposal** (`folio_proposals`) a person previews and applies. |
457| `list_spaces` | Spaces the asker and audience can all read, with abilities. |
458| `stale_artifacts` | As `stale_pages`. |
459| `query_data` | Runs a `DatasetQuery`, or a dashboard tile by id, **as the asker narrowed to the audience** (section 4.3). |
460| `share_artifact` | Grants `view` or `comment` to people **already in the conversation**, only when the asker has `manage`. It can never set general access or grant `edit` or `manage`. For those, the agent posts a card with a "Share" button the person presses. |
461
462**Where an agent's new artifact lands:**
463- **Owner** is the asker. `created_by` is `agent:<id>`, and the agent gets an `edit` grant so it can keep working.
464- **Location:**
465- the named space, if the asker can edit there;
466- "conversation": Private, plus `view` grants to the DM's or private channel's people;
467- otherwise Private to the asker.
468
469In a public channel the default is the workspace's General space if the asker can edit it, else Private, with the reply handled as in section 4.3.
470
471### 4.2 External MCP and REST (`apps/api`, Rust)
472
473- **Scopes** (`crates/contracts/src/scopes.rs` and the TS mirror `packages/contracts/src/scopes.ts`):
474- `Resource::Artifacts` with `artifacts:read` (list, get, search, versions, query data), `artifacts:write` (create, update, edit, trash, restore, propose) and `artifacts:admin` (share, change general access, delete forever).
475- Descriptions go into the scope table.
476- **Tool:** one MCP tool `artifact` in `apps/api/src/tools.rs`, implemented in `apps/api/src/folios.rs` (`FoliosOp`). Actions:
477- `list` (tab, kind, space)
478- `search`
479- `get` (metadata and the agent form)
480- `create`
481- `update` (title, icon, move)
482- `edit` (`FolioAgentEdit`)
483- `trash`
484- `restore`
485- `versions`
486- `restore_version`
487- `access`
488- `share`
489- `templates`
490- `query_data`
491- `spaces`
492
493Its description must say it is not the `workflow` tool's run artifacts.
494- **REST:**
495- `GET/POST /workspaces/{ws}/artifacts`
496- `GET/PATCH/DELETE /workspaces/{ws}/artifacts/{id}`
497- `GET/PUT /workspaces/{ws}/artifacts/{id}/content`
498- `GET/PUT /workspaces/{ws}/artifacts/{id}/access`
499- `GET /workspaces/{ws}/artifacts/{id}/versions`
500- `POST /workspaces/{ws}/datasets/query`
501- **apps/api** gets a `DOCS` service binding and a small Rust client for the docs service RPC.
502- **OpenAPI** (`apps/api/src/openapi.rs`) and `apps/docs/src/data/openapi.json` are regenerated (the test enforces it). Agent tokens (`AgentScope.operations`) can name the new operations.
503
504### 4.3 Permissions and leak rules (must have tests)
505
5061. **Agent reads.** These use `agentFolios(viewer, audience)`, generalizing `agentSpaces`: **folio-level** effective access for the viewer, intersected with every audience member's.
507 - `audience: workspace` (a public channel) gives only folios readable through an open space or `general_access = 'workspace'`.
508 - Link-only and Private folios never qualify for that audience.
509 - More than 20 people is treated like the workspace audience.
5102. **Agent replies about a folio the audience can't all read.** The tool result carries `audience_can_read: false`. The prompt rule is: "don't quote it here; say you made or found something and that you've sent the link to <asker> directly". The agents service sends the link to the asker as a DM or ephemeral notice.
5113. **Chat link unfurls and cards** resolve per viewer. Someone who can't read the folio sees "An artifact you don't have access to" with no title.
5124. **Mentions inside a folio.** Mentioning someone who can't read it asks the editor "Share with @x?". A notification is sent only to people who can read it. Mentioned agents are notified only through their asker.
5135. **Backlinks, search and FTS** show only folios the reader can read now.
5146. **Dashboard values** are computed per viewer. In an agent turn they run as the asker restricted to repositories every audience member can read (the `repoSpacesForAudience` pattern). `spend` and billing datasets are refused unless the audience is just the asker.
5157. **Workspace memory.** A turn that read a non-workspace-readable folio writes `remember` facts at `person` scope by default (`services/agents/src/memory.ts`).
5168. **Templates.** "Save as template" from a Private or shared folio confirms "Everyone in the workspace will be able to use this template".
5179. **Events and the inbox** carry no title or content for folios that aren't workspace-readable.
51810. **Rooms.** Access changes call `setRole` on every open room in the affected subtree (a queue job for large subtrees). Revoked sockets close with 4403.
51911. **The vector index** is filtered by `scope` and always re-checked against D1 (section 5).
520
521### 4.4 "Write this up" from Chat
522
523`components/chat/write-up.tsx` and `lib/write-up.ts` become "Write this up as an artifact". The dialog has:
524
525- **Kind:** Auto (default), Doc, Slides, Design or Dashboard.
526- **Where:** a space the person can edit, **Private (just me)**, or **Shared with this conversation**. The default is "Shared with this conversation" in DMs and private channels, and the General space in public channels.
527- **Title**, and **Writer** (as now).
528
529The posted ask becomes:
530
531`@g1t write this thread up as an artifact (<kind or "whichever kind fits best: a doc for decisions and notes, slides to present, a design for screens or layouts, a dashboard to track numbers">) <where> titled "<title>". Link this thread as the source: <link>`
532
533The agent calls `create_artifact` with `source` set. `folios.source` shows "From a conversation" on the artifact, linking back for readers of the thread.
534
535---
536
537## 5. Search and the hybrid index
538
539- **Indexer.** `src/indexer.ts` `indexPage` becomes `indexFolio(env, id)`. It reads `folios.text` and `kind` and chunks through `kinds[kind].chunks`:
540- doc: by heading, as now (`chunks.ts`);
541- slides: one passage per slide, with heading "Slide 4 · Pricing"; tiny slides join the next;
542- design: one passage per top-level frame, with heading the frame name;
543- dashboard: one passage (definition only).
544
545The embed cap, the catch-up run and the backfill (`doc_index_runs`, which also covers folios) all stay.
546- **Vectorize.**
547- A new index `g1t-folios` (768 dimensions, cosine, binding `FOLIO_VECTORS`) with metadata indexes on `workspace_id`, `scope` and `kind`. Repository files move into it with `scope = repo:<repo space id>`.
548- Ids are `<folio id>:<seq>` or `rf_…:<seq>`.
549- **`scope`** is `space:<id>` when the folio's access is exactly its space's (inherit chain to the top, no restriction, no grants). Otherwise it is `folio:<acl_root>`.
550- Recall builds `allowed` from readable spaces, readable access roots (from `folio_access`, the general access rows and visits) and readable repo spaces. It uses `$in` when there are 40 or fewer keys, else a workspace filter and a post-filter (`vectorQueryPlan` as today).
551- **Every hit is re-checked** against D1 with `effectiveRole` and the audience before it is returned.
552- When access changes, a `folios.rescope` job rewrites metadata for the subtree's vectors: `getByIds`, then `upsert` with the same values. No re-embedding.
553- `g1t-docs` is deleted in Phase 7.
554- **Adapters.** `Embedder` and `VectorStore` (`src/vectors.ts`) are unchanged; self-hosters swap them. Without them, word recall over `folio_chunks_fts` still works.
555- **People's search.**
556- Artifacts home `?q=` is hybrid (RRF, as now), with kind, space, owner and project filters, and shows the matched passage. The separate `/search` page goes away.
557- Typing in link pickers stays words-only (`folios_fts`).
558- The command palette (`routes/search-json.ts`) adds an "Artifacts" group from `search_folios` with a limit of 5.
559- **Recall for agents.** `recall_folios_for_agent` returns `FolioPassage[]`. The agents service (`services/agents/src/recall.ts`) switches to it. The old `recall_for_agent` stays until Phase 7.
560
561---
562
563## 6. UI
564
565### 6.1 Mode wiring
566
567- **`lib/workspace-nav.ts`:** `ModeKey "docs"` becomes `"artifacts"`, `PAGE_MODES` maps `artifacts`, and `modeHome` gives `/${slug}/-/artifacts`. Update `workspace-nav.test.ts`.
568- **`components/rail.tsx`:** `{ key: "artifacts", label: "Artifacts", icon: <Shapes size={19}/> }` in the same slot as Docs.
569- **`components/shell.tsx`:** `Panel "artifacts"`, `MODE_MENU`, and `sidebarFor` renders `<FoliosSidebar>`.
570- **`components/mobile.tsx`:** the tab bar and the sheet row.
571- **`routes.ts`:**
572- Remove every `-/docs` route. Per the decision, there is no redirect; old links 404 with the normal not-found page.
573- Add, under `:owner`:
574
575```
576-/artifacts/live routes/workspace/folios/live.ts
577-/artifacts/api routes/workspace/folios/api.ts (JSON: list, sidebar, access, versions, templates, search, mutations)
578-/artifacts/query routes/workspace/folios/query.ts (dashboard tile data)
579-/artifacts/threads/:folio/* routes/workspace/folios/threads.ts
580-/artifacts/upload routes/workspace/folios/upload.ts
581-/artifacts/export routes/workspace/folios/export.ts
582-/artifacts (layout) routes/workspace/folios/layout.tsx
583index home.tsx ?tab=all|yours|shared &view=list|grid &kind= &space= &owner= &project= &q=
584new/:kind new.tsx ?space= &parent= &template= (creates, then redirects)
585templates templates.tsx ?kind=
586trash trash.tsx
587stale stale.tsx
588spaces spaces.tsx (browse and join open spaces)
589spaces/new space-new.tsx
590spaces/:space space.tsx
591spaces/:space/settings space-settings.tsx
592repo/:repoOwner/:repoName/* repo-file.tsx (projects' docs, read-only, unchanged)
593:folio folio.tsx (<title-slug>-<id>; dispatches to the kind's editor; ?present=1 for slides)
594:folio/history history.tsx
595```
596
597Addresses are flat and end in the id, so moving between spaces and Private never breaks a link.
598
599### 6.2 Home (`routes/workspace/folios/home.tsx`)
600
601- **Header:** "Artifacts", with a search box (`/` focuses it), filters (Kind, Space, Owner, Project) and a **grid/list toggle**. Icon buttons have a `Hint`.
602- **Tabs:** **All / Yours / Shared with you** (`ui/tab-strip.tsx`), kept in the URL.
603- **"Make something new" tiles:** Docs, Slides, Design, and Dashboard with a "Beta" badge.
604- Each tile goes to `new/:kind`, with a "Starting…" busy state and no double submit (as in the latest Docs fix).
605- A split chevron on each tile opens "From a template…" and "In a space…".
606- **List view, grouped by day** in the viewer's time zone (`lib/time-zone.ts`): **Today, Yesterday**, then `Oct 7`, with the year when it isn't this year. Each row shows:
607- the **kind icon**, in the kind's colour on a soft square: Docs `FileText`, Slides `Presentation`, Design `PenTool`, Dashboard `LayoutDashboard`;
608- the title (or "Untitled");
609- a **lock icon** with the Hint "Only you can see this" when private, or else up to 3 avatars of who it's shared with;
610- the space name (muted);
611- "Edited 45m ago" (`TimeAgo` on `edited_at`), with "by @x" in the Hint;
612- a **⋯ menu**: Open in new tab, Add to Favorites, Share…, Rename, Duplicate, Move to…, Copy link, Export, Save as template, Move to trash.
613- **Ordering by tab:**
614- **Yours:** `owner = me`, by `edited_at`.
615- **Shared with you:** `folio_access` rows for my keys that aren't mine, by `since` or `edited_at`, whichever is newer. Link-visited folios are included.
616- **All:** everything readable, by `edited_at`.
617- **Paging:** cursor paging with "Show more".
618- **Grid view:** cards with a **client-rendered preview** from `folios.preview` (JSON written at save time):
619- doc: the first lines;
620- slides: the first slide's layout and title;
621- design: the first frame's top 50 simplified nodes;
622- dashboard: tile boxes with titles and no numbers.
623
624No server thumbnails until Phase 8.
625- **Empty states per tab:** "Nothing shared with you yet. When someone shares an artifact with you, it shows up here."
626
627### 6.3 Sidebar (`components/folios/sidebar.tsx`, from `components/docs/sidebar.tsx`)
628
629- Search, then **Home**, **New ▾** (the four kinds) and **Templates**.
630- **Favorites**: draggable.
631- **Spaces**: joined open spaces, my team spaces and my Members-only spaces, each with its tree. "Browse spaces" and "New space" are in the section's `+`.
632- **Private**: my tree, with nothing in a space.
633- **Shared**: the tops of what's shared with me, meaning the highest readable ancestor.
634- **Possibly out of date** (when there is anything), **Projects' docs** (repo spaces) and **Trash**.
635- **Tree rows** show the kind icon or emoji, drag to reorder or nest (docs accept children), and have a ⋯ menu. A lock shows on restricted subtrees.
636- `lib/docs.ts` `buildTree` becomes `lib/folios.ts`.
637
638### 6.4 Share dialog (`components/folios/share-dialog.tsx`)
639
640Opened from the editor header's **Share** button and from ⋯ → Share….
641
642- **Title:** "Share '<title>'".
643- **Invite row:** a people picker for people, agents and teams (`components/people-picker.tsx`, extended with agents and teams), a role select (Can view / Can comment / Can edit / Full access, from `DOC_ROLE_LABELS`), an optional "Notify" message, and **Invite**.
644- **"Who has access":**
645- the owner;
646- explicit grants, editable;
647- inherited rows, read-only with "From <space>" or "From <parent>" and a link to manage them there.
648- **General access:**
649- in a space: **Everyone in <space>** (inherits) or **Only people invited** (restricts);
650- then: **Restricted** / **Everyone in <workspace>** (with a role) / **Anyone in the workspace with the link** (with a role);
651- "Public link: off for this workspace" is shown disabled until Phase 8.
652- **Agents:** "Agents may: suggest changes / edit directly" (`agent_mode`), defaulting to the space's.
653- **Footer:** **Copy link**, and Done.
654- Changing access shows "Updated" inline and the room's live notice refreshes other people's badges.
655- People without `manage` see the dialog read-only with "Ask <owner> for access", which sends a request notification.
656
657**Request access:** a 403 on a folio shows "You need access" with a button that notifies the owner and managers. The owner gets an inbox item with Approve (view / comment / edit) / Deny.
658
659### 6.5 Editor shells (`components/folios/shell.tsx` + per kind)
660
661**A shared header:**
662- breadcrumb: space or Private › parent;
663- icon and title (inline rename);
664- presence avatars;
665- the "Offline, changes will sync" status (from the provider);
666- **Share**, a comments toggle, a history toggle, ⋯ (Favorite, Duplicate, Move, Export, Save as template, Trash);
667- the kind's own actions: Slides **Present**; Design zoom and Export frame; Dashboard **Refresh**, as-of time and filters.
668
669**Shared side panels:** comments, history (versions list, preview, restore) and proposals (preview with accept/reject, for non-doc kinds).
670
671**Per kind:** `components/folios/doc/`, `slides/`, `design/`, `dashboard/`. Each is lazy-loaded through `React.lazy` so the home page carries no editor code.
672
673### 6.6 Phone
674
675- **Home:** list view only, tiles as a horizontal scroller, filters in a sheet, and the sidebar in the mode sheet.
676- **Docs:** full editing (as today).
677- **Slides:** view, present (swipe) and edit text on the current slide. No reordering or layout changes beyond a sheet.
678- **Design:** view, pan, zoom and comment. Editing says "Open on a larger screen to edit the canvas".
679- **Dashboards:** a single-column read view, refresh, and the filters sheet.
680
681---
682
683## 7. Docs service RPC (`services/docs`, `foliosClient`)
684
685**New file layout**, so `index.ts` stops growing:
686- `src/folios/service.ts`: the class `Folios`, sharing workspace, people and teams helpers moved from `index.ts` into `src/who.ts`.
687- `src/folios/rpc.ts`: a method table.
688- `src/folios/list.ts`, `access-store.ts`, `agents.ts`, `recall.ts`.
689- `src/datasets/run.ts`: `query_tile`, validation and cache.
690- `src/kinds/*`.
691- `index.ts` keeps routing: `/rpc/<method>` checks the folio table first, then the legacy switch.
692
693**Methods:**
694- Lists and navigation: `folio_list`, `folio_sidebar`, `folio`.
695- Changing folios: `create_folio`, `update_folio`, `move_folio`, `duplicate_folio`, `trash_folio`, `restore_folio`, `delete_folio`, `folio_trash`, `favorite_folio`.
696- Sharing and spaces: `folio_access`, `set_folio_grant`, `set_folio_general_access`, `request_folio_access`, `join_space`, `leave_space` (plus the existing space methods).
697- Search, history, templates and export: `search_folios`, `folio_versions`, `folio_version`, `restore_folio_version`, `folio_templates`, `save_folio_template`, `delete_folio_template`, `export_folio`.
698- Suggestions and proposals: `folio_suggestions`, `decide_folio_suggestion`, `folio_proposals`, `decide_folio_proposal`, `folio_thread`, `folio_threads`.
699- Dashboards: `query_tile`, `query_dataset_for_agent`.
700- Agent calls: `folios_for_agent`, `read_folio_for_agent`, `create_folio_as_agent`, `edit_folio_as_agent`, `share_folio_as_agent`, `recall_folios_for_agent`, `stale_folios_for_agent`, `mark_folio_current`, `reindex_folios`.
701
702**Other entry points:** `GET /live?folio=` and `PUT /files?folio=`.
703
704**Wrangler** (`services/docs/wrangler.jsonc`):
705- `FolioRoom` (DO migration v2);
706- `FOLIO_VECTORS` (`g1t-folios`);
707- service bindings `ACTIONS`, `DEPLOYMENTS`, `BILLING` for dataset fan-out (`WORK` and `AGENTS` already exist);
708- a cron (`"triggers": { "crons": ["17 3 * * *"] }`) to purge trash older than 30 days.
709
710**Datasets fan-out** goes through a `DatasetSource` port per service, so self-hosting needs nothing special.
711
712---
713
714## 8. Phases
715
716Each phase deploys on its own:
717- Migrations ship in or before the phase that reads them.
718- Nothing deployed still reads what a phase drops.
719- Deploy order inside a phase is the docs service, then agents and api, then web.
720
721Sizes:
722- **S** ≈ ≤1 day of one agent
723- **M** ≈ 2–4 days
724- **L** ≈ 1–2 weeks
725- **XL** ≈ 2–4 weeks
726
727| # | Phase | Size | Ships | Depends on |
728|---|---|---|---|---|
729| 0 | **Contracts and plan** | S | `folios.ts`, `folios-*.ts`, `datasets.ts` (types and validators, with tests); `crates/contracts` `folios.rs`, `datasets.rs`, `FOLIO_EVENTS` (not yet subscribed); `Resource::Artifacts` scopes (not yet used by any op); this doc. No runtime change. | none |
730| 1 | **Service core + doc kind** | L | Migration `0004_folios.sql`; `FolioRoom` with the kind adapter and `kinds/doc`; access (`effectiveRole`, materialize, with tests); list, sidebar, CRUD, share, trash, versions, templates (doc builtins ported), search, `folio.*` events; indexer to `g1t-folios`; agent RPCs (`*_for_agent`, `recall_folios_for_agent`). Old Docs keeps running on old tables. | 0 |
731| 2 | **Artifacts mode (web) + agents switch** | L | Rail, mode, shell and mobile wiring; `-/docs` routes deleted; home (tabs, tiles, day-grouped list, grid), sidebar, share dialog, editor shell, doc editor moved, history, trash, templates, spaces pages. **Same release:** `services/agents` tools and recall switched to the folio RPCs (`search_artifacts`, `read_artifact`, `create_artifact` (doc only for now), `edit_artifact`, ...); the write-up dialog with Where (kind fixed to Doc until slides ship); per-viewer chat unfurls for artifact links. Old pages are dropped (see D2). apps/docs: `guides/artifacts.mdx` (overview, spaces, sharing, private), `guides/docs.mdx` removed. Slides, Design and Dashboard tiles show "Coming soon" (disabled with a Hint), not hidden, until their phase. | 1 |
732| 3 | **External MCP/REST** | M | `artifact` MCP tool, REST routes, `artifacts:*` scopes on ops, OpenAPI and `apps/docs/src/data/openapi.json`, `DOCS` binding in apps/api, guide `guides/bring-your-own-agent.mdx` section, scope table in `guides/authentication.md`. | 1 (parallel with 2) |
733| 4 | **Slides** | L | `kinds/slides.ts` (Markdown deck parse/serialize, ops, chunks, preview), slides editor, filmstrip, layouts, themes, present and presenter mode, print to PDF, slides templates (5), agent ops, write-up kind, apps/docs `guides/artifacts-slides.md`. | 2 (editor shell registry) |
734| 5a | **Datasets in owning services** | M each, parallel | `query_dataset` in `services/work` (issues, PRs), `services/actions` (runs), `services/deployments`, `services/billing` (spend), `services/agents` (sessions, spend), each with access tests and a D1 index migration where needed. | 0 |
735| 5b | **Dashboards** | L | `components/charts/` (extracted from usage.tsx; usage pages switched to it), `kinds/dashboard.ts`, grid editor, query builder, `query_tile` with cache, the `query_data` agent tool, 4 templates, "Beta" badge, apps/docs `guides/artifacts-dashboards.md`. | 2, 5a (each dataset lights up as its service ships) |
736| 6a | **Design: canvas core** | L | `kinds/design.ts`, SVG renderer, tools, select, snap, layers and properties, frames with stack layout, images, awareness cursors, comments pinned, export SVG/PNG, agent `upsert_nodes`. | 2 |
737| 6b | **Design: components and HTML frames** | M | Components and instances, `html` nodes in the usercontent sandbox (with server sanitizer), design templates (4), apps/docs `guides/artifacts-design.md`. | 6a |
738| 7 | **Cleanup** | S | Migration `0005_drop_pages.sql`; DO migration v3 `deleted_classes: ["PageRoom"]`; remove the legacy RPC methods, `DocPage*` contracts, `doc.page.*` from `events.ts`, `events.rs` and `subscribers.rs`, `components/docs/*` and `routes/workspace/docs/*` leftovers; delete Vectorize `g1t-docs`; rewrite docs/WORKSPACE.md "Docs" to point here. Ships only after 2, 3 and the agents switch have been deployed everywhere. | 2, 3 |
739| 8 | **Later** | — | Public links (usercontent origin, workspace setting, `view` only); server PDF and thumbnails through the `Renderer` adapter (og `Screenshots`); imports (Markdown folders, a deck file); `folio.*` events offered to webhooks; a documenter agent routine on `folio.stale`; dashboard alerts. | — |
740
741### Parallel worktrees and file ownership
742
743**One worktree each, in parallel:**
744- **After 0:** `1`, `5a-work`, `5a-actions`, `5a-deployments`, `5a-billing`, `5a-agents`. Each 5a branch owns only its service directory and `crates/contracts/src/datasets.rs` additions. The contracts are settled in 0, so these are read-only for 5a.
745- **After 1:** `2` and `3`.
746- **After 2:** `4`, `5b` and `6a`.
747
748| Phase | Owns (exclusive) | Touches (append-only, merged by the integrator) |
749|---|---|---|
750| 1 | `services/docs/src/folios/**`, `src/kinds/doc/**`, `src/kinds/types.ts`, `src/access.ts`, `src/indexer.ts`, `src/persist.ts`, `src/room.ts`, `migrations/0004_*`, `wrangler.jsonc` | `src/index.ts` (route to the folio table) |
751| 2 | `apps/web/app/routes/workspace/folios/**`, `components/folios/{shell,sidebar,share-dialog,provider,kinds,doc/**}`, `lib/folios.ts`, `lib/workspace-nav.ts`, `rail.tsx`, `mobile.tsx`, `routes.ts`; `services/agents/src/{tools,ports,recall}.ts`; `components/chat/write-up.tsx`, `lib/write-up.ts`; `apps/docs/.../guides/artifacts.mdx` | `components/shell.tsx` (panel switch) |
752| 3 | `apps/api/src/folios.rs`, `apps/api/wrangler.jsonc`, `crates/contracts/src/scopes.rs` (Artifacts ops) | `apps/api/src/{tools,rest,openapi,operations}.rs`, `apps/docs/src/data/openapi.json` |
753| 4 | `services/docs/src/kinds/slides/**`, `src/templates/slides.ts`, `components/folios/slides/**`, `packages/contracts/src/folios-slides.ts`, `guides/artifacts-slides.md` | `kinds/index.ts`, `components/folios/kinds.tsx` (one line each) |
754| 5b | `components/charts/**`, `components/usage.tsx`, `services/docs/src/kinds/dashboard/**`, `src/datasets/**`, `components/folios/dashboard/**`, `routes/workspace/folios/query.ts`, `folios-dashboard.ts` | the same registries; `services/agents/src/tools.ts` (`query_data`, a small hunk) |
755| 6a/6b | `services/docs/src/kinds/design/**`, `components/folios/design/**`, `folios-design.ts`, `apps/web/workers/usercontent.ts` (html frames, 6b only) | the same registries |
756
757Conflict hotspots are kept to one-line registry appends: `kinds/index.ts`, `components/folios/kinds.tsx`, `packages/contracts/src/index.ts` exports and the apps/docs sidebar config. Never reformat these files in a feature branch.
758
759---
760
761## 9. Risks and open decisions, each with a recommendation
762
763| # | Decision or risk | Recommendation |
764|---|---|---|
765| D1 | Code name for the mode | **`folio`** in code; "artifact" only in UI, URLs, MCP, REST and scopes (section 0). Keep `services/docs` and the `g1t-docs*` resources. |
766| D2 | Carry existing Docs pages over, or drop them? | **Drop** (pre-launch; the user allows a reset). A copy needs every old room's Yjs state read through `PageRoom.state()` for little value. Re-seed demo workspaces from the new templates. If a copy is wanted after all, a one-off owner-run `copy_pages_to_folios` job (pages become `doc` folios in the same spaces, state copied) is an M-sized add-on to Phase 2. |
767| D3 | `-/docs` URLs | **Removed, no redirect** (the user's instruction). They 404 with the normal page. |
768| D4 | Addresses | **Flat** `/-/artifacts/<title-slug>-<id>`, so moves never break links. Spaces at `/-/artifacts/spaces/<slug>`. |
769| D5 | Folders | **No folder kind in v1.** Docs hold children, and only docs. Revisit only if people ask to group decks without a page. |
770| D6 | Space kinds | Keep `workspace` / `team` / `private` in the database. In the UI: **Open**, **Team**, **Members only**. Open spaces need a **join** (`space_joins`) to show in the sidebar, so it doesn't fill up. |
771| D7 | Who can share | **`manage` only by default.** A space setting "Editors can share" is offered. Agents can only grant view or comment to people already in the conversation, never general access. |
772| D8 | Agent changes to non-doc kinds where they can't edit | **Proposals**: a Yjs update against a state vector, previewed in a forked document, applied or rejected as a whole. Doc keeps inline suggestions. |
773| D9 | Design canvas library | **Build on SVG with Yjs maps**, with `html` frames in the usercontent sandbox for rich agent output. Don't adopt a whiteboard library (licence and fit, section 3.3). Scope it as a layout tool, not a vector editor. |
774| D10 | Charts | **Own SVG chart kit** extracted from `components/usage.tsx`. No dependency, and the usage pages share it. Revisit only if we need more than about 8 chart types. |
775| D11 | Dashboard query safety | **A declared dataset catalog, not SQL.** Executed by owning services as the viewer. Values never persisted in the folio, versions, index or previews. Agents are audience-narrowed, and spend is refused in shared audiences. |
776| D12 | Dashboard numbers differ by viewer | Accepted and labelled: "Based on what you can see" when `partial`. Don't add "run as owner" in v1 (it would leak). |
777| R1 | Access complexity and performance | Materialized `folio_access` and a denormalized `acl_root`/`path`, with pure `effectiveRole` tests over a matrix of private / space / team / general / link / restrict / nested / owner-workspace-owner. Tree depth capped at 10. Big subtree changes go through the queue. |
778| R2 | Stale vector metadata after access changes | A `scope` key plus a mandatory D1 re-check of every hit. Rescope jobs only improve recall quality; correctness never depends on them. |
779| R3 | Yjs document size (design) | Images are files, never inline. Node, html and file caps are enforced in `validate`. Version state over 1.5 MB goes to the file store (`state_key`). Compaction as today. |
780| R4 | BlockNote instances per deck | Mount live editors only for the current slide; thumbnails are static renders. Fallback: one editor per slide body with regions as blocks. |
781| R5 | Bundle size | Each kind's editor is lazy-loaded. Home ships no editor code. The chart kit loads only with dashboards. |
782| R6 | HTML in design frames (XSS) | Server-side sanitizer, served only from the usercontent origin in `sandbox=""` iframes with a strict CSP (no scripts, no forms, images only from the files origin). |
783| R7 | Self-hosting | DO, R2, D1, Vectorize, Workers AI, the Cache API and Browser Rendering each sit behind an existing or new adapter (`room`, `FileStore`, SQL, `Embedder`, `VectorStore`, `QueryCache`, `Renderer`, `DatasetSource`). docs/SELF_HOSTING.md is updated in Phases 1, 5b and 8. |
784| R8 | Window where agents and UI disagree during Phase 2 | Ship the docs service first, then agents and web in the same release. Until then the old Docs UI and old tools keep working on the old tables. |
785| R9 | Rail label length | "Artifacts" is 9 characters, the same as "Workspace", which already fits the 5rem rail. |
786| R10 | Insights placeholder | Later, point `-/insights` at a workspace-pinned dashboard ("Engineering health" template). Not in scope now. |
787| R11 | Public links | Phase 8, off by default per workspace, view only, never indexed or recalled, and revocable by rotating the token. |
788| R12 | Copy rules | No product comparisons in UI or apps/docs. The tile labels are "Docs, Slides, Design, Dashboard". |
789
790---
791
792### Critical Files for Implementation
793
794- `services/docs/src/room.ts`: becomes `FolioRoom` with kind adapters.
795- `services/docs/src/index.ts` and `src/access.ts`: RPC routing, access rules, agent reads and recall (`agentSpaces`, `recallForAgent`).
796- `packages/contracts/src/docs.ts`: the source for the new `folios.ts` and `datasets.ts`.
797- `apps/web/app/routes.ts`, plus `apps/web/app/lib/workspace-nav.ts`, `apps/web/app/components/rail.tsx` and `apps/web/app/components/shell.tsx`: mode wiring.
798- `services/agents/src/tools.ts` and `apps/api/src/tools.rs`: agent and MCP tools.
799
800---
801
802## 10. Decided in Phase 0
803
804Phase 0 shipped the contracts with no runtime change. Where this plan was open, it settled these:
805
806- **Scopes not offered yet.** `artifacts:read|write|admin` are in `Scope::ALL` (at the end) behind `Resource::offered()`, which is false for Artifacts. Presets, full access as a list (`everything()`), OAuth's `scopes_supported`, `parse_scopes` and `resolve_permissions` leave them out, and no operation needs them. The TypeScript mirror keeps them in `UPCOMING_SCOPES` and `UPCOMING_RESOURCES`, so the token form, the OAuth checklist and apps/docs never show them. Phase 3 makes `offered()` true, moves the rows to the end of `SCOPES` and `SCOPE_RESOURCES`, adds `artifacts:read` to the presets and adds the docs scope table.
807- **Dataset catalog.** Section 3.4 named the datasets. Phase 0 fixed their fields (`DATASETS` in `datasets.ts` and `datasets.rs`):
808 - per dataset: `times` (the default first), `dimensions` (text), `measures` (numbers) and `rates` (yes-or-no, for `rate`);
809 - `DatasetQuery.time` picks the time field;
810 - `DatasetResult.partial` drives "Based on what you can see";
811 - limits: 10 filters, 50 values per `in`, 100 rows, and a `{ from, to }` range of at most 366 days, given as dates or RFC 3339 UTC times.
812 The 5a services implement exactly these fields.
813- **Three more RPC methods.** `folio_content` and `edit_folio` are a person's (or their token's) read and edit in the agent form, for REST `/content` and the MCP `get`/`edit` actions. `query_dataset` is a person's query, for `POST /datasets/query` and MCP `query_data`. `FolioAccessChange` also carries `inherit` and `agent_mode` changes. The client sends grants and revokes to `set_folio_grant` and the rest to `set_folio_general_access`.
814- **Events.** Payloads are camelCase, like every event on the bus. `title` is null unless the whole workspace can read the folio. `folio.updated` uses `versionKind`, because `kind` is the folio's kind. `folio.stale` names the repository as `owner/name` only. `FOLIO_EVENTS` and the payload structs are in `folios.rs` (re-exported from `events.rs`). `subscribers.rs` is untouched until Phase 1 publishes them.
815- **Validators.**
816 - They are pure and return an error sentence or null (`Result` in Rust), and the words are the same in both languages.
817 - Shared cases in `datasets.fixtures.json` and `folios.fixtures.json` are run by both test suites.
818 - Contracts files import only types from each other, because Node runs the tests on the files as they are. So `dashboardOpError` takes the query validator (`datasetQueryError`) as an argument.
819- **Ids.** `fol_` for folios and `prp_` for proposals. Versions, templates and files keep `ver_`, `tpl_` and `fil_`.
820- **Slides themes** are a slug plus an optional `#rrggbb` accent. Phase 4 names the themes.