---
title: "Canvas Audit"
canonical_url: "https://scholarxiv.com/developers/docs/canvas-audit"
markdown_url: "https://scholarxiv.com/developers/docs/canvas-audit.md"
---

> For the complete documentation index, see [llms.txt](/llms.txt).

# Canvas Audit
URL: /developers/docs/canvas-audit
LLM index: /llms.txt

# Canvas audit (Stage 0)

Read-only audit of the canvas feature as of commit `155c79c`, plus the proposed edge value types and node runtime state for Stages 1–9. No code has been changed.

## 1. Rendering

**There is no graph library.** The canvas is hand-rolled in one component, `src/components/canvas/canvas_workspace.svelte` (~1,080 lines). `package.json` has no `@xyflow/svelte` or similar.

| Concern    | How it works today                                                                                                                                                                                                                                    | Where                                      |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| Pan / zoom | A single absolutely positioned layer with `transform: translate(x,y) scale(zoom)`. Panning comes from pointer drags on the background, zoom from the wheel and buttons (0.25–2×).                                                                     | `canvas_workspace.svelte` 447–522, 772–775 |
| Nodes      | One `{#each}` over `canvasWorkspaceState.items`. Every node type is rendered inline in the same file, picked by payload type guards (`isPaperPayload`, `isNotePayload`, …). There are no per-node components.                                         | 776–1010                                   |
| Handles    | Four identical port buttons per node (top, right, bottom, left) from `portPositions`. **A port has no role** (in or out), and any port starts a connection.                                                                                           | 54–59, 805–822                             |
| Edges      | One SVG overlay draws a cubic Bézier per connection. The endpoint sides are not stored. `facingPort` picks whichever side faces the other node, each time it draws. Each edge has an arrowhead marker on its `toId` end. Clicking an edge deletes it. | 114–173, 701–770                           |
| Connecting | Click or drag from a port, then release on a node. The hit test is the node's bounding box plus 20 px.                                                                                                                                                | 231–289, 374–428                           |

Node types in `CanvasItemType`: `paper`, `attachment`, `note`, `clip` (a PDF text selection dropped from the PDF viewer) and `ai`. `clip` isn't in the tools panel, but it is a real source node, so the later stages need to handle it.

`src/components/canvas/__narrowing_repro.svelte` is a tracked file that nothing imports. It looks like a leftover type-narrowing repro and is safe to delete.

## 2. Storage, save and load

The data lives in three MongoDB collections, all accessed through `src/lib/server/canvases.ts`:

| Collection           | Shape                                                              | Notes                                                                                                                            |
| -------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `canvases`           | `{ _id, userId, title, viewport, createdAt, updatedAt }`           |                                                                                                                                  |
| `canvas_items`       | `{ _id, canvasId, userId, type, position, size, zIndex, payload }` | `payload` is the per-type union in `src/lib/canvas/types.ts`. The AI node's output is stored in its payload as `payload.result`. |
| `canvas_connections` | `{ _id, canvasId, userId, fromId, toId }`                          | Unique index on `(canvasId, userId, fromId, toId)` (`src/db/indexes.ts:85`). No handle IDs are stored.                           |

- **Load:** `GET /api/canvases/[id]` → `getCanvasBoard` returns `{ canvas, items, connections, attachments }` in one response. That response goes into `CanvasWorkspaceState` (`src/state/canvas_workspace_state.svelte.ts`), which holds plain `$state` arrays.
- **Save:** each change is its own REST call with an optimistic local update, and there is no autosave or batching.
  - Item create: `POST …/items` (validates the payload for each type, `items/+server.ts`).
  - Move or edit: `PATCH …/items/[itemId]`, sent on pointer-up after a drag and on blur for note and AI text.
  - Connect: `POST …/connections`.
  - Delete: `DELETE …`.
  - Viewport: saved by `PATCH /api/canvases/[id]` after every pan or zoom.
- **Connection rules today:** `createCanvasConnection` (`canvases.ts:170`) only rejects self-loops and unknown nodes. It accepts direction-agnostic and cyclic graphs: A→B and B→A can both exist, and so can longer loops.
- **Model choice is not persisted.** The AI node renders the global `<SelectModel />` (`canvas_workspace.svelte:975`). Every AI node shares, and sends, whatever model is currently picked in `modelsState`, and the payload has no `model` field.

## 3. What Run does on an AI node

The model call runs on the server, is not streamed, and handles one node at a time.

