···894894**Week 3 (Formula Power):** #112 (color-coded refs), #91 (array spill) -- transform the formula editing experience and unlock the next wave of functions.
895895896896**Week 4 (Platform):** #52 (command palette), #21 (toolbar cleanup) -- polish the interaction layer that every user touches.
897897+898898+---
899899+900900+## 11. Gap Analysis: Daily Driver Readiness
901901+902902+What would need to be true to use Tools as the ONLY docs+sheets tool, replacing Google Docs and Google Sheets entirely? This section identifies the gaps honestly.
903903+904904+### Data Loss Prevention
905905+906906+**Current state:** Good, with caveats.
907907+908908+- Snapshots are saved on a 500ms debounce after every edit, plus every 10 seconds via periodic timer, plus on `beforeunload` (tab close) and `visibilitychange` (tab switch).
909909+- The Yjs CRDT document is the source of truth. If the WebSocket disconnects, local edits are preserved in the Yjs Doc in memory. They sync when the connection resumes.
910910+- Version history keeps up to 50 snapshots per document on the server.
911911+912912+**Gaps:**
913913+- **Browser crash during edit.** If the browser process crashes (not a clean tab close), `beforeunload` does not fire. The last saved snapshot on the server may be up to 10 seconds old. Edits between the last save and the crash are lost. Mitigation: IndexedDB persistence (#84) would save the Yjs state to disk on every change, surviving a crash.
914914+- **localStorage is not durable.** Encryption keys stored in localStorage can be lost if the user clears browser data. There is no key backup mechanism. If the key is lost and the user does not have the URL saved, the document is permanently unrecoverable.
915915+- **No undo across sessions.** The Yjs UndoManager tracks undo history in memory. If the user closes the tab and reopens, undo history is gone. Only version history provides cross-session recovery.
916916+917917+**Verdict:** Adequate for normal use. Not crash-proof. IndexedDB persistence (#84) is the key improvement.
918918+919919+### Reliability
920920+921921+**Current state:** Reasonable for a self-hosted tool.
922922+923923+- WebSocket reconnects with 2-4 second randomized backoff on disconnect.
924924+- When reconnecting, the client sends its state vector and the peer (or snapshot) provides missing updates. No data is lost during disconnection.
925925+- Single-client scenario (no peers) works fully offline after initial load. The "synced" flag is set immediately if no peers are detected.
926926+927927+**Gaps:**
928928+- **Server restart loses WebSocket rooms.** If the server restarts, all WebSocket connections drop. Clients reconnect and re-sync from snapshots, but there is a brief interruption. In-flight encrypted messages that were not snapshot-persisted are lost (up to 500ms of edits).
929929+- **No health monitoring.** The `/health` endpoint exists but there is no alerting, no uptime monitoring, no automatic restart on crash. This is a deployment concern, not an application concern, but it matters for daily-driver reliability.
930930+- **Snapshot conflicts.** If two clients save snapshots simultaneously, the last write wins. Yjs state merges correctly on load, but there is a theoretical window where a client loads a snapshot that is missing another client's most recent changes. Those changes are recovered on the next WebSocket sync, but the user might see a brief "time travel" if they reload during this window.
931931+932932+**Verdict:** Reliable for 1-3 concurrent users on a stable network. Not yet battle-tested for high-concurrency or unreliable networks.
933933+934934+### Performance
935935+936936+**Current state:** Good for typical use, with known scaling limits.
937937+938938+- The sheets grid renders all cells on every `renderGrid()` call (100 rows x 26 cols = 2,600 cells). This is fast enough for default grids but would degrade with 1,000+ rows.
939939+- The virtual scrolling module exists but is not wired up (#146). Activating it would allow 10,000+ row sheets.
940940+- Formula evaluation caches results by formula text. Dependent formulas are evaluated recursively. There is no topological sort (though `RecalcEngine` appears to have been added for some formulas), and circular references crash the browser (#145).
941941+- `updateSelectionVisuals()` queries the DOM for each cell in the selection range. A 50-column selection triggers 50+ `querySelector` calls. Noticeable lag at large selections.
942942+- TipTap/ProseMirror handles document editing performance well for documents up to ~100 pages. Very long documents (500+ pages) may become sluggish due to ProseMirror's document traversal.
943943+944944+**Gaps:**
945945+- **No lazy loading for the landing page.** If a user has 500+ documents, the landing page fetches all of them, decrypts all names, and renders the full list. Pagination or virtual list rendering would help.
946946+- **Chart rendering blocks the main thread.** Chart.js renders synchronously on a canvas. Large datasets in charts can cause a visible frame drop.
947947+- **Full grid re-render on Yjs change.** Any Yjs update (from any peer) triggers `scheduleRenderGrid()` which does a `requestAnimationFrame(renderGrid)`. This rebuilds the entire grid HTML. A targeted cell update (only re-render changed cells) would be more efficient.
948948+949949+**Verdict:** Fine for sheets up to ~500 rows and docs up to ~50 pages. Wire up virtual scrolling (#146) and fix the circular reference crash (#145) to unlock larger workloads.
950950+951951+### Missing Basics (Compared to Google Sheets/Docs)
952952+953953+These are things a Google Sheets/Docs user would notice within the first 30 minutes:
954954+955955+| Feature | Google Has It | Tools Status |
956956+|---------|:---:|---|
957957+| Insert row/column in the middle | Yes | No -- can only append (#113) |
958958+| Paste from clipboard | Yes | Broken (#144 -- crashes) |
959959+| Drag-and-drop images | Yes | No -- URL-only (#81) |
960960+| Find and replace in sheets | Yes | No (only in docs) |
961961+| Conditional formatting presets (color scales, data bars) | Yes | No -- rules only (#120) |
962962+| Cell comments/notes with author | Yes | Partial -- notes exist but no author tracking |
963963+| Sparklines in cells | Yes | No (#87) |
964964+| IMPORTRANGE / cross-document data | Yes | No (#72) |
965965+| Print dialog with options | Yes | Docs only -- sheets print is basic (#115) |
966966+| Download as CSV | Yes | Exists (implemented) |
967967+| Download as .xlsx | Yes | No (#109) |
968968+| Revision history diff view | Yes | No -- versions exist but no visual diff (#49) |
969969+| Mobile app / PWA | Yes | No PWA yet (#54, #83, #84) |
970970+| Multiple undo levels with history | Yes | Yjs UndoManager works but no visual undo history |
971971+| Data validation with custom error messages | Yes | Partial -- validation exists, messages are generic |
972972+| Hyperlinks in cells | Yes | No -- cells are plain text |
973973+974974+### Trust Assessment
975975+976976+**Would I trust Tools with tax documents?**
977977+978978+Yes, with caveats:
979979+- **Encryption is sound.** AES-256-GCM with random IVs via the Web Crypto API is industry-standard. The key-in-fragment approach is clever and verifiable.
980980+- **Server is zero-knowledge.** Auditable by reading server.js (322 lines). The server never calls decrypt, never stores keys, never parses document content.
981981+- **Self-hosted eliminates third-party trust.** Running your own instance means no one else touches your data.
982982+983983+But:
984984+- **Key management is fragile.** One cleared localStorage away from permanent data loss. No backup mechanism, no recovery phrase, no key escrow.
985985+- **No audit log.** Cannot prove who accessed the document or when.
986986+- **Server-served JavaScript.** If someone compromises the server, they could serve modified JS that exfiltrates keys. Self-hosting mitigates this, but most users will trust the deployed instance.
987987+988988+**Verdict:** Trustworthy for privacy-sensitive documents. Not yet trustworthy for "only copy of critical financial records" without an external backup strategy.
989989+990990+### Summary: What's Needed for Daily Driver
991991+992992+**Must-fix (blocking daily use):**
993993+1. Fix paste crash (#144) -- basic operations must not crash
994994+2. Fix circular reference crash (#145) -- formulas must not freeze the browser
995995+3. Insert row/column in the middle (#113) -- fundamental spreadsheet operation
996996+4. Wire up virtual scrolling (#146) -- needed for any real-world sheet
997997+998998+**High-value improvements:**
999999+5. IndexedDB persistence (#84) -- crash recovery and true offline
10001000+6. Command palette (#52) -- fast navigation between documents
10011001+7. Row/column context menu actions (#149) -- right-click must work
10021002+8. Image drag-and-drop (#81) -- basic docs expectation
10031003+9. .xlsx export (#109) -- interoperability with the outside world
10041004+10. Key backup/export mechanism -- prevent catastrophic key loss
10051005+10061006+**Nice-to-have for parity:**
10071007+11. Find and replace in sheets
10081008+12. Revision history diff view (#49)
10091009+13. PWA with offline support (#83, #84)
10101010+14. Hyperlinks in sheet cells
10111011+10121012+The honest assessment: Tools is approximately **70% of the way to daily-driver status** for a technical user who values privacy. The remaining 30% is dominated by missing basics (#113, #144, #145), data durability (IndexedDB, key backup), and interoperability (.xlsx export, PWA).
+156
docs/adr/005-plugin-extension-system.md
···11+# ADR 005: Plugin/Extension System Design
22+33+## Status
44+55+Proposed (not yet implemented)
66+77+## Context
88+99+Users want to extend Tools with custom behavior: additional formula functions, custom cell renderers (progress bars, star ratings), custom slash commands, and themes. We need an extension system that:
1010+1111+1. **Preserves E2EE.** Plugin code runs entirely client-side. No plugin data or logic ever executes on the server. The server remains a zero-knowledge relay.
1212+2. **Is safe by default.** A malicious plugin should not be able to exfiltrate encryption keys, document content, or user data without explicit user consent.
1313+3. **Is simple for authors.** Writing a formula function or cell renderer should take 10 minutes, not 10 hours.
1414+4. **Does not require server changes.** Plugins are loaded and executed by the browser. The server does not need a plugin registry, execution environment, or API.
1515+1616+### Threat Model for Plugins
1717+1818+The critical risk: a plugin with access to the main page's JavaScript context can read `localStorage` (which contains encryption keys), access the Yjs document (which contains plaintext content), and exfiltrate data via `fetch`. This is the same risk as browser extensions, but we should minimize it.
1919+2020+## Decision
2121+2222+A three-tier plugin system, each tier with different capabilities and trust levels.
2323+2424+### Tier 1: Formula Functions (Trusted, Inline)
2525+2626+**Capability:** Register custom spreadsheet functions callable from formulas.
2727+2828+**Trust level:** Full access to cell values (necessary for computation). No access to DOM, network, or encryption keys.
2929+3030+**Mechanism:** The user writes a function definition and registers it:
3131+3232+```javascript
3333+// Example: FIBONACCI(n)
3434+tools.formulas.register('FIBONACCI', {
3535+ signature: 'FIBONACCI(n)',
3636+ description: 'Compute the nth Fibonacci number',
3737+ evaluate: (args) => {
3838+ const n = Math.round(args[0]);
3939+ if (n <= 0) return 0;
4040+ let a = 0, b = 1;
4141+ for (let i = 2; i <= n; i++) [a, b] = [b, a + b];
4242+ return b;
4343+ },
4444+});
4545+```
4646+4747+**Implementation:**
4848+- `tools.formulas.register(name, { signature, description, evaluate })` adds to the formula function library.
4949+- The `evaluate` function receives a flat args array (same as built-in functions in `callFunction()`).
5050+- Custom functions appear in the autocomplete dropdown with their signatures.
5151+- Functions are stored as JavaScript text in a `plugins` key in localStorage. They are `eval()`-ed on page load.
5252+5353+**Security:** Tier 1 functions run in the main context. They CAN access the DOM and network. This is acceptable because:
5454+- The user explicitly wrote or pasted the code.
5555+- The code only runs in the user's own browser.
5656+- It is equivalent to pasting code into the browser console.
5757+- A future sandboxed version (Tier 2) can provide isolation for untrusted plugins.
5858+5959+### Tier 2: Custom Cell Renderers and Slash Commands (Sandboxed)
6060+6161+**Capability:** Render custom cell content (progress bar, rating stars, color chips, sparklines) and add custom slash commands to the docs editor.
6262+6363+**Trust level:** Receives cell values or editor context. Cannot access encryption keys, localStorage, or network without explicit permission.
6464+6565+**Mechanism:** Plugins run inside a sandboxed `<iframe>` with `sandbox="allow-scripts"` (no `allow-same-origin`). Communication is via `postMessage`.
6666+6767+```javascript
6868+// Host sends cell data to the iframe:
6969+pluginFrame.contentWindow.postMessage({
7070+ type: 'render-cell',
7171+ value: 75,
7272+ format: 'percent',
7373+ width: 96,
7474+ height: 26,
7575+}, '*');
7676+7777+// Plugin iframe renders and sends back HTML or canvas data:
7878+window.addEventListener('message', (e) => {
7979+ if (e.data.type === 'render-cell') {
8080+ const pct = e.data.value;
8181+ // Render a progress bar
8282+ const html = `<div style="background:#e0e0e0;height:100%">
8383+ <div style="background:#5ea3e0;width:${pct}%;height:100%"></div>
8484+ </div>`;
8585+ parent.postMessage({ type: 'rendered', html }, '*');
8686+ }
8787+});
8888+```
8989+9090+**Implementation:**
9191+- Each plugin is an HTML file loaded in a sandboxed iframe.
9292+- The host provides a `postMessage` API: `render-cell`, `execute-command`, `get-selection`.
9393+- The plugin responds with rendered HTML (sanitized by the host before insertion) or action descriptors.
9494+- Plugins cannot access `document.cookie`, `localStorage`, `fetch` to the host origin, or the parent frame's DOM.
9595+9696+**Distribution:** Plugin HTML files are loaded from:
9797+- A local `plugins/` directory served by the Tools server.
9898+- A URL pasted by the user (loaded in a sandboxed iframe).
9999+100100+### Tier 3: Themes and Skins (CSS Only)
101101+102102+**Capability:** Override CSS custom properties and add style rules.
103103+104104+**Trust level:** No JavaScript execution. CSS only.
105105+106106+**Mechanism:** The user provides a CSS file (or CSS text) that is injected as a `<style>` tag. Themes override the `:root` custom properties:
107107+108108+```css
109109+/* Custom theme: Nord */
110110+:root {
111111+ --color-bg: #2e3440;
112112+ --color-surface: #3b4252;
113113+ --color-text: #eceff4;
114114+ --color-accent: #88c0d0;
115115+ /* ... */
116116+}
117117+```
118118+119119+**Implementation:**
120120+- Themes are stored in localStorage as CSS text.
121121+- On page load, the stored CSS is injected into a `<style id="user-theme">` element.
122122+- A theme picker in settings allows selecting from built-in themes or pasting custom CSS.
123123+- No JavaScript in themes. The `<style>` tag is injected with `textContent` (not `innerHTML`) to prevent injection.
124124+125125+**Distribution:** Themes are shared as CSS files or text snippets. No registry needed.
126126+127127+### Plugin Discovery and Distribution
128128+129129+For the initial implementation, plugins are local to each user's browser (stored in localStorage). There is no central plugin registry. Distribution methods:
130130+131131+1. **Copy-paste:** Share a code snippet (formula function) or URL (iframe plugin) via any channel.
132132+2. **Git repositories:** A plugin author publishes a repo with the plugin file(s). The user downloads and loads them.
133133+3. **Future: Plugin manifest.** A JSON file describing the plugin (name, version, type, entry point) that can be loaded by URL. No centralized registry -- just a URL.
134134+135135+A centralized plugin registry is explicitly out of scope. It would require trust infrastructure (code review, signing, hosting) that conflicts with the self-hosted, zero-trust philosophy.
136136+137137+## Consequences
138138+139139+**Positive:**
140140+- Formula extensions are trivially simple (a single function definition).
141141+- Sandboxed iframe plugins cannot access encryption keys or document content beyond what the host explicitly sends them.
142142+- CSS-only themes eliminate all JavaScript risk.
143143+- No server changes needed. Plugins are entirely client-side.
144144+- The tier system lets users choose their comfort level: Tier 1 (full trust, maximum power), Tier 2 (sandboxed, limited), Tier 3 (CSS only, zero risk).
145145+146146+**Negative:**
147147+- Tier 1 plugins (formula functions) have full main-context access. A careless user who pastes malicious code could have their keys exfiltrated. Mitigation: clear warnings in the plugin UI, and a future migration path to Web Worker sandboxing.
148148+- Sandboxed iframe plugins add latency (postMessage round-trip per render). For cell renderers called on every visible cell, this could be slow. Mitigation: batch rendering messages, cache rendered output.
149149+- No automatic updates for plugins. Users must manually update.
150150+- Plugin state is per-browser (localStorage). A plugin installed on one device is not available on another. Future mitigation: store plugin definitions in the Yjs document (encrypted, synced).
151151+152152+**E2EE Interaction:**
153153+- Plugins run client-side, after decryption. They operate on plaintext data, same as the built-in code.
154154+- Tier 2 plugins receive only the data the host sends them (individual cell values, not entire documents).
155155+- No plugin data is ever sent to the server. The server does not know plugins exist.
156156+- If a plugin needs external data (e.g., a stock price lookup), the user must explicitly grant network access. The sandboxed iframe cannot make network requests by default.
+225
docs/adr/006-forms-architecture.md
···11+# ADR 006: Forms Architecture
22+33+## Status
44+55+Proposed (not yet implemented)
66+77+## Context
88+99+Tools needs a form builder that allows users to collect structured data (RSVPs, feedback, surveys, intake forms) with responses flowing into an encrypted Sheet. The key challenge: how does a form respondent encrypt their response if they do not have the document key?
1010+1111+### E2EE Challenge
1212+1313+In the current model, everyone who accesses a document shares the same AES key via the URL fragment. For forms, this is problematic:
1414+1515+- The form creator has the key.
1616+- Form respondents should be able to submit data without seeing other responses.
1717+- If respondents get the full Sheet key, they can read all other responses.
1818+1919+Three approaches considered:
2020+2121+1. **Same key for form and sheet.** Respondents get the key. Simple, but they can see other responses. Acceptable for internal/trusted forms (team feedback), not for public forms.
2222+2. **Write-only key pair.** Use asymmetric encryption: the form creator generates a key pair, publishes the public key in the form URL, and respondents encrypt with the public key. Only the creator (with the private key) can decrypt responses. Web Crypto API supports RSA-OAEP and ECDH.
2323+3. **Per-response symmetric key.** Each response gets its own AES key. The respondent encrypts their response and sends both the encrypted blob and the key, encrypted with the form creator's public key. This is hybrid encryption (asymmetric envelope, symmetric payload).
2424+2525+## Decision
2626+2727+Implement **approach 3: hybrid encryption** for forms. This provides the strongest security guarantees while keeping the system practical.
2828+2929+### Encryption Flow
3030+3131+**Form creation:**
3232+1. Creator clicks "New Form" (linked to a Sheet).
3333+2. Client generates an RSA-OAEP key pair (2048-bit) or ECDH key pair (P-256).
3434+3. The public key is stored on the server alongside the form metadata (NOT encrypted -- it is public by definition).
3535+4. The private key is stored in the URL fragment of the creator's form management link, similar to how document AES keys work.
3636+5. The form's shareable URL includes the form ID and the public key fingerprint (for verification).
3737+3838+**Response submission:**
3939+1. Respondent opens the form URL (`/forms/{formId}`).
4040+2. Client fetches the form definition (question structure) and the public key from the server.
4141+3. Respondent fills out the form.
4242+4. Client generates a one-time AES-256-GCM key.
4343+5. Client encrypts the response payload with the one-time AES key.
4444+6. Client encrypts the one-time AES key with the creator's public key (RSA-OAEP or ECDH+AES-KW).
4545+7. Client POSTs to the server: `{ encrypted_response, encrypted_key }`.
4646+8. Server stores the encrypted response as a new row.
4747+4848+**Response viewing:**
4949+1. Creator opens the Sheet linked to the form.
5050+2. Client loads encrypted responses from the server.
5151+3. For each response: decrypts the one-time AES key using the private key, then decrypts the response payload.
5252+4. Decrypted responses are inserted as rows in the Sheet.
5353+5454+### Data Model
5555+5656+#### Server-Side (new table)
5757+5858+```sql
5959+CREATE TABLE forms (
6060+ id TEXT PRIMARY KEY,
6161+ sheet_id TEXT NOT NULL, -- linked Sheet document ID
6262+ definition TEXT NOT NULL, -- encrypted form structure (JSON)
6363+ public_key TEXT NOT NULL, -- RSA/ECDH public key (JWK format, NOT encrypted)
6464+ created_at TEXT DEFAULT (datetime('now')),
6565+ updated_at TEXT DEFAULT (datetime('now'))
6666+);
6767+6868+CREATE TABLE form_responses (
6969+ id TEXT PRIMARY KEY,
7070+ form_id TEXT NOT NULL,
7171+ encrypted_response BLOB NOT NULL, -- AES-256-GCM encrypted response data
7272+ encrypted_key BLOB NOT NULL, -- RSA/ECDH encrypted one-time AES key
7373+ created_at TEXT DEFAULT (datetime('now'))
7474+);
7575+```
7676+7777+#### Yjs Structure (form definition)
7878+7979+The form definition is stored as an encrypted JSON blob on the server, decryptable by the creator. The public-facing form definition (question text, types, options) is a subset that the server serves to respondents.
8080+8181+```javascript
8282+{
8383+ title: "Event RSVP",
8484+ description: "Please let us know if you can attend.",
8585+ questions: [
8686+ {
8787+ id: "q1",
8888+ type: "text",
8989+ label: "Your Name",
9090+ required: true,
9191+ placeholder: "Full name",
9292+ },
9393+ {
9494+ id: "q2",
9595+ type: "select",
9696+ label: "Attending?",
9797+ required: true,
9898+ options: ["Yes", "No", "Maybe"],
9999+ },
100100+ {
101101+ id: "q3",
102102+ type: "multiselect",
103103+ label: "Dietary needs",
104104+ required: false,
105105+ options: ["Vegetarian", "Vegan", "Gluten-free", "None"],
106106+ },
107107+ {
108108+ id: "q4",
109109+ type: "textarea",
110110+ label: "Comments",
111111+ required: false,
112112+ }
113113+ ],
114114+ settings: {
115115+ confirmationMessage: "Thank you for your response!",
116116+ allowMultiple: false,
117117+ closeAfter: null, // ISO date string or null
118118+ },
119119+ mapping: {
120120+ // Maps question IDs to Sheet columns
121121+ q1: { col: 1 }, // Column A
122122+ q2: { col: 2 }, // Column B
123123+ q3: { col: 3 }, // Column C
124124+ q4: { col: 4 }, // Column D
125125+ _timestamp: { col: 5 }, // Auto-populated
126126+ }
127127+}
128128+```
129129+130130+### Question Types
131131+132132+| Type | Input Widget | Data Stored |
133133+|------|-------------|-------------|
134134+| `text` | Single-line input | String |
135135+| `textarea` | Multi-line textarea | String |
136136+| `number` | Number input | Number |
137137+| `email` | Email input | String (validated) |
138138+| `url` | URL input | String (validated) |
139139+| `date` | Date picker | ISO date string |
140140+| `time` | Time picker | Time string |
141141+| `select` | Dropdown / radio buttons | Single string |
142142+| `multiselect` | Checkbox group | Array of strings (comma-joined in cell) |
143143+| `rating` | Star rating (1-5) | Number |
144144+| `scale` | Linear scale (1-N) | Number |
145145+| `file` | File upload (future, requires #80) | Encrypted file reference |
146146+147147+### Form URL Structure
148148+149149+```
150150+https://tools.example.com/forms/{formId}
151151+```
152152+153153+No encryption key in the URL. The respondent does not need to decrypt anything -- they only need the public key (fetched from the server) to encrypt their response.
154154+155155+The creator's management URL:
156156+```
157157+https://tools.example.com/forms/{formId}/manage#{privateKeyBase64}
158158+```
159159+160160+### Server-Side Changes
161161+162162+1. **New routes:**
163163+ - `POST /api/forms` -- create a form linked to a sheet
164164+ - `GET /api/forms/:id` -- get form definition + public key (for respondents)
165165+ - `PUT /api/forms/:id` -- update form definition
166166+ - `POST /api/forms/:id/responses` -- submit an encrypted response
167167+ - `GET /api/forms/:id/responses` -- list encrypted responses (for creator)
168168+169169+2. **New HTML entry point:** `src/forms/index.html` -- form respondent view. Minimal UI: form title, questions, submit button. No toolbar, no editor.
170170+171171+3. **New Vite input:** Add `forms` to `rollupOptions.input` in `vite.config.js`.
172172+173173+### Conditional Logic
174174+175175+Question visibility can depend on previous answers:
176176+177177+```javascript
178178+{
179179+ id: "q5",
180180+ type: "text",
181181+ label: "Plus-one name",
182182+ required: false,
183183+ condition: {
184184+ questionId: "q2",
185185+ operator: "equals",
186186+ value: "Yes",
187187+ },
188188+}
189189+```
190190+191191+Condition evaluation is client-side (pure logic module). Only shown questions are included in the response payload.
192192+193193+### Estimated Complexity
194194+195195+| Component | Effort | Dependencies |
196196+|-----------|--------|-------------|
197197+| Server routes + DB schema | Small (1 day) | None |
198198+| Hybrid encryption (RSA-OAEP + AES) | Medium (2 days) | Extend crypto.js |
199199+| Form builder UI | Medium (3 days) | None |
200200+| Form respondent view | Small (2 days) | None |
201201+| Response decryption + Sheet insertion | Medium (2 days) | None |
202202+| Conditional logic | Small (1 day) | Form builder |
203203+| **Total** | **~11 days** | |
204204+205205+### What to Build First
206206+207207+**Phase 1 (MVP):** Simple forms with text, select, and textarea questions. Respondents use the sheet's AES key (approach 1 -- same key). This ships faster and covers the internal/trusted use case.
208208+209209+**Phase 2:** Hybrid encryption (approach 3). Add the RSA key pair generation, per-response encryption, and the separate form URL without the private key. This enables public/untrusted forms.
210210+211211+**Phase 3:** Conditional logic, file uploads, and form analytics (response count, completion rate).
212212+213213+## Consequences
214214+215215+**Positive:**
216216+- Form responses are E2E encrypted. The server stores only ciphertext.
217217+- Respondents cannot see other responses (with hybrid encryption).
218218+- Forms feed directly into Sheets, maintaining a single data model.
219219+- The form URL does not contain a decryption key, so it can be shared publicly.
220220+221221+**Negative:**
222222+- Hybrid encryption adds complexity (RSA key pair management, two encryption layers).
223223+- The form definition (question text) must be partially unencrypted so respondents can see the questions. The server can see question labels but not response data.
224224+- No real-time collaboration on form responses (they are append-only, not CRDT-synced). The creator must refresh to see new responses.
225225+- The private key is in the creator's URL fragment. If lost, responses are unrecoverable (same limitation as document keys).
+186
docs/adr/007-ai-integration-via-aperture.md
···11+# ADR 007: AI Integration via Aperture
22+33+## Status
44+55+Proposed (not yet implemented)
66+77+## Context
88+99+Tools can benefit from AI assistance for formula generation, document writing, data analysis, and content explanation. The AI gateway is Aperture, a self-hosted Tailscale service at `http://ai` that routes requests by model name to various providers (Anthropic, OpenAI, local models). Aperture injects provider credentials based on Tailscale identity.
1010+1111+The critical constraint: **AI features must not break E2EE guarantees.** The Tools server must never see plaintext document content. Any data sent to an AI provider is an explicit, user-initiated action -- the user chooses what context to share.
1212+1313+### Trust Model for AI
1414+1515+| Entity | Trusts With |
1616+|--------|------------|
1717+| **User** | Decrypted document content (they own it) |
1818+| **Aperture** | Selected context sent for AI processing (user's explicit choice) |
1919+| **AI provider** (Anthropic, OpenAI) | The prompt content (governed by provider's data policies) |
2020+| **Tools server** | Nothing. AI requests bypass the server entirely. |
2121+2222+The user is making a deliberate trade-off: sharing a specific piece of content with an AI provider to get assistance. This is no different from copying text from Tools and pasting it into ChatGPT -- we just make the workflow smoother.
2323+2424+## Decision
2525+2626+AI features are **client-side only**. The browser sends requests directly to Aperture (or any OpenAI-compatible API endpoint). The Tools server is not involved in AI requests.
2727+2828+### Architecture
2929+3030+```
3131+Browser (decrypted content)
3232+ |
3333+ | HTTPS (selected context only)
3434+ v
3535+Aperture (ai.lobster-hake.ts.net)
3636+ |
3737+ | Provider API
3838+ v
3939+AI Provider (Anthropic Claude, OpenAI, etc.)
4040+```
4141+4242+### Configuration
4343+4444+The Aperture URL is stored in `localStorage` under `tools-ai-endpoint`. Default: none (AI features are disabled until configured).
4545+4646+Settings UI:
4747+- **AI Endpoint:** Text input for the API base URL (e.g., `http://ai`, `https://api.openai.com/v1`).
4848+- **API Key:** Text input (stored in localStorage, never sent to Tools server). For Aperture on Tailscale, this can be empty (Aperture injects creds).
4949+- **Model:** Dropdown or text input (e.g., `claude-sonnet-4-20250514`, `gpt-4o`).
5050+- **Enable AI features:** Toggle. Off by default.
5151+5252+### Sheets: Formula Assistant
5353+5454+**Entry points:**
5555+1. **Formula bar AI button:** Small sparkle icon next to the formula input. Click to open the AI panel.
5656+2. **Context menu:** Right-click a cell -> "AI: Suggest formula" or "AI: Explain formula".
5757+3. **Keyboard shortcut:** Cmd+Shift+A to open the AI panel for the selected cell.
5858+5959+**UX Flow: Generate Formula from Description**
6060+1. User selects a cell (or range) and opens the AI panel.
6161+2. User types a natural-language description: "Sum of column B where column A is 'Revenue'"
6262+3. Client builds a prompt including:
6363+ - The user's description.
6464+ - Column headers (first row values for context).
6565+ - A few sample data rows (limited, to minimize data sent).
6666+ - The cell reference context (where the formula will be placed).
6767+4. Client sends to Aperture: `POST /chat/completions` with the prompt.
6868+5. AI responds with a suggested formula: `=SUMIF(A2:A100,"Revenue",B2:B100)`.
6969+6. Client shows the formula in a preview panel with:
7070+ - The formula text (editable).
7171+ - A live preview of the computed result.
7272+ - "Insert" and "Cancel" buttons.
7373+7. User clicks "Insert" -> formula is written to the cell.
7474+7575+**UX Flow: Explain Formula**
7676+1. User selects a cell containing a formula.
7777+2. Right-click -> "AI: Explain formula" (or click the AI button).
7878+3. Client sends the formula text to Aperture with a system prompt: "Explain this spreadsheet formula in plain English. Break down each function and reference."
7979+4. AI response is displayed in a sidebar panel with formatted text.
8080+8181+**Context Minimization:**
8282+- Only the selected cell's formula, column headers, and a limited sample (first 5 data rows) are sent.
8383+- The user can see exactly what context will be sent before confirming.
8484+- A "Context preview" expandable section shows the prompt.
8585+8686+### Docs: Writing Assistant
8787+8888+**Entry points:**
8989+1. **Inline AI menu:** Select text -> floating toolbar shows AI button.
9090+2. **Slash command:** Type `/ai` to open the AI prompt in the editor.
9191+3. **Sidebar panel:** Toggle an AI sidebar for longer interactions.
9292+9393+**Capabilities:**
9494+- **Continue writing:** AI extends the current paragraph or section.
9595+- **Rewrite:** Rephrase selected text (shorter, longer, simpler, more formal).
9696+- **Summarize:** Condense selected text or the entire document.
9797+- **Translate:** Translate selected text to another language.
9898+- **Fix grammar:** Correct spelling and grammar in selected text.
9999+- **Explain:** Break down complex text in simpler terms.
100100+101101+**UX Flow: Rewrite Selected Text**
102102+1. User selects a paragraph.
103103+2. Clicks AI button -> dropdown shows: Continue, Rewrite, Summarize, Translate, Fix Grammar.
104104+3. User selects "Rewrite -> More concise".
105105+4. Client sends the selected text + instruction to Aperture.
106106+5. AI response appears as a suggestion (using the suggesting mode marks if enabled, or an inline diff preview).
107107+6. User accepts or rejects the rewrite.
108108+109109+**Context sent:**
110110+- Selected text (or surrounding paragraph for "Continue").
111111+- Document title (for tone context).
112112+- A preceding paragraph for context (optional, user-visible).
113113+- Never the entire document unless the user explicitly chooses "Summarize entire document."
114114+115115+### Implementation Details
116116+117117+**API Client Module:** `src/lib/ai-client.js`
118118+119119+```javascript
120120+export class ApertureClient {
121121+ constructor({ endpoint, apiKey, model }) {
122122+ this.endpoint = endpoint;
123123+ this.apiKey = apiKey;
124124+ this.model = model;
125125+ }
126126+127127+ async complete({ messages, maxTokens = 1024 }) {
128128+ const res = await fetch(`${this.endpoint}/chat/completions`, {
129129+ method: 'POST',
130130+ headers: {
131131+ 'Content-Type': 'application/json',
132132+ ...(this.apiKey ? { 'Authorization': `Bearer ${this.apiKey}` } : {}),
133133+ },
134134+ body: JSON.stringify({
135135+ model: this.model,
136136+ messages,
137137+ max_tokens: maxTokens,
138138+ }),
139139+ });
140140+ if (!res.ok) throw new Error(`AI request failed: ${res.status}`);
141141+ const data = await res.json();
142142+ return data.choices?.[0]?.message?.content || '';
143143+ }
144144+}
145145+```
146146+147147+**Prompt Templates:** `src/lib/ai-prompts.js`
148148+149149+System prompts for each use case, optimized for concise, actionable responses. Formula prompts include the function library reference so the AI suggests only functions that Tools supports.
150150+151151+**UI Components:**
152152+- AI panel (shared between docs and sheets): a slide-out sidebar with a text input, response area, and action buttons.
153153+- Inline suggestion preview: for docs, uses the existing suggesting mode marks. For sheets, shows a formula preview with computed result.
154154+155155+### Fallback When Aperture Is Unavailable
156156+157157+- If the AI endpoint is not configured: AI buttons and menu items are hidden.
158158+- If Aperture is unreachable: show a toast notification "AI assistant unavailable -- check your connection to [endpoint]." AI buttons become disabled with a tooltip.
159159+- If the AI returns an error: show the error message in the AI panel. Do not silently fail.
160160+161161+### Privacy Indicators
162162+163163+When AI features are active, show a subtle indicator:
164164+- A small "AI" badge in the status bar when the AI endpoint is configured.
165165+- Before sending any request, a brief flash of "Sending to AI..." in the AI panel.
166166+- A "What's being sent" expandable section in every AI interaction so the user can audit the context.
167167+168168+## Consequences
169169+170170+**Positive:**
171171+- AI requests go directly from browser to Aperture. The Tools server never sees the content.
172172+- Users explicitly opt in by configuring the endpoint and initiating each AI action.
173173+- Context minimization: only the selected content (not the entire document) is sent.
174174+- Works with any OpenAI-compatible API, not just Aperture.
175175+- Gracefully degrades: AI features are invisible when not configured.
176176+177177+**Negative:**
178178+- Content sent to AI providers leaves the E2EE boundary. This is an explicit trade-off, but it must be clearly communicated to users.
179179+- Aperture requires Tailscale access. Users outside the Tailnet need a direct API key to their provider.
180180+- AI responses may suggest formulas that use functions Tools doesn't support. Mitigation: include the supported function list in the system prompt.
181181+- Streaming responses (for longer doc writing) require SSE or chunked response handling, adding complexity.
182182+183183+**E2EE Interaction:**
184184+- AI features do not weaken E2EE. The server's zero-knowledge properties are unchanged.
185185+- The trust boundary expands to include the AI provider, but only for the specific content the user chooses to send.
186186+- AI configuration (endpoint, API key) is stored in localStorage, not in the encrypted document. Different users of the same document can have different AI configurations (or none).