Marginalia
An assistant where every claim keeps its source attached.
An AI research assistant where answers cite a numbered evidence list that the server resolves, so a source can never be invented.
- Next.js
- TypeScript
- Supabase
- Tailwind CSS
- Vitest
The premise
Marginalia is an AI research assistant for people working from their own documents. A reader uploads PDFs, asks questions inside a workspace, and gets an answer whose citations point at the passages that produced it. The build covers upload and text extraction, retrieval that selects relevant passages, three source modes for document, web or both, and a citation protocol that resolves every marker server-side. It runs as a private single-user app: every table carries row-level security and each answer holds a status the interface polls until generation finishes.
Read it with the sources visible
Read claims beside the captures and repository evidence that support them. Open a numbered source or show all sources inline. The figures below remain readable without the source reader.
Jump to a figure
- 01
Workspace overview

Workspace overview with a recent research entry, a recent PDF and the New research action. This capture shows orientation, not an answer or citation. - 02
Research spaces

Research list with a workspace entry and the New workspace action. The page groups work by research space. - 03
Document upload

Document library with a research selector, PDF file input and Upload PDF action. Successful extraction is evidenced in the repository decision, not by this still. Read the related decision - 04
Account settings

Settings view with theme controls and profile fields. The OAuth decision explains the account boundary separately. Read the related decision
Decisions
Close the token in the citation protocol, not in the model
The model produced citations, and a wrong citation is a fabricated source.
- Options
- Let the model emit citation identifiers and resolve them afterwards.
- Have the model cite by 1-based index into a numbered evidence list, resolved server-side.
- Choice
- Cite by 1-based index into a numbered evidence list, resolved server-side, dropping anything unresolvable.
- Trade-off
- The model can only cite what was retrieved, so a correct answer depending on a missed passage becomes uncitable and its marker is stripped.
Evidence
- Citation protocol implementation and type comment
Marginalia:src/lib/research/citation-generation.ts (GeneratedCitation, toAnswerCitations, sanitizeAnswerMarkers) - Deleted parallel resolution path
Marginalia commit c42ed06 - Protocol tests
Marginalia:src/lib/research/__tests__/citation-generation.test.ts - Seed record and specificity verdict
docs/truth/marginalia-decisions.md, seed 1; docs/seed-specificity-review.md
Reject unknown OAuth identities instead of provisioning them
The callback first self-healed a missing profile by upserting one server-side, then that path was removed.
- Options
- Self-heal a missing profile by upserting one server-side, the earlier behaviour at commit 8b2b5c8.
- Reject unknown Google identities with account_not_found, sign out and clear every cookie, commit 5b0e7ae.
- Choice
- Reject unknown Google identities, so nothing is silently provisioned and the admission check is a profile row.
- Trade-off
- A registered user whose profile row is missing is told no account exists, the error classification matches the substring "signup" in upstream error text, and every rejection lands on /login.
Evidence
- Self-healing callback, then its removal
Marginalia commits 8b2b5c8 and 5b0e7ae - Admission, rejection and classification paths
Marginalia:src/app/auth/callback/route.ts - Admission and no-silent-provisioning tests
Marginalia:src/app/auth/__tests__/callback-route.test.ts - Seed record and specificity verdict
docs/truth/marginalia-decisions.md, seed 8; docs/seed-specificity-review.md
Swap pdf-parse for unpdf for serverless compatibility
PDF text extraction used pdf-parse, which bundles pdfjs-dist canvas-dependent code paths that break in Vercel's serverless runtime.
- Options
- Keep pdf-parse with the hand-written DOMMatrix polyfill added in commit 13d832f.
- Move to unpdf, which extracts text with no canvas or native dependencies.
- Choice
- Move to unpdf, remove the polyfill and the pdfjs-dist paths, and set serverExternalPackages in next.config.ts.
- Trade-off
- Dependency weight fell, 301 lines removed against 51 added. Whether extraction fidelity on chart-heavy PDFs was measured or only observed is not recorded.
Evidence
- Dependency swap
Marginalia commit 179c217 (6 files, 51 insertions, 301 deletions) - Extraction module and server external package setting
Marginalia:src/lib/research/document-parse.ts; Marginalia:next.config.ts - Extraction tests
Marginalia:src/lib/research/__tests__/document-parse.test.ts - Seed record and specificity verdict
docs/truth/marginalia-decisions.md, seed 4; docs/seed-specificity-review.md
The tools
- Next.js
- App Router routes, server actions for research flows and server-side generation keep the model keys and prompts off the client.
- TypeScript
- Typed contracts for the citation protocol, the answer schema and the retrieval layer, with tests alongside them.
- Supabase
- Authentication, row-level security on every table, private storage for documents and status columns for state.
- Tailwind CSS
- Shared utility styling across the document, research and settings routes.
- Vitest
- Unit tests split into node and jsdom projects, so the environment follows the file extension.
Problems and fixes
PDF extraction failed in the serverless runtime
pdf-parse bundles pdfjs-dist canvas-dependent code paths, which break in Vercel's serverless environment even after a hand-written DOMMatrix polyfill, and some PDFs with embedded figures still failed extraction. Commit 179c217 moves extraction to unpdf, which needs no canvas or native dependencies. The commit removes 301 lines and adds 51, and it preserves the input and output shape and the user-safe error handling.
One table had no row-level security
Every application table had row-level security except profiles, which ProfileProvider and ProfileForm read and update from the browser's authenticated client. Any authenticated request that could name a user id could read or overwrite another row, and only the app's own query behaviour stood in the way. Migration 20260929000000_secure_profiles.sql enables RLS and scopes SELECT and UPDATE to auth.uid(), and grants no INSERT because the sign-up trigger creates rows as the table owner.
A web-only question could answer with nothing
When a web-only question returned no usable results, the app still generated an answer, which claimed a search had happened. Commit 3c8530f changed that path to mark the question failed with NO_WEB_SOURCES_MESSAGE instead. The case only became reachable after e39c03d surfaced provider failures. The cost is stated in the comment above the constant: the reader gets a failure where a thin answer would have been something.
What I would change
What I would change first is the deployment and verification story. The repository carries no deployment-to-commit parity check, the web search tier caps at 1,000 credits a month with no handling for the cap, and the fallback provider only runs if its key is configured, so that resilience path may never execute in production. I would also give the retrieval layer a recovery path when it misses a passage, because a correct answer depending on that passage becomes uncitable. These are open items in the project's own audit, not changes already made.
What I rejected
reversedReject unknown OAuth identities instead of provisioning them
The callback first self-healed a missing profile by upserting one server-side, then that path was removed.
Read the full decision