1. **Client:** `runAI` (`canvas_workspace.svelte:438`) calls `canvasWorkspaceState.runAI(id, prompt, modelsState.currentModelID)`. That sends `POST /api/canvases/[id]/items/[itemId]/run` with `{ prompt, model }` (`canvas_workspace_state.svelte.ts:130`). While it waits, the button shows "Thinking…".
2. **Server** (`src/routes/api/canvases/[id]/items/[itemId]/run/+server.ts`):
   - It reloads the whole board with `getCanvasBoard`.
   - **Gathering inputs** (lines 49–55): it takes every connection whose `toId` is this node, looks up each `fromId` item and turns each one into text with `describeInput`:
     - paper → title, authors, year and URL (no abstract)
     - note → `Note\n<text>`
     - attachment → **the filename only**; the file contents are never read
     - clip → source title, pages and the clip text
     - ai → `AI result\n<stored result>`

     So chaining already works in a limited way: an upstream AI node's last saved result is used, but that node is not re-run.

   - The inputs are joined with `---` and the joined text is hard-cut at 30,000 characters (`MAX_CONTEXT_CHARS`). That cut can fall mid-input, and nothing tells the user it happened.
   - **Model:** `resolveModel({ requestedModel, surface: 'chat', planId, text, userId })` from `#lib/server/router`. That resolves "auto" presets through the router and falls back to the plan's default model if the user isn't entitled to the one requested. It then calls `generateText` from the `ai` SDK with a fixed research-assistant system prompt.
   - **Result:** `updateCanvasItem(…, { payload: { prompt, result: text.slice(0, 40_000) } }, 'ai')` writes the result back into the AI node's own payload in `canvas_items`, and the updated item is returned to the client.

3. **What's missing today:**
   - The canvas route checks no rate limit or usage quota. The only plan gate is model entitlement inside `resolveModel`. Stage 2's "respect plan limits" needs a decision on which counter to charge. The candidates are the chat surface's limits, `checkAndIncrementUserRateLimit` (`src/routes/api/utils/api_rate_limit.ts`), or a new canvas counter.
   - There is no streaming, no cancellation, and no record of which inputs produced the result.
   - The server returns one generic 502 on any failure.
4. **Edges out of an AI node have no effect,** including an AI → Note edge. Nothing ever writes AI output into a Note. Those edges are only drawn.

The app already has a streaming pattern that Stage 2 should reuse rather than invent:

- `createUIMessageStreamResponse` from the AI SDK
- `serveDetachedRun` in `src/lib/server/chat/serve_run.ts`, which keeps a run alive after the browser disconnects

The detached-run part also matters for Stage 8, where paused runs must survive a page reload.

## 4. "Search papers" and "Library" on the canvas

Both live in `src/components/canvas/canvas_tools_sheet.svelte` (the side pane), and both are one-time imports.

- **Search papers** (lines 57–96):
  - It sends `POST /api/search_papers` with `{ title, maxResults: 20 }`. That endpoint is the ScholarXIV/arXiv catalogue only (Atlas or legacy Mongo search). **It is not the federated search.**
  - Each result has an "Add to canvas" button that creates a static `paper` item.
  - `paperPayload` hard-codes `source: 'arxiv'` and drops the abstract (`summary`).
  - The search has no presence on the board and is never re-run.
- **Federated search exists separately:**
  - On the client, `src/state/federated_search_state.svelte.ts` fans out from the browser across 11 sources.
  - On the server, `runFederatedSearch` (`src/routes/api/utils/federated_search.ts`) is already used by the chat's research workflow. It takes `{ query, sources, page, limit, fromYear, toYear, sort }` and returns deduped `FederatedPaper`s. **Stage 4 should call this server function directly.**
  - Federated search is a paid entitlement (`federatedSearch: false` on the free plan, `src/lib/config/entitlements.ts:65`). For free users, the Search node should fall back to arXiv only.
- **Library** (lines 98–108, 285–331):
  - It lists files from `GET /api/uploads`, filtered by name on the client.
  - "Add to canvas" creates an `attachment` item holding only `{ attachmentId }`.
  - Files dropped or uploaded onto the board take the same path.
  - As noted above, the file's contents never reach the AI node.
  - Text extraction already exists in `src/lib/server/attachment_parts.ts` (`renderAttachmentsAsParts`, which caps extracted text at 40,000 characters) and should be reused for `document.text`.

## 5. Proposed edge value types

All values are plain JSON, so they can be stored, hashed and streamed. Every value carries a `kind` field that identifies its type (a discriminated union).

```ts
// src/lib/canvas/values.ts (proposed)

/** Stable, namespaced id: reuse FederatedPaper.externalID, e.g. "arxiv:2401.01234", "openalex:W27…". */
export type PaperId = string;

export type PaperRef = {
	id: PaperId;
	title: string;
	authors: string[];
	year: number | null;
	abstract: string; // '' when the source has none
	source: SourceId | 'manual'; // SourceId from #lib/federated/federated.js
	url: string | null; // landing page
	// Optional, kept for citations (Stage 9) and full-text loading (Stage 4).
	doi?: string;
	pdfUrl?: string;
};

export type TextValue = {
	kind: 'text';
	text: string;
	label?: string; // e.g. "Note", "Output of 'Extract methods'"; used when the prompt is built
};

export type PapersValue = {
	kind: 'papers';
	papers: PaperRef[];
};

export type DocumentValue = {
	kind: 'document';
	id: string; // attachment id
	name: string;
	mime: string;
	text: string | null; // extracted text, or null for images and other files with no text
	truncated?: boolean;
};

/** Items keep their own key, so results can be cached per item and linked back to their source. */
export type ListItem<T extends ScalarValue> = {
	key: string; // the paper id for a for-each over papers, otherwise the index or a source id
	label: string; // the paper title, or "Item 3"
	value: T;
};

export type ListValue<T extends ScalarValue = ScalarValue> = {
	kind: 'list';
	of: T['kind'];
	items: ListItem<T>[];
};

export type ScalarValue = TextValue | PapersValue | DocumentValue;
export type PortValue = ScalarValue | ListValue;
// Stage 7 adds `TableValue` to ScalarValue.
```

Design choices to agree on:

- **`papers` is already a collection,** so a for-each over papers iterates its `papers` array directly, with no separate `list<paper>` type. `list<T>` exists for results the for-each produces (Stage 5 outputs `list<text>`, one item per paper, keyed by paper id).
- **Paper identity uses the namespaced `externalID`** that federated search already produces. Stage 8 needs it to remember excluded papers, Stage 9 to dedupe collections and cite papers, and the run engine to cache per item. The current `CanvasPaper` would map to `PaperRef`, and paper cards would need to start storing the abstract.
- **Values are what flows along edges.** Each source node computes its value from its payload:
  - Note → `text`
  - Attachment → `document`
  - Paper card → `papers` of length 1
  - Clip → `text` labeled with its source

  AI and Search nodes take their value from the runtime output described below. Source nodes have no runtime state.

## 6. Proposed node runtime state

```ts
export type NodeStatus = 'idle' | 'queued' | 'running' | 'done' | 'error' | 'stale';
// Stage 8 adds 'waiting'.

export type NodeRuntime = {
	status: NodeStatus;
	output: PortValue | null; // the last successful output; kept, shown dimmed, while the node is stale
	error: string | null; // a user-facing message ("Upstream failed", a plan-limit message, …)
	lastRunAt: string | null; // ISO
	inputHash: string | null; // the hash of the inputs used to produce `output`
};
```

- **Where it's stored:** a new `runtime` field on the `canvas_items` document, kept apart from `payload`. The payload stays what the user wrote (instruction, model, query), and `runtime` holds what the engine produced. On load, `payload.result` migrates to `runtime.output` as `{ kind: 'text', text: result }`. `CanvasAI` also gains a `model: string` field, so each node stops relying on the global picker.
- **`inputHash`:** SHA-256 over canonical JSON of:
  - the node type
  - its config (instruction, the _requested_ model, and the Search filters)
  - the hash of each input, sorted by source node id, with empty inputs dropped

  An AI or Search input contributes its own `inputHash`. A source node contributes a hash of its value. That makes the hash cheap and Merkle-like: a node's hash changes exactly when something upstream of it changes. Using the requested model rather than the model the router resolves means "auto" doesn't break the cache every time.

- **Staleness:** a status change pushed down the graph, not stored separately for each edge.
  - Editing a node's config or content, or changing its incoming edges, marks that node and every AI or Search node downstream `stale`.
  - When a node is run, if its recomputed hash equals its stored `inputHash`, the run is a cache hit: the node goes straight back to `done` with its existing output.
  - Edits that are later undone therefore cost nothing.

## 7. Issues to decide before Stage 1

1. **Renderer:** I recommend keeping the hand-rolled renderer rather than adopting `@xyflow/svelte`. It is small, styled to the app, and the Stage 1 changes are contained: give each port a role, add two fixed sides, and store nothing new on edges. Switching libraries would mean rewriting every node's rendering for little gain. Stage 6's 50-node performance check is where to reconsider.
2. **"Remap old edges" is mostly free.** Edges already store direction (`fromId → toId`), and handles were never stored, so every existing edge maps onto the new right→left handles unchanged. The only edges Stage 1 would drop are those that break the new rules:
   - edges into a source-only node, such as AI → Note, Note → Note or Paper → Attachment
   - edges that close a loop
3. **Stage 1 and Stage 3 conflict.** Stage 1 says to drop edges that can't be mapped, but Stage 3 says to keep AI → Note edges working and show a hint. I propose that in Stage 1, AI → Note edges are kept and marked as legacy, drawn dashed, rather than dropped. Stage 3 then attaches its hint to them. Other invalid edges are dropped, with a `console.warn`. Edges are only dropped from the loaded view, not deleted from the database, until the board is next edited, so nothing is lost if the rules change.
4. **Loops in existing data:** if an old canvas contains a loop, I propose dropping the loop's newest edge, by `createdAt`, and logging a warning.
5. **Server-side enforcement:** connection rules (direction, source-only targets, loops) should also be checked in `createCanvasConnection`, not only on the client.
6. **Limits:** confirm which limit canvas runs should count against (see §3).

## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
Docs-scoped sitemap: [/docs/sitemap.md](/docs/sitemap.md).
Well-known sitemap: [/.well-known/sitemap.md](/.well-known/sitemap.md).
