diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 2cbbb7a..cf7db6d 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,8 +1,14 @@
# Architecture
-Tutor is one Electron desktop app for one reader on one machine. It generates a book chapter by chapter, and each chapter is shaped by the feedback and quiz results of the one before it. Everything below follows from that, and from there being exactly one writer and no cloud.
+Tutor is one Electron desktop app for one reader on one machine. It generates a book chapter by chapter, and each chapter is shaped by the feedback and quiz results of the one before it.
-Start here, then follow the links. Domain words are defined in [CONTEXT.md](CONTEXT.md), decisions and their costs in [docs/adr/](docs/adr/README.md), and the HTTP surface in [docs/api-routes.md](docs/api-routes.md), which is generated from the route registry rather than written by hand.
+Everything below follows from those two facts. There is exactly one writer, and there is no cloud.
+
+Start here, then follow the links.
+
+- Domain words are defined in [CONTEXT.md](CONTEXT.md).
+- Decisions and what each one cost are in [docs/adr/](docs/adr/README.md).
+- The HTTP surface is in [docs/api-routes.md](docs/api-routes.md), generated from the route registry, never written by hand.
## 1. What talks to what
@@ -27,10 +33,14 @@ flowchart LR
server --> local
```
-The Fastify server is embedded in the Electron main process rather than deployed anywhere. It binds `127.0.0.1` on a free port at launch, or 3147 when run standalone with `pnpm dev:server`. The library is plain Markdown and YAML under the OS data directory, which is [ADR 0001](docs/adr/0001-filesystem-as-the-database.md). Narration is synthesized locally instead of by a metered cloud service, which is [ADR 0003](docs/adr/0003-local-kokoro-tts.md).
+The Fastify server runs inside the Electron main process. It is not deployed anywhere. It binds `127.0.0.1` on a free port at launch, or port 3147 when run standalone with `pnpm dev:server`.
+
+The library is plain Markdown and YAML under the OS data directory ([ADR 0001](docs/adr/0001-filesystem-as-the-database.md)). Narration is synthesized locally instead of by a metered cloud service ([ADR 0003](docs/adr/0003-local-kokoro-tts.md)).
## 2. The server hexagon
+The core never does I/O. It talks to 15 small interfaces, the ports, and each port has one or more adapters that do the real work.
+
```mermaid
flowchart LR
subgraph core["server core"]
@@ -44,18 +54,18 @@ flowchart LR
subgraph ports["ports/ (15 interfaces)"]
direction TB
- pStore["BookRepository ArtifactStore LibraryMigrator JobJournal KeyVault"]
- pAi["TextGeneration ImageGeneration"]
- pMedia["SpeechSynthesis AudioAssembly DiagramRenderer EpubImport EpubExport"]
- pSys["BackgroundTasks Clock OsFileManager"]
+ pStore["books and files BookRepository, ArtifactStore, LibraryMigrator, JobJournal, KeyVault"]
+ pAi["AI TextGeneration, ImageGeneration"]
+ pMedia["media SpeechSynthesis, AudioAssembly, DiagramRenderer, EpubImport, EpubExport"]
+ pSys["system BackgroundTasks, Clock, OsFileManager"]
end
subgraph adapters["adapters/ (the only I/O)"]
direction TB
aStore["fs-*.ts file-key-vault.ts"]
aAi["ai-sdk-text-generation.ts http-image-generation.ts"]
- aMedia["kokoro-speech-synthesis.ts ffmpeg-audio-assembly.ts kroki- and electron-diagram-renderer.ts epub2-import.ts, epub-gen-export.ts"]
- aSys["in-memory- and journalled-background-tasks.ts system-clock.ts, os-file-manager.ts"]
+ aMedia["kokoro, ffmpeg, kroki, electron, epub2, epub-gen"]
+ aSys["in-memory and journalled tasks, system-clock, os-file-manager"]
end
services --> pStore
@@ -68,7 +78,50 @@ flowchart LR
pSys --> aSys
```
-Nothing in the core names an adapter. [`server/composition-root.ts`](server/composition-root.ts) is the one place a real adapter is chosen, and `buildServer(overrides)` lets a test or the Electron shell substitute one. Every port ships an in-memory fake and a shared contract test, and every adapter that can be exercised without spending money or downloading a model runs that same contract, which is what stops a fake from drifting into a convenient fiction. See [`server/ports/README.md`](server/ports/README.md) and [`server/adapters/README.md`](server/adapters/README.md) for the full mapping, and [ADR 0005](docs/adr/0005-ai-sdk-behind-a-port.md) for why the AI SDK sits behind one.
+The boxes inside `ports/` are just themes to keep the diagram readable. They are not layers, and they do not exist in the code. What each port actually is, and why it exists, in one line each.
+
+**Books and files**
+
+| Port | What it does | Why it is a port |
+|---|---|---|
+| BookRepository | Reads and writes the library itself, book metadata, chapters, quizzes, feedback | Services never touch `fs`, and tests run against an in-memory library |
+| ArtifactStore | The binary files that belong to a book, covers, audio, EPUBs | Big blobs with their own lifecycle, kept apart from the YAML the repository owns |
+| LibraryMigrator | Upgrades an older on-disk library to the current schema at boot | Works on raw YAML that may not validate yet, so it sits below the repository |
+| JobJournal | One file per long-running job, so a restart can resume it | Added as a decorator, the in-memory task adapter never had to change |
+| KeyVault | Stores the reader's API keys | Keys live in one place and never end up in logs or the journal |
+
+**AI**
+
+| Port | What it does | Why it is a port |
+|---|---|---|
+| TextGeneration | Every prompt to a language model, streaming and structured | The only doorway to AI in the whole app. One fake makes everything testable without a key |
+| ImageGeneration | Generates book cover images | Same idea as TextGeneration with a smaller surface |
+
+**Media**
+
+| Port | What it does | Why it is a port |
+|---|---|---|
+| SpeechSynthesis | Turns chapter text into narration audio | The real model is a 100MB download. The fake keeps tests instant |
+| AudioAssembly | Stitches chapter audio into one M4B audiobook | ffmpeg is a separate binary with its own failure modes |
+| DiagramRenderer | Renders Mermaid blocks to images for EPUB export | The app renders offscreen in Electron, dev uses kroki. One interface hides which |
+| EpubImport | Parses an uploaded EPUB into plain book data | Returns data only. Saving it is the service's job |
+| EpubExport | Builds an EPUB file from rendered chapters | Wraps a CJS library with awkward packaging, quarantined here |
+
+**System**
+
+| Port | What it does | Why it is a port |
+|---|---|---|
+| BackgroundTasks | The task tray. Start a job, report progress, cancel | Long jobs outlive a request, and the UI watches them over SSE |
+| Clock | The current time and fresh ids | Tests pin time instead of sleeping |
+| OsFileManager | Reveals a file in Finder | The one place the app shells out to `open` |
+
+Three rules hold all of this together.
+
+- Nothing in the core names an adapter. [`server/composition-root.ts`](server/composition-root.ts) is the one place a real adapter is chosen, and `buildServer(overrides)` lets a test swap any of them out.
+- Every port ships an in-memory fake and a shared contract test.
+- Every adapter that can run without spending money or downloading a model runs that same contract test. That is what keeps a fake honest about how the real adapter behaves.
+
+See [`server/ports/README.md`](server/ports/README.md), [`server/adapters/README.md`](server/adapters/README.md), and [ADR 0005](docs/adr/0005-ai-sdk-behind-a-port.md) for why the AI SDK sits behind a port.
## 3. How a request travels
@@ -83,7 +136,9 @@ flowchart LR
ports --> adapters["adapters/"]
```
-Components render and hooks decide. Every call to the server goes through [`client/api/`](client/README.md), and a raw `fetch` or `new EventSource` anywhere else is an ESLint error rather than a convention, because the client previously held eighty four scattered fetch calls and two competing reconnect policies.
+Components render. Hooks decide.
+
+Every call to the server goes through [`client/api/`](client/README.md). A raw `fetch` or `new EventSource` anywhere else is an ESLint error, not a convention. The client used to hold 84 scattered fetch calls and two competing reconnect policies, and the lint rule is what keeps that from coming back.
## 4. The adaptive loop
@@ -113,7 +168,9 @@ sequenceDiagram
Note over Server,AI: chapter 2 is shaped by chapter 1's feedback and quiz result
```
-Chapters are generated one at a time rather than up front, and the quiz exists partly to cover the generation latency. That is [ADR 0002](docs/adr/0002-just-in-time-chapter-generation.md). If the app is closed mid-generation the work is not lost, because jobs are journalled to disk and resumed at the next boot, which is [ADR 0008](docs/adr/0008-persisted-job-journal.md).
+Chapters are generated one at a time, not up front, and the quiz exists partly to cover the generation wait ([ADR 0002](docs/adr/0002-just-in-time-chapter-generation.md)).
+
+Closing the app mid-generation loses nothing. Jobs are journalled to disk and resumed at the next boot ([ADR 0008](docs/adr/0008-persisted-job-journal.md)).
## 5. The dependency rule
@@ -126,11 +183,13 @@ flowchart TD
shared -. "ESLint error" .-> client
```
-`shared/` is the dependency root and imports neither side. It holds the Zod schemas, the status predicates, the HTTP contract types, and the SSE event unions, so the two halves of the app validate against the same definitions. This is one package shaped like a monorepo rather than real workspaces, which is [ADR 0004](docs/adr/0004-single-package-monorepo-shaped.md), and the folders are already package-shaped if that ever needs to change.
+`shared/` is the root. It imports neither side. It holds the Zod schemas, the status predicates, the HTTP contract types, and the SSE event unions, so both halves of the app validate against the same definitions.
+
+This is one package shaped like a monorepo, not real workspaces ([ADR 0004](docs/adr/0004-single-package-monorepo-shaped.md)). The folders are already package-shaped if that ever needs to change.
## Deliberately out of scope
-Observability and telemetry, a security-hardening pass, and release engineering were all considered and declined. The app runs locally on one machine, holds one reader's data, has no cloud component, and has no multi-user surface, so each of those would add machinery with nothing to protect or measure. The reasoning is recorded in [ADR 0004](docs/adr/0004-single-package-monorepo-shaped.md) rather than left as an unexplained gap.
+Observability, a security-hardening pass, and release engineering were considered and declined. The app runs on one machine, holds one reader's data, and has no cloud component, so each of those would add machinery with nothing to protect or measure. The reasoning is in [ADR 0004](docs/adr/0004-single-package-monorepo-shaped.md) so the gap reads as a decision, not an oversight.
## Where to read next
diff --git a/CLAUDE.md b/CLAUDE.md
index 60a2486..99547c9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,6 +1,6 @@
# Tutor — Adaptive Learning Library
-AI-generated books tailored to your learning style. Books are generated chapter-by-chapter with a feedback loop: after each chapter, quiz questions and feedback shape how subsequent chapters are generated. The book literally rewrites itself based on how you're learning.
+AI-generated books tailored to your learning style. Books are generated chapter by chapter with a feedback loop. After each chapter, quiz results and feedback shape how the next one is generated. The book literally rewrites itself based on how you're learning.
## Start here
@@ -15,14 +15,14 @@ AI-generated books tailored to your learning style. Books are generated chapter-
## How It Works
-1. **Create a book** — Enter a topic + prompt, AI generates a table of contents
-2. **Approve the TOC** — Review, edit, reorder chapters, then approve
-3. **Read chapter-by-chapter** — Quick, digestible chapters (~1,500 words, 5-10 min read) teaching specific concepts
-4. **Inline chat** — Select any text to slide out a chat panel for deeper AI explanation, then return to where you left off
-5. **Feedback** — After finishing a chapter, give feedback on what resonated/didn't
-6. **Generation triggered** — Submitting feedback triggers next chapter generation in the background
-7. **Quiz while waiting** — Optional 3-question quiz to test retention and aid memory while next chapter generates
-8. **Adaptive** — Next chapter incorporates feedback + quiz results (wrong answers trigger brief recap at start)
+1. **Create a book.** Enter a topic and a prompt, and the AI generates a table of contents.
+2. **Approve the TOC.** Review, edit, and reorder chapters, then approve.
+3. **Read chapter by chapter.** Quick, digestible chapters, roughly 1,500 words and a 5 to 10 minute read, each teaching specific concepts.
+4. **Inline chat.** Select any text to slide out a chat panel for a deeper AI explanation, then return to where you left off.
+5. **Feedback.** After finishing a chapter, say what resonated and what didn't.
+6. **Generation triggered.** Submitting feedback starts the next chapter generating in the background.
+7. **Quiz while waiting.** An optional 3-question quiz tests retention while the next chapter generates.
+8. **Adaptive.** The next chapter incorporates the feedback and quiz results, and a wrong answer triggers a brief recap at its start.
## Repo layout
@@ -62,18 +62,18 @@ docs/ ADRs, generated routes, plans → docs/adr/README.md
## Key Design Decisions
-- **Single-user app** — No auth, no concurrency concerns
-- **Chapter length** — ~1,500 words (5-10 min), flex longer when content demands
-- **TOC approval** — Step-by-step wizard before generation begins
-- **Progress tracking** — Scroll-based auto-tracking (completed at ≥90%)
-- **Generation flow** — Just-in-time: one chapter at a time, quiz masks latency ([ADR 0002](docs/adr/0002-just-in-time-chapter-generation.md))
-- **Background work** — A `BackgroundTask` in memory, journalled to disk by the `JobJournal` port so an interrupted one can resume at the next boot ([ADR 0008](docs/adr/0008-persisted-job-journal.md))
-- **If the app restarts mid-generation** — `generate-all` and audiobook jobs resume from disk without redoing finished work; a single interrupted chapter surfaces in the reader's retry panel
-- **Inline chat** — Select any text to open a slide-out panel for AI-powered deeper explanation; dismissing returns to reading position
+- **Single-user app.** No auth and no concurrency concerns.
+- **Chapter length.** Roughly 1,500 words, a 5 to 10 minute read, flexing longer when the content demands it.
+- **TOC approval.** A step-by-step wizard runs before any generation begins.
+- **Progress tracking.** Scroll-based auto-tracking, with a chapter completed at 90 percent or more.
+- **Generation flow.** Just in time, one chapter at a time, with the quiz masking the latency ([ADR 0002](docs/adr/0002-just-in-time-chapter-generation.md)).
+- **Background work.** A `BackgroundTask` lives in memory and is journalled to disk by the `JobJournal` port, so an interrupted one can resume at the next boot ([ADR 0008](docs/adr/0008-persisted-job-journal.md)).
+- **Restart mid-generation.** `generate-all` and audiobook jobs resume from disk without redoing finished work. A single interrupted chapter surfaces in the reader's retry panel.
+- **Inline chat.** Selecting text opens a slide-out panel for a deeper AI explanation, and dismissing it returns to the reading position.
## Electron Packaging
-This is an Electron app using `vite-plugin-electron`. Three modes exist with different behaviors:
+This is an Electron app using `vite-plugin-electron`. Three modes exist with different behaviors.
| Mode | Command | Renderer loads from | API routing | Origin header |
|------|---------|--------------------|--------------|----|
@@ -83,11 +83,12 @@ This is an Electron app using `vite-plugin-electron`. Three modes exist with dif
### Critical conventions
-- **Address**: Always use `127.0.0.1` (not `localhost`) for server communication — avoids IPv6 mismatch on macOS
-- **CORS**: Server must accept `Origin: null` (file:// protocol) and any `localhost`/`127.0.0.1` origin — enforced in `server/index.ts:isAllowedOrigin()`
-- **CSP**: Both `index.html` meta tag and `electron/main.ts` header must allow `http://localhost:*` AND `http://127.0.0.1:*` in `connect-src`
-- **pnpm + electron-builder**: `.npmrc` requires `node-linker=hoisted`. The electron build bundles the unified/remark/rehype ecosystem into `dist-electron/` (via rollup) since electron-builder can't resolve their deep transitive deps. The bundle list is in `vite.config.ts` `external()`. CJS packages (fastify, etc.) stay external — if a CJS transitive dep is missing, add it to `package.json` `dependencies` (e.g., `json-schema-ref-resolver` for fastify). For CJS packages imported dynamically, handle the double-default: `mod.default?.default ?? mod.default` (see `epub-gen-memory` import in `server/adapters/epub-gen-export.ts`).
-- **Never modify `index.html` or `package.json` to match build output** — `dist/` is the build target, source files must keep source references (`/client/app/main.tsx`)
+- **Address.** Always use `127.0.0.1`, never `localhost`, for server communication. This avoids an IPv6 mismatch on macOS.
+- **CORS.** The server must accept `Origin: null` (the file protocol) and any `localhost` or `127.0.0.1` origin. Enforced in `server/index.ts:isAllowedOrigin()`.
+- **CSP.** Both the `index.html` meta tag and the `electron/main.ts` header must allow `http://localhost:*` and `http://127.0.0.1:*` in `connect-src`.
+- **pnpm + electron-builder.** `.npmrc` requires `node-linker=hoisted`. The electron build bundles the unified, remark, and rehype ecosystem into `dist-electron/` via rollup, because electron-builder cannot resolve their deep transitive deps. The bundle list is in `vite.config.ts` `external()`.
+- **CJS packages stay external.** If a CJS transitive dep is missing, add it to `package.json` `dependencies`, the way `json-schema-ref-resolver` is there for fastify. A CJS package imported dynamically needs the double-default dance, `mod.default?.default ?? mod.default`, as in `server/adapters/epub-gen-export.ts`.
+- **Never modify `index.html` or `package.json` to match build output.** `dist/` is the build target, and source files must keep source references (`/client/app/main.tsx`).
## Development
@@ -106,47 +107,47 @@ pnpm mcp:dev # MCP server, needs dev:server on 3147 (see .mcp.json)
## Conventions
-- Zod domain schemas live in `shared/`, the single source of truth for both sides
-- YAML for all metadata, Markdown for chapter content
-- Vercel AI SDK is reached only through the `TextGeneration` port, never imported outside `server/adapters/`
-- Tests colocated with source files (`*.test.ts`)
-- **TDD** — tests land before or with implementation, visible in commit order. Contract test before adapter, service test before service, api-client test before the client function
-- Path aliases: `@client/*` → `client/*`, `@server/*` → `server/*`, `@shared/*` → `shared/*`
-- Domain names come from `CONTEXT.md`. Do not invent a synonym for a word that already has an owner
+- Zod domain schemas live in `shared/`, the single source of truth for both sides.
+- YAML for all metadata, Markdown for chapter content.
+- The Vercel AI SDK is reached only through the `TextGeneration` port and never imported outside `server/adapters/`.
+- Tests are colocated with source files (`*.test.ts`).
+- **TDD.** Tests land before or with implementation, visible in commit order. Contract test before adapter, service test before service, api-client test before the client function.
+- Path aliases: `@client/*` → `client/*`, `@server/*` → `server/*`, `@shared/*` → `shared/*`.
+- Domain names come from `CONTEXT.md`. Do not invent a synonym for a word that already has an owner.
## Domain & Architecture
These are the rules new and refactored code follows. The server conforms today. The client conforms on the api boundary and is still converging elsewhere.
-- **Ubiquitous domain language**: `Book`, `Chapter`, `TOC`, `Feedback`, `Quiz`, `Progress`, `LearningProfile`, `Audiobook`, `BackgroundTask`. Use these names everywhere — schemas, services, components, prompts, UI copy. `CONTEXT.md` is the register
-- **Pure domain core** in `shared/` and `server/domain/` — Zod types and pure functions only. No `fs`, `fetch`, AI SDK imports, or env vars inside the domain
-- **Ports for every external dependency**: each gets a single named module the rest of the app depends on by shape, not by SDK. Every port ships an in-memory fake and a contract test
-- **Adapters do the I/O**: only the adapter touches the SDK, library, child process, or filesystem. Swappable and testable in isolation
-- **Routes are thin**: parse input → call a service → return result. No business logic, no direct `fs` or SDK calls in `server/routes/*.ts`
-- **Frontend goes through one client**: components import from `client/api/`, not raw `fetch`. A raw `fetch` or `new EventSource` outside `client/api/` is an ESLint error. New endpoints get a function in the client
-- **No new SDK sprinkling**: when adding a third-party SDK, wrap it behind a port first, then consume the port from services
+- **Ubiquitous domain language.** `Book`, `Chapter`, `TOC`, `Feedback`, `Quiz`, `Progress`, `LearningProfile`, `Audiobook`, `BackgroundTask`. Use these names everywhere, in schemas, services, components, prompts, and UI copy. `CONTEXT.md` is the register.
+- **Pure domain core** in `shared/` and `server/domain/`. Zod types and pure functions only. No `fs`, `fetch`, AI SDK imports, or env vars inside the domain.
+- **Ports for every external dependency.** Each gets a single named module the rest of the app depends on by shape, not by SDK. Every port ships an in-memory fake and a contract test.
+- **Adapters do the I/O.** Only the adapter touches the SDK, library, child process, or filesystem. Swappable and testable in isolation.
+- **Routes are thin.** Parse input, call a service, return the result. No business logic and no direct `fs` or SDK calls in `server/routes/*.ts`.
+- **The frontend goes through one client.** Components import from `client/api/`, and a raw `fetch` or `new EventSource` outside it is an ESLint error. A new endpoint gets a function in the client.
+- **No new SDK sprinkling.** When adding a third-party SDK, wrap it behind a port first, then consume the port from services.
## UI / Frontend Design
### Desktop-First Design
-- Optimize for 1280–2560px desktop resolutions — no mobile-first layouts
-- Keyboard-first navigation with shortcuts everywhere (use `lucide-react` icons + ``)
-- Horizontal layouts: sidebars, resizable panels (shadcn `ResizablePanelGroup`), command palettes (`cmdk`)
-- Fluid typography/spacing for large screens: `text-3xl` → `text-4xl` on `lg`, `container mx-auto max-w-7xl`
-- Minimum layout target: `min-width: 1024px`
+- Optimize for 1280 to 2560px desktop resolutions, no mobile-first layouts.
+- Keyboard-first navigation with shortcuts everywhere (use `lucide-react` icons + ``).
+- Horizontal layouts: sidebars, resizable panels (shadcn `ResizablePanelGroup`), command palettes.
+- Fluid typography and spacing for large screens: `text-3xl` → `text-4xl` on `lg`, `container mx-auto max-w-7xl`.
+- Minimum layout target: `min-width: 1024px`.
### shadcn/ui + Tailwind v4
-- Use CVA for component variants, `cn()` for class merging, CSS variables from theme
-- Respect system dark mode via `prefers-color-scheme` + manual toggle with shadcn theme provider
-- Build using composition: small shadcn-extended primitives first (e.g., ` `, ``), then compose pages
-- Use CVA to add custom variants (e.g., button with `glass`, `command`)
-- Keep logic out of UI files — prefer hooks + context
+- Use CVA for component variants, `cn()` for class merging, CSS variables from theme.
+- Respect system dark mode via `prefers-color-scheme` plus a manual toggle with the shadcn theme provider.
+- Build by composition, small shadcn-extended primitives first, then compose pages.
+- Use CVA to add custom variants, such as a button with `glass` or `command`.
+- Keep logic out of UI files. Prefer hooks and context.
### Page Layout Patterns
-- **Header**: Centered title only, draggable region (`-webkit-app-region: drag`), no navigation buttons inside header
-- **Back button**: Absolute-positioned overlay on the content area below the header — `absolute left-6 top-3 z-20` on a plain `` with `text-content-muted opacity-50 hover:opacity-100`. See `client/features/creation/components/CreationView.tsx` as the reference pattern.
+- **Header**: centered title only, a draggable region (`-webkit-app-region: drag`), and no navigation buttons inside it.
+- **Back button**: an absolute-positioned overlay on the content area below the header, `absolute left-6 top-3 z-20` on a plain `` with `text-content-muted opacity-50 hover:opacity-100`. `client/features/creation/components/CreationView.tsx` is the reference pattern.
### Visual Aesthetic
-- Clean, minimal aesthetic inspired by Raycast, Linear, Obsidian
-- Subtle glassmorphism on floating panels: `backdrop-blur-md bg-background/80 border-border/50`
-- Custom window chrome: no default browser titlebar; implement draggable region with `-webkit-app-region: drag`
+- Clean, minimal aesthetic inspired by Raycast, Linear, and Obsidian.
+- Subtle glassmorphism on floating panels: `backdrop-blur-md bg-background/80 border-border/50`.
+- Custom window chrome, no default browser titlebar, with a draggable region via `-webkit-app-region: drag`.
diff --git a/CONTEXT.md b/CONTEXT.md
index 9d9fac6..db70108 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -2,9 +2,9 @@ Up: [ARCHITECTURE.md](ARCHITECTURE.md)
# Domain language
-The words this codebase uses, and the type or module that owns each one. Names here are the ubiquitous language. They appear unchanged in schemas, services, components, prompts, and UI copy, and a synonym for one of them is a bug in the writing rather than a stylistic choice.
+The words this codebase uses, and the type or module that owns each one. These names appear unchanged in schemas, services, components, prompts, and UI copy. Do not invent synonyms for them.
-Several words in this domain are overloaded, some of them badly. The later sections exist because those collisions are real and permanent, so the fix is to qualify them consistently rather than to rename around them.
+Some words carry more than one meaning. Those collisions are permanent, so the sections below say how to qualify each one instead of renaming around them.
## Core domain
@@ -44,21 +44,18 @@ Both keep their names. The domain type is never renamed to avoid the collision,
**Job** and **task** are not synonyms. A background task is the live, in-memory unit the tray shows. A job is its persisted record on disk, written by the `JobJournal` port so an interrupted task can be resumed after a restart. The schemas are `GenerationJobSchema` and friends in [`shared/domain.ts`](shared/domain.ts), and `GenerationJobType` covers every `TaskType` plus `generate-chapter`, which is the just-in-time single chapter path that never went through background tasks. Use "job" only when you mean the durable record.
-**Section** is an intra-chapter slice used for pagination and reader navigation, in `client/lib/split-sections.ts` and `client/features/reader/hooks/useSectionNavigation.ts`. It is never a synonym for Chapter.
+The rest of the overloads fit in one table.
-**Review** is two unrelated things. `toc_review` is the BookStatus in which a generated TOC is waiting for the reader to approve it. Smart Review is the spaced-repetition flow that re-quizzes a reader on questions they missed, in `client/features/quiz/components/SmartReviewFlow.tsx`.
-
-**Prompt** and **brief** are different lengths of the same idea. A `prompt` is the reader's short topic request stored on `BookMeta`. A `brief` is the long agentic-generation specification stored per book and consumed by the MCP authoring services.
-
-**Summary** is either a `ChapterSummary`, which is cross-chapter context fed into generation, or the book-completion summary the reader sees at the end. Qualify which.
-
-**Reference** is source material saved for agentic generation, as `ReferenceEntry` in [`shared/domain.ts`](shared/domain.ts). It is not a citation.
-
-**Narration** is chapter Markdown transformed for speech, in [`server/services/markdown-to-narration.ts`](server/services/markdown-to-narration.ts). It is a derived form of the chapter text and not the chapter text itself.
-
-**Progress** appears three ways, as reading scroll progress, as `TaskProgress` on a background task, and as skill progress on the profile. Always qualify it.
-
-**Error kind** is `AiErrorKind` in [`shared/responses.ts`](shared/responses.ts), one of `auth-failed`, `rate-limited`, `overloaded`, `timed-out`, `network-failed`, `content-refused`, or `unknown`. It classifies why an AI call failed, which is what decides whether the adapter retries. It is not a status.
+| Word | It can mean | How to tell |
+|---|---|---|
+| **Section** | An intra-chapter slice used for pagination and reader navigation | Never a synonym for Chapter. Lives in `client/lib/split-sections.ts` |
+| **Review** | `toc_review`, the BookStatus waiting for TOC approval, or Smart Review, the spaced-repetition re-quiz flow | Unrelated features. Name the one you mean |
+| **Prompt** vs **brief** | A `prompt` is the reader's short topic request on `BookMeta`. A `brief` is the long generation spec the MCP authoring services consume | Length and audience |
+| **Summary** | `ChapterSummary`, cross-chapter context fed into generation, or the completion summary the reader sees at the end | Qualify which |
+| **Reference** | Source material saved for agentic generation, `ReferenceEntry` | Not a citation |
+| **Narration** | Chapter Markdown transformed for speech | A derived form, never the chapter text itself |
+| **Progress** | Reading scroll progress, `TaskProgress` on a background task, or skill progress on the profile | Always qualify |
+| **Error kind** | `AiErrorKind`, why an AI call failed, `auth-failed` through `unknown` | Decides whether the adapter retries. Not a status |
## Numbering
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f97de51..dc7ab66 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,19 +1,14 @@
# Contributing to Tutor
-Thank you for taking the time to check out Tutor! We're grateful for the interest and support from the community. This project is released under the GNU General Public License v3.0 (GPLv3), which means you're free to use, modify, and distribute it as long as you comply with the license terms.
+Tutor is released under the GNU General Public License v3.0. You are free to use, modify, and distribute it under the license terms in [LICENSE](LICENSE).
-## Our Contribution Policy
-To maintain full control over the codebase—particularly for licensing, copyright, and project direction reasons—we're not accepting external contributions like pull requests, patches, or code changes at this time. This helps avoid potential complications with relicensing, intellectual property, or integrating code that might not align perfectly with our vision.
+## Contribution policy
-We understand this might disappoint some folks, and we're sorry if that's the case! It's not a reflection on your skills or ideas—it's just how we're managing the project right now to keep things sustainable.
+This project does not accept external pull requests, patches, or code changes. Keeping a single author over the codebase keeps licensing, copyright, and project direction simple. If the project's needs change, this policy can too.
-## Ways You Can Still Help
-While we're not merging code, there are other great ways to engage:
-- **Fork and Customize**: Feel free to fork the repo and make it your own. That's the power of open source—build on it, experiment, and share your version if you like!
-- **Report Issues or Bugs**: If you spot problems, please open an issue with details (e.g., steps to reproduce, environment). We'll review and address them when possible.
-- **Share Feedback or Ideas**: Use issues or GitHub Discussions (if enabled) for suggestions. We might incorporate them ourselves if they fit.
-- **Spread the Word**: Star the repo, share it with others, or use it in your projects—we'd love to hear how it's helping.
+## Ways to engage
-If circumstances change (e.g., the project grows or our needs evolve), we may open up to contributions in the future. For now, thanks for understanding and respecting this approach.
-
-See the [LICENSE](LICENSE) file for full details on GPLv3.
\ No newline at end of file
+- **Fork it.** Make it your own, experiment, and share your version.
+- **Report bugs.** Open an issue with steps to reproduce and your environment.
+- **Share ideas.** Open an issue with a suggestion. Good ones may get built.
+- **Spread the word.** Star the repo or share what you built with it.
diff --git a/client/README.md b/client/README.md
index 1b08690..9523a42 100644
--- a/client/README.md
+++ b/client/README.md
@@ -18,7 +18,11 @@ The React 19 renderer, built with Vite and rendered inside Electron's sandboxed
## Rules ESLint enforces
-Two rules here are lint errors rather than conventions. A file outside `client/api/` may not call `fetch`. The client used to hold eighty four scattered fetch calls, and the only durable reason it now holds none is that adding one fails the build. The same rule covers `new EventSource`, because the background task stream used to be constructed in two separate components, each with its own idea of when to reconnect. Reach the server through `client/api` in both cases.
+Two rules here are lint errors rather than conventions.
+
+A file outside `client/api/` may not call `fetch`. The client used to hold eighty four scattered fetch calls, and the only durable reason it now holds none is that adding one fails the build.
+
+The same rule covers `new EventSource`, because the background task stream used to be constructed in two separate components, each with its own idea of when to reconnect. Reach the server through `client/api` in both cases.
`client/` may also not import from `server/`, and it may not import `@shared/node/*`. That corner is Node-only, and importing it from the renderer would pull `process` into the client bundle.
diff --git a/client/features/README.md b/client/features/README.md
index 805a0bb..4708e9b 100644
--- a/client/features/README.md
+++ b/client/features/README.md
@@ -21,10 +21,12 @@ One folder per user-facing capability. Each owns its own components, hooks, and
## Rules
-Logic lives in hooks, components render. All server access goes through `client/api`, so no feature holds a raw `fetch` call. Shared primitives live in `components/ui/`, available to every feature. Beyond that, a feature imports directly from whichever feature it needs, `reader` reaches into `audiobook`, `chat`, and `markdown`, `settings` reaches into `profile` and `audiobook`, rather than the two being merged into one folder.
+Logic lives in hooks. Components render. All server access goes through `client/api`, so no feature holds a raw `fetch` call.
-`library/dialogs/dialog-machine.ts` replaces the ad hoc dialog booleans the library page used to carry with a single reducer over a tagged union of the sixteen dialogs the library can show, and it is unit-tested at `dialog-machine.test.ts`.
+Shared primitives live in `components/ui/`, available to every feature. Beyond that, a feature imports directly from whichever feature it needs. For example, `reader` reaches into `audiobook`, `chat`, and `markdown`, and `settings` reaches into `profile` and `audiobook`, rather than the two being merged into one folder.
-Redux slices live in `client/store/`, one level up. RTK Query was not adopted, server state is fetched through `client/api` and cached in plain slices instead.
+`library/dialogs/dialog-machine.ts` replaces the ad hoc dialog booleans the library page used to carry with a single reducer over a tagged union of the sixteen dialogs the library can show. It is unit-tested at `dialog-machine.test.ts`.
+
+Redux slices live in `client/store/`, one level up. RTK Query was not adopted. Server state is fetched through `client/api` and cached in plain slices instead.
Related: [CONTEXT.md](../../CONTEXT.md), [client/README.md](../README.md)
diff --git a/docs/adr/0001-filesystem-as-the-database.md b/docs/adr/0001-filesystem-as-the-database.md
index 45da1c2..6020919 100644
--- a/docs/adr/0001-filesystem-as-the-database.md
+++ b/docs/adr/0001-filesystem-as-the-database.md
@@ -9,21 +9,26 @@ Tutor is a single-user desktop app. A book's content is Markdown chapters plus Y
## Decision
-Each book lives at `books/{id}/` under the OS data directory, holding `meta.yml`, `toc.yml`, `chapters/NN.md`, `progress.yml`, and `feedback/NN.yml`. [`createFsBookRepository`](../../server/adapters/fs-book-repository.ts) is the only adapter that touches these files, and every read is validated against a Zod schema before the caller sees it. Every write goes through [`fs-paths.ts`](../../server/adapters/fs-paths.ts)'s `writeYaml` helper, or an equivalent inline pattern for chapter Markdown, which writes a `.tmp` file first and renames it into place, so a reader never observes a half-written file. [`createRecoverFromCrash`](../../server/services/recover-from-crash.ts) runs once at boot. It sweeps any `.tmp` leftovers from an interrupted write and resets a book's status when the process died mid-generation.
+Each book lives at `books/{id}/` under the OS data directory, holding `meta.yml`, `toc.yml`, `chapters/NN.md`, `progress.yml`, and `feedback/NN.yml`.
+
+- [`createFsBookRepository`](../../server/adapters/fs-book-repository.ts) is the only adapter that touches these files. Every read is validated against a Zod schema before the caller sees it.
+- Every write goes through [`fs-paths.ts`](../../server/adapters/fs-paths.ts)'s `writeYaml` helper, or an equivalent inline pattern for chapter Markdown. It writes a `.tmp` file first and renames it into place, so a reader never observes a half-written file.
+- [`createRecoverFromCrash`](../../server/services/recover-from-crash.ts) runs once at boot. It sweeps any `.tmp` leftovers from an interrupted write and resets a book's status when the process died mid-generation.
`meta.yml` and `learning-profile.yml` also carry a schema version stamp that is checked on every read. That stamp was added after this decision. See [ADR 0007](0007-versioned-library-with-forward-only-migrations.md) for the migration path it enables.
## Consequences
**What this buys**
-- Chapters and metadata stay human-readable and git-friendly, and a book directory is trivially backed up by copying it.
+- Chapters and metadata stay human-readable and git-friendly.
+- A book directory is trivially backed up by copying it.
- EPUB import and export become plain file operations against the same layout.
**What this costs**
- There is no transaction across files, so a multi-file update can partially land if the process dies mid-write. Boot-time recovery narrows this risk, but it does not eliminate it.
- There is no query layer, so listing the library means reading every book's `meta.yml`.
- Invariants live in application code rather than in a schema the filesystem can enforce.
-- Crash safety is this app's own responsibility, which is acceptable because exactly one process holds the data directory at a time.
+- Crash safety is this app's own responsibility. That works because exactly one process holds the data directory at a time.
## Revisit when
diff --git a/docs/adr/0002-just-in-time-chapter-generation.md b/docs/adr/0002-just-in-time-chapter-generation.md
index 59bc965..aaae768 100644
--- a/docs/adr/0002-just-in-time-chapter-generation.md
+++ b/docs/adr/0002-just-in-time-chapter-generation.md
@@ -9,9 +9,17 @@ A chapter takes tens of seconds to generate. Pre-generating the whole book up fr
## Decision
-TOC approval triggers chapter 1's generation. Chapters then generate one at a time. [`generate-next-chapter.ts`](../../server/services/generate-next-chapter.ts) generates one chapter's text and quiz. Both flows below share it. [`chapter-generation-stream.ts`](../../server/services/chapter-generation-stream.ts) is the in-memory hub behind the single-chapter SSE routes, and it streams chapter text to a subscriber as the model generates it. Submitting feedback through [`submit-feedback.ts`](../../server/services/submit-feedback.ts) scores the reader's quiz answers and fires the next chapter's generation. Meanwhile, [`generate-quiz.ts`](../../server/services/generate-quiz.ts) produces a 3-question quiz over the chapter just read, for the reader to answer while that runs. A separate, opt-in `generate-all` background task in [`generate-all-chapters.ts`](../../server/services/generate-all-chapters.ts) generates every remaining chapter without the per-chapter quiz pause, for a reader who wants it all at once.
+TOC approval triggers chapter 1's generation. Chapters then generate one at a time.
-Each single-chapter generation is journalled while it streams and cleared once it settles, so a process that dies mid-stream leaves a record behind. There is no way to resume a partially streamed chapter with any provider, so the next boot seeds that chapter's generation with an explicit interrupted error the reader's error panel already shows. The opt-in `generate-all` task differs. It checkpoints progress and resumes on the next boot by re-reading how many chapters exist on disk. See [ADR 0008](0008-persisted-job-journal.md) for the persisted job journal behind both.
+- [`generate-next-chapter.ts`](../../server/services/generate-next-chapter.ts) generates one chapter's text and quiz. Both flows below share it.
+- [`chapter-generation-stream.ts`](../../server/services/chapter-generation-stream.ts) is the in-memory hub behind the single-chapter SSE routes. It streams chapter text to a subscriber as the model generates it.
+- Submitting feedback through [`submit-feedback.ts`](../../server/services/submit-feedback.ts) scores the reader's quiz answers and fires the next chapter's generation.
+- Meanwhile, [`generate-quiz.ts`](../../server/services/generate-quiz.ts) produces a 3-question quiz over the chapter just read, for the reader to answer while that runs.
+- A separate, opt-in `generate-all` background task in [`generate-all-chapters.ts`](../../server/services/generate-all-chapters.ts) generates every remaining chapter without the per-chapter quiz pause, for a reader who wants it all at once.
+
+Each single-chapter generation is journalled while it streams and cleared once it settles, so a process that dies mid-stream leaves a record behind. There is no way to resume a partially streamed chapter with any provider, so the next boot seeds that chapter's generation with an explicit interrupted error the reader's error panel already shows.
+
+The opt-in `generate-all` task differs. It checkpoints progress and resumes on the next boot by re-reading how many chapters exist on disk. See [ADR 0008](0008-persisted-job-journal.md) for the persisted job journal behind both.
## Consequences
@@ -26,4 +34,4 @@ Each single-chapter generation is journalled while it streams and cleared once i
## Revisit when
-Chapter generation drops to a few seconds. At that point, masking the latency with a quiz stops paying for the complexity it costs.
+Chapter generation drops to a few seconds. At that point, masking the latency with a quiz is not worth the complexity it adds.
diff --git a/docs/adr/0003-local-kokoro-tts.md b/docs/adr/0003-local-kokoro-tts.md
index 2a25382..08d59e9 100644
--- a/docs/adr/0003-local-kokoro-tts.md
+++ b/docs/adr/0003-local-kokoro-tts.md
@@ -9,7 +9,11 @@ Audiobooks need narration for an entire book, chapter by chapter. Cloud text-to-
## Decision
-Narration runs locally through [`kokoro-speech-synthesis.ts`](../../server/adapters/kokoro-speech-synthesis.ts), which loads the `onnx-community/Kokoro-82M-v1.0-ONNX` model at `q8` quantization using `kokoro-js` and `onnxruntime-node`. [`audiobook-installer.ts`](../../server/services/audiobook-installer.ts) downloads the model and a separate ffmpeg binary on first use, and it reports progress as a background task. Together those downloads total approximately 195 MB, 115 MB for the model and 80 MB for ffmpeg. [`ffmpeg-audio-assembly.ts`](../../server/adapters/ffmpeg-audio-assembly.ts) muxes the per-chapter WAV files into a single M4B with chapter markers once [`generate-audiobook.ts`](../../server/services/generate-audiobook.ts) has narrated every chapter. The voice list orders male voices first, and within Kokoro's American-male group, `am_michael` is pinned ahead of every other voice regardless of its position in Kokoro's own catalogue.
+Narration runs locally through [`kokoro-speech-synthesis.ts`](../../server/adapters/kokoro-speech-synthesis.ts), which loads the `onnx-community/Kokoro-82M-v1.0-ONNX` model at `q8` quantization using `kokoro-js` and `onnxruntime-node`.
+
+- [`audiobook-installer.ts`](../../server/services/audiobook-installer.ts) downloads the model and a separate ffmpeg binary on first use, and reports progress as a background task. Together those downloads total approximately 195 MB, 115 MB for the model and 80 MB for ffmpeg.
+- [`ffmpeg-audio-assembly.ts`](../../server/adapters/ffmpeg-audio-assembly.ts) muxes the per-chapter WAV files into a single M4B with chapter markers once [`generate-audiobook.ts`](../../server/services/generate-audiobook.ts) has narrated every chapter.
+- The voice list orders male voices first. Within Kokoro's American-male group, `am_michael` is pinned ahead of every other voice regardless of its position in Kokoro's own catalogue.
## Consequences
diff --git a/docs/adr/0004-single-package-monorepo-shaped.md b/docs/adr/0004-single-package-monorepo-shaped.md
index 4aed241..542d05a 100644
--- a/docs/adr/0004-single-package-monorepo-shaped.md
+++ b/docs/adr/0004-single-package-monorepo-shaped.md
@@ -9,12 +9,17 @@ One `package.json` used to serve the React app, the Fastify server, the Electron
## Decision
-The repo is split into top-level `client/`, `server/`, `shared/`, and `electron/` folders, reached through `@client/*`, `@server/*`, and `@shared/*` aliases declared in both [`tsconfig.json`](../../tsconfig.json) and [`vite.config.ts`](../../vite.config.ts). [`eslint.config.mjs`](../../eslint.config.mjs) enforces the boundary between them with the rule `@typescript-eslint/no-restricted-imports`, which forbids server code from importing client code and client code from importing server code. A second rule, `no-restricted-syntax`, forces every client-to-server call through `client/api/` rather than a raw `fetch` or `EventSource` constructed elsewhere. `shared/` may import neither zone. All of it still runs from one `package.json`, one lockfile, and one `node_modules`, with `node-linker=hoisted` set in `.npmrc`.
+The repo is split into top-level `client/`, `server/`, `shared/`, and `electron/` folders, reached through `@client/*`, `@server/*`, and `@shared/*` aliases declared in both [`tsconfig.json`](../../tsconfig.json) and [`vite.config.ts`](../../vite.config.ts).
+
+- [`eslint.config.mjs`](../../eslint.config.mjs) enforces the boundary between them with the rule `@typescript-eslint/no-restricted-imports`, which forbids server code from importing client code and client code from importing server code.
+- A second rule, `no-restricted-syntax`, forces every client-to-server call through `client/api/` rather than a raw `fetch` or `EventSource` constructed elsewhere.
+- `shared/` may import neither zone.
+- All of it still runs from one `package.json`, one lockfile, and one `node_modules`, with `node-linker=hoisted` set in `.npmrc`.
## Consequences
**What this buys**
-- It enforces the same import boundaries a real workspace would give, starting today, without adding the build risk a workspace migration would bring to the Electron packaging that [ADR 0006](0006-electron-packaging-constraints.md) describes.
+- It enforces the same import boundaries a real workspace would give, without the build risk a workspace migration would add to the Electron packaging described in [ADR 0006](0006-electron-packaging-constraints.md).
- It keeps one install and one test command, `pnpm test`, for the whole repo.
**What this costs**
@@ -25,4 +30,6 @@ The repo is split into top-level `client/`, `server/`, `shared/`, and `electron/
## Revisit when
-A second application wants to consume `shared/`, or CI time justifies per-package caching. The folders are already package-shaped for that move. Adding `pnpm-workspace.yaml` and a `package.json` per folder is mechanical once the import boundaries above have held for a while, but `node-linker=hoisted` and the `external()` allow-list in `vite.config.ts` need re-verifying across all three Electron modes first.
+A second application wants to consume `shared/`, or CI time justifies per-package caching. The folders are already package-shaped for that move.
+
+Adding `pnpm-workspace.yaml` and a `package.json` per folder is mechanical once the import boundaries above have held for a while. But `node-linker=hoisted` and the `external()` allow-list in `vite.config.ts` need re-verifying across all three Electron modes first.
diff --git a/docs/adr/0005-ai-sdk-behind-a-port.md b/docs/adr/0005-ai-sdk-behind-a-port.md
index 1697399..325e114 100644
--- a/docs/adr/0005-ai-sdk-behind-a-port.md
+++ b/docs/adr/0005-ai-sdk-behind-a-port.md
@@ -5,11 +5,21 @@ Date: 2026-07-21
## Context
-Calls to the Vercel AI SDK used to sit directly inside route and service modules, five files calling `generateObject` and `streamText` against the SDK itself, per the account in [`../../server/ports/text-generation.ts`](../../server/ports/text-generation.ts). Anthropic, OpenAI, and Google are all user-selectable providers on a per-call basis, so each of those call sites also resolved a provider and model on its own. Each caller hand-rolled its own timeout and abort handling rather than sharing one policy. Nothing that touched an AI model could be exercised in a test without a network call and a live key.
+Calls to the Vercel AI SDK used to sit directly inside route and service modules, five files calling `generateObject` and `streamText` against the SDK itself, per the account in [`../../server/ports/text-generation.ts`](../../server/ports/text-generation.ts). Anthropic, OpenAI, and Google are all user-selectable providers on a per-call basis, so each of those call sites also resolved a provider and model on its own.
+
+Each caller hand-rolled its own timeout and abort handling rather than sharing one policy. Nothing that touched an AI model could be exercised in a test without a network call and a live key.
## Decision
-One `TextGeneration` port now covers every shape a caller needs, `streamText`, `generateObject`, and `runToolConversation`, the last running a short tool-calling conversation and yielding only its text output. [`../../server/adapters/ai-sdk-text-generation.ts`](../../server/adapters/ai-sdk-text-generation.ts) is the only module allowed to import the `ai` package. It resolves provider and model, combines a caller's cancellation signal with a five-minute generation timeout, and retries through the policy in [`../../server/adapters/retry-policy.ts`](../../server/adapters/retry-policy.ts), explicitly setting `maxRetries: 0` on every SDK call so the SDK's own retry can never run underneath this adapter's. A failure maps to a typed `TextGenerationError` carrying one of seven `kind` values, `auth-failed`, `rate-limited`, `overloaded`, `timed-out`, `network-failed`, `content-refused`, or `unknown`. [`../../server/ports/text-generation.fake.ts`](../../server/ports/text-generation.fake.ts) scripts responses in memory, and [`../../server/ports/text-generation.contract.ts`](../../server/ports/text-generation.contract.ts) pins the fake's behavior. That contract is deliberately fake-only, since a real subject would spend money against a live provider on every run. Services depend on the port and routes stay thin. Prompt text lives in typed TypeScript template literals inside each service module rather than runtime markdown files, plus one shared fragment, [`../../server/prompts/formatting-rules.ts`](../../server/prompts/formatting-rules.ts), so prompts survive Electron bundling and type-check like any other code.
+One `TextGeneration` port now covers every shape a caller needs.
+
+- The shapes are `streamText`, `generateObject`, and `runToolConversation`. The last of those runs a short tool-calling conversation and yields only its text output.
+- [`../../server/adapters/ai-sdk-text-generation.ts`](../../server/adapters/ai-sdk-text-generation.ts) is the only module allowed to import the `ai` package.
+- That adapter resolves provider and model, combines a caller's cancellation signal with a five-minute generation timeout, and retries through the policy in [`../../server/adapters/retry-policy.ts`](../../server/adapters/retry-policy.ts). It explicitly sets `maxRetries: 0` on every SDK call, so the SDK's own retry can never run underneath this adapter's.
+- A failure maps to a typed `TextGenerationError` carrying one of seven `kind` values, `auth-failed`, `rate-limited`, `overloaded`, `timed-out`, `network-failed`, `content-refused`, or `unknown`.
+- [`../../server/ports/text-generation.fake.ts`](../../server/ports/text-generation.fake.ts) scripts responses in memory, and [`../../server/ports/text-generation.contract.ts`](../../server/ports/text-generation.contract.ts) pins the fake's behavior. That contract is deliberately fake-only, since a real subject would spend money against a live provider on every run.
+- Services depend on the port and routes stay thin.
+- Prompt text lives in typed TypeScript template literals inside each service module, rather than runtime markdown files, plus one shared fragment, [`../../server/prompts/formatting-rules.ts`](../../server/prompts/formatting-rules.ts). This way prompts survive Electron bundling and type-check like any other code.
## Consequences
diff --git a/docs/adr/0006-electron-packaging-constraints.md b/docs/adr/0006-electron-packaging-constraints.md
index 440a616..a6f9d6e 100644
--- a/docs/adr/0006-electron-packaging-constraints.md
+++ b/docs/adr/0006-electron-packaging-constraints.md
@@ -5,11 +5,24 @@ Date: 2026-07-21
## Context
-Electron packaging breaks in several independent ways. pnpm's symlinked store confuses electron-builder's dependency resolution. The unified and remark ecosystem ships ESM-only with deep transitive dependencies electron-builder cannot follow. The audiobook feature's ONNX runtime is a native addon, and `kokoro-js` pulls in a phonemizer that bundles the espeak-ng WASM build, neither of which bundles like ordinary JavaScript. On top of that, the three run modes, dev, preview, and a packaged build, load the renderer from three different origins.
+Electron packaging breaks in several independent ways.
+
+- pnpm's symlinked store confuses electron-builder's dependency resolution.
+- The unified and remark ecosystem ships ESM-only with deep transitive dependencies electron-builder cannot follow.
+- The audiobook feature's ONNX runtime is a native addon, and `kokoro-js` pulls in a phonemizer that bundles the espeak-ng WASM build. Neither bundles like ordinary JavaScript.
+- On top of that, the three run modes, dev, preview, and a packaged build, load the renderer from three different origins.
## Decision
-[`../../.npmrc`](../../.npmrc) sets `node-linker=hoisted` so electron-builder sees a flat `node_modules` instead of pnpm's symlinks. In [`../../vite.config.ts`](../../vite.config.ts), the Electron main process build's `external()` function bundles the unified, remark, rehype, mdast, and hast packages straight into `dist-electron/`, while CJS packages and the native audiobook dependencies (`kokoro-js`, `onnxruntime-node`, `onnxruntime-common`, `fluent-ffmpeg`, `phonemizer`, and the Hugging Face packages) stay external. [`../../package.json`](../../package.json)'s `build.files` lists those same native packages so electron-builder copies them into the packaged app, and `build.asarUnpack` unpacks `onnxruntime-node` from the asar archive. The embedded server always binds `127.0.0.1` on a free port, chosen by passing port `0` to `startServer` in [`../../electron/main.ts`](../../electron/main.ts). CORS in [`../../server/index.ts`](../../server/index.ts)'s `isAllowedOrigin` accepts a `null` origin and any `localhost` or `127.0.0.1` origin. `connect-src` in both [`../../index.html`](../../index.html) and `electron/main.ts` lists `http://127.0.0.1:*` and `http://localhost:*`. The source `index.html` always points at its source entry, never at build output. A dynamically imported CJS module, `epub-gen-memory` in [`../../server/adapters/epub-gen-export.ts`](../../server/adapters/epub-gen-export.ts), handles being double-wrapped under Node's ESM loader. [`../../scripts/bundle-fingerprint.sh`](../../scripts/bundle-fingerprint.sh) prints every bare specifier that survives into the built main bundle, run as a no-op check that a change to the bundle list has not let a new specifier leak through unbundled.
+- [`../../.npmrc`](../../.npmrc) sets `node-linker=hoisted`, so electron-builder sees a flat `node_modules` instead of pnpm's symlinks.
+- In [`../../vite.config.ts`](../../vite.config.ts), the Electron main process build's `external()` function bundles the unified, remark, rehype, mdast, and hast packages straight into `dist-electron/`. CJS packages and the native audiobook dependencies (`kokoro-js`, `onnxruntime-node`, `onnxruntime-common`, `fluent-ffmpeg`, `phonemizer`, and the Hugging Face packages) stay external.
+- [`../../package.json`](../../package.json)'s `build.files` lists those same native packages so electron-builder copies them into the packaged app, and `build.asarUnpack` unpacks `onnxruntime-node` from the asar archive.
+- The embedded server always binds `127.0.0.1` on a free port, chosen by passing port `0` to `startServer` in [`../../electron/main.ts`](../../electron/main.ts).
+- CORS in [`../../server/index.ts`](../../server/index.ts)'s `isAllowedOrigin` accepts a `null` origin and any `localhost` or `127.0.0.1` origin.
+- `connect-src` in both [`../../index.html`](../../index.html) and `electron/main.ts` lists `http://127.0.0.1:*` and `http://localhost:*`.
+- The source `index.html` always points at its source entry, never at build output.
+- A dynamically imported CJS module, `epub-gen-memory` in [`../../server/adapters/epub-gen-export.ts`](../../server/adapters/epub-gen-export.ts), handles being double-wrapped under Node's ESM loader.
+- [`../../scripts/bundle-fingerprint.sh`](../../scripts/bundle-fingerprint.sh) prints every bare specifier that survives into the built main bundle. It runs as a no-op check that a change to the bundle list has not let a new specifier leak through unbundled.
## Consequences
diff --git a/docs/adr/0007-versioned-library-with-forward-only-migrations.md b/docs/adr/0007-versioned-library-with-forward-only-migrations.md
index c0ec0bc..8ea6b2e 100644
--- a/docs/adr/0007-versioned-library-with-forward-only-migrations.md
+++ b/docs/adr/0007-versioned-library-with-forward-only-migrations.md
@@ -9,7 +9,14 @@ The library is plain YAML on the filesystem, and its shape used to be implied by
## Decision
-`meta.yml` on each book and `learning-profile.yml` globally now carry a `schemaVersion` field, defined in [`../../shared/schema-version.ts`](../../shared/schema-version.ts), with an absent field read as version 1, since every build before this one wrote neither file with a version at all. Each of the two gets its own ordered chain of pure forward-only steps, walked by `migrateForward` in [`../../server/migrations/migrate.ts`](../../server/migrations/migrate.ts) and documented in [`../../server/migrations/README.md`](../../server/migrations/README.md). A `LibraryMigrator` port, defined in [`../../server/ports/library-migrator.ts`](../../server/ports/library-migrator.ts) and implemented by [`../../server/adapters/fs-library-migrator.ts`](../../server/adapters/fs-library-migrator.ts), runs `migrate()` once inside `runStartupTasks` in [`../../server/index.ts`](../../server/index.ts), before crash recovery, because recovery reads and writes `meta.yml` through the current schema and would silently skip a book still at an old version. `buildServer` itself stays mutation-free, only `runStartupTasks` touches disk. The first time a document is migrated, the migrator writes a one-time backup of its exact original bytes alongside it, named `meta.yml.bak-v1` for every migration written so far, since version 1 is the only version any released build has ever produced. A document newer than the running build supports is never written to and is reported failed, raising `SchemaTooNewError` on any later direct read. Committed fixture libraries at old schema versions, under `server/migrations/__fixtures__/`, are the test corpus proving each step round-trips.
+`meta.yml` on each book and `learning-profile.yml` globally now carry a `schemaVersion` field, defined in [`../../shared/schema-version.ts`](../../shared/schema-version.ts). An absent field reads as version 1, since every build before this one wrote neither file with a version at all.
+
+- Each of the two gets its own ordered chain of pure forward-only steps, walked by `migrateForward` in [`../../server/migrations/migrate.ts`](../../server/migrations/migrate.ts) and documented in [`../../server/migrations/README.md`](../../server/migrations/README.md).
+- A `LibraryMigrator` port, defined in [`../../server/ports/library-migrator.ts`](../../server/ports/library-migrator.ts) and implemented by [`../../server/adapters/fs-library-migrator.ts`](../../server/adapters/fs-library-migrator.ts), runs `migrate()` once inside `runStartupTasks` in [`../../server/index.ts`](../../server/index.ts), before crash recovery. It has to run first, because recovery reads and writes `meta.yml` through the current schema and would silently skip a book still at an old version.
+- `buildServer` itself stays mutation-free. Only `runStartupTasks` touches disk.
+- The first time a document is migrated, the migrator writes a one-time backup of its exact original bytes alongside it, named `meta.yml.bak-v1` for every migration written so far, since version 1 is the only version any released build has ever produced.
+- A document newer than the running build supports is never written to and is reported failed, raising `SchemaTooNewError` on any later direct read.
+- Committed fixture libraries at old schema versions, under `server/migrations/__fixtures__/`, are the test corpus proving each step round-trips.
## Consequences
@@ -20,7 +27,7 @@ The library is plain YAML on the filesystem, and its shape used to be implied by
**What this costs**
-- There is no backward migration, so downgrading the app fails loudly with `SchemaTooNewError` rather than risk silently corrupting a newer library.
+- There is no backward migration, so downgrading the app fails loudly with `SchemaTooNewError` instead of silently corrupting a newer library.
- A book added to disk while the app is already running waits for the next boot before it is migrated.
## Revisit when
diff --git a/docs/adr/0008-persisted-job-journal.md b/docs/adr/0008-persisted-job-journal.md
index fb4803f..9bf06dc 100644
--- a/docs/adr/0008-persisted-job-journal.md
+++ b/docs/adr/0008-persisted-job-journal.md
@@ -5,11 +5,20 @@ Date: 2026-07-21
## Context
-Background tasks, EPUB export, cover generation, audiobook install and generation, and generating every remaining chapter, lived only in memory, tracked by `BackgroundTasks`. A restart stranded any job still running, and crash recovery could only reset a book's status rather than pick a job back up.
+Background tasks, meaning EPUB export, cover generation, audiobook install and generation, and generating every remaining chapter, lived only in memory, tracked by `BackgroundTasks`. A restart stranded any job still running, and crash recovery could only reset a book's status rather than pick a job back up.
## Decision
-[`../../server/ports/job-journal.ts`](../../server/ports/job-journal.ts) defines a `JobJournal` port, one YAML file per job under `{dataDir}/jobs/`, written through the same temp-then-rename `writeYaml` helper every other adapter uses. [`../../server/adapters/fs-job-journal.ts`](../../server/adapters/fs-job-journal.ts) is the real adapter. [`../../server/adapters/journalled-background-tasks.ts`](../../server/adapters/journalled-background-tasks.ts) composes a `JobJournal` onto an existing `BackgroundTasks` as a decorator, calling `record()` on `start()` and `clear()` on `succeed()`, `fail()`, and `cancel()`, rather than making `BackgroundTasks` itself asynchronous. The same `BackgroundTasks` contract test, unchanged, passes against the decorated adapter, which is the proof that adding persistence changed no observable behavior. At boot, [`../../server/services/resume-interrupted-jobs.ts`](../../server/services/resume-interrupted-jobs.ts) reads every surviving record after crash recovery finishes, from `runStartupTasks` in [`../../server/index.ts`](../../server/index.ts). Only `generate-all` and `generate-audiobook` auto-resume. Every other job type is marked cleanly retriable instead, either in the existing task tray or, for a chapter already mid-stream, as an interrupted generation the reader already knows how to surface. Resume never trusts the journalled checkpoint to decide what to redo. It recomputes the real start point from the book's own metadata on disk, so the checkpoint is advisory only, a progress label rather than a decision. Chapter generation restarts a whole chapter rather than resuming mid-stream, because, per [`../../server/services/chapter-generation-stream.ts`](../../server/services/chapter-generation-stream.ts), there is no way to resume a partially streamed chapter with any provider. `TUTOR_NO_AUTO_RESUME=1` is a debugging escape hatch that leaves every journal record untouched.
+[`../../server/ports/job-journal.ts`](../../server/ports/job-journal.ts) defines a `JobJournal` port, one YAML file per job under `{dataDir}/jobs/`, written through the same temp-then-rename `writeYaml` helper every other adapter uses.
+
+- [`../../server/adapters/fs-job-journal.ts`](../../server/adapters/fs-job-journal.ts) is the real adapter.
+- [`../../server/adapters/journalled-background-tasks.ts`](../../server/adapters/journalled-background-tasks.ts) composes a `JobJournal` onto an existing `BackgroundTasks` as a decorator, calling `record()` on `start()` and `clear()` on `succeed()`, `fail()`, and `cancel()`, rather than making `BackgroundTasks` itself asynchronous.
+- The same `BackgroundTasks` contract test, unchanged, passes against the decorated adapter. That is the proof that adding persistence changed no observable behavior.
+- At boot, [`../../server/services/resume-interrupted-jobs.ts`](../../server/services/resume-interrupted-jobs.ts) reads every surviving record after crash recovery finishes, from `runStartupTasks` in [`../../server/index.ts`](../../server/index.ts).
+- Only `generate-all` and `generate-audiobook` auto-resume. Every other job type is marked cleanly retriable instead, either in the existing task tray or, for a chapter already mid-stream, as an interrupted generation the reader already knows how to surface.
+- Resume never trusts the journalled checkpoint to decide what to redo. It recomputes the real start point from the book's own metadata on disk, so the checkpoint is advisory only, a progress label rather than a decision.
+- Chapter generation restarts a whole chapter rather than resuming mid-stream. Per [`../../server/services/chapter-generation-stream.ts`](../../server/services/chapter-generation-stream.ts), there is no way to resume a partially streamed chapter with any provider.
+- `TUTOR_NO_AUTO_RESUME=1` is a debugging escape hatch that leaves every journal record untouched.
## Consequences
diff --git a/docs/api-routes.md b/docs/api-routes.md
index 0ee0fdb..fc09c58 100644
--- a/docs/api-routes.md
+++ b/docs/api-routes.md
@@ -8,7 +8,14 @@ Routes are grouped by the module that registers them. The `HEAD` route Fastify p
## Why a generated table and not OpenAPI
-This file is a projection of the live Fastify route registry, regenerated by `pnpm docs:routes` and gated in CI, so the code stays the single source of truth and this table cannot describe an endpoint the server does not serve. An OpenAPI document would need converted JSON Schema attached to all 70 routes, and this server deliberately does not carry any. Request validation goes through Zod in [`server/http/parse.ts`](../server/http/parse.ts) rather than Fastify's ajv `schema.body`, because ajv changes the shape of the 400 body the client already handles and drops the Zod defaults and transforms the domain relies on. A specification would also describe an API with no external consumer, since the only client ships in this repo and imports the real Zod schemas and contract types out of `shared/`, which binds the two sides harder than anything generated from a spec could. Revisit this when a second client or an outside consumer appears. At that point the same `buildServer` registry walk, plus `zod-to-json-schema` over the contracts that already exist, emits a real specification under this same drift gate.
+This file is generated from the live route registry and gated in CI. The code is the single source of truth, so this table cannot describe an endpoint the server does not serve.
+
+OpenAPI would not add anything here today.
+
+- A spec needs JSON Schema attached to all 70 routes, and this server carries none. Validation goes through Zod in [`server/http/parse.ts`](../server/http/parse.ts), not Fastify's ajv `schema.body`, because ajv changes the 400 body the client handles and drops the Zod defaults the domain relies on.
+- There is no external consumer to hand a spec to. The only client ships in this repo and imports the same Zod schemas and contract types from `shared/`, which is a tighter contract than generated client code.
+
+Revisit when a second client or an outside consumer appears. The same `buildServer` registry walk plus `zod-to-json-schema` over the existing contracts can emit a real specification under this same drift gate.
### `server/index.ts`
diff --git a/e2e/README.md b/e2e/README.md
index ee584b6..4b8fb25 100644
--- a/e2e/README.md
+++ b/e2e/README.md
@@ -1,9 +1,10 @@
Up: [ARCHITECTURE.md](../ARCHITECTURE.md)
+
# End-to-end journeys
-Up: [../README.md](../README.md)
+This suite drives the real browser UI against the real Fastify server. Only the server's outermost adapters are swapped for fakes.
-This suite drives the real browser UI against the real Fastify server, with the server's outermost adapters swapped for fakes. It is the answer to a fair question a cold reader asks about a hexagonal codebase, which is whether the ports are load bearing or decorative. Every journey here boots the production `buildServer` and hands it fakes through the same `overrides` argument Electron uses to hand it a diagram renderer, and it works.
+Every journey boots the production `buildServer` and hands it fakes through the same `overrides` argument the Electron shell uses. The design reasoning lives in [design.md](design.md). This file is about using the suite.
## Running it
@@ -15,11 +16,12 @@ pnpm e2e create-book # one file
pnpm e2e --ui # Playwright's time-travel debugger
```
-The run builds `dist/` first, every time, in `global-setup.ts`. That is one code path rather than two, so a journey can never test yesterday's client. Set `E2E_SKIP_BUILD=1` while iterating on a journey to skip it.
+Useful flags.
-Set `E2E_VERBOSE=1` to see the Fastify server's log lines. They are suppressed by default because each test boots its own instance and the JSON request log drowns the reporter.
+- `E2E_SKIP_BUILD=1` skips the `dist/` build while you iterate. By default `global-setup.ts` rebuilds every run so a journey can never test yesterday's client.
+- `E2E_VERBOSE=1` shows the Fastify log lines, which are suppressed because every test boots its own server.
-`pnpm test` is a different thing and stays fast. It is the vitest unit and contract suite and it never opens a browser. The one file under `e2e/` that vitest DOES own is `support/scripted-text-generation.test.ts`, because that test needs no browser.
+`pnpm test` is a different suite. It is the fast vitest unit and contract run and it never opens a browser.
## What a journey gets
@@ -31,81 +33,30 @@ test('...', async ({ page, model, app }) => { /* ... */ })
| Fixture | What it is |
|---|---|
-| `page` | A browser page already pointed at this test's own server instance, so `page.goto('/')` just works |
-| `app` | `{ origin, dataDir, bookDir(id), fastify }`. `app.fastify.inject(...)` gives an API-level assertion without a second HTTP client |
-| `model` | The scripted `TextGeneration` this test's server is wired to. Add rules before the action that triggers a call, read `model.requests` after |
+| `page` | A browser page already pointed at this test's own server, so `page.goto('/')` just works |
+| `app` | `{ origin, dataDir, bookDir(id), fastify }`. Use `app.fastify.inject(...)` for an API-level assertion |
+| `model` | The scripted `TextGeneration` this server is wired to. Add rules before acting, read `model.requests` after |
-Plus the helpers:
+Plus the helpers.
- `support/seed.ts` puts books on disk through the real filesystem repository.
- `support/journeys/*.ts` are page objects that expose intent, such as `wizard.approveToc()` and `reader.finishChapter()`.
-- `fixtures/*.ts` are the content the scripted model returns, as TypeScript so `tsc` catches drift.
+- `fixtures/*.ts` hold the content the scripted model returns, written as TypeScript so `tsc` catches drift.
## Adding a journey
-1. Add a `*.spec.ts` under `e2e/journeys/`, importing `test` and `expect` from `../support/app.js`, never from `@playwright/test`. The re-export is what carries the fixtures.
-2. Get to your subject the cheap way. If the journey is about exporting a book, `seedBook(app.dataDir, ...)` rather than driving the whole creation wizard, so a wizard regression fails the wizard journey and not yours.
-3. Address the UI the way a screen reader does. `getByRole` first, `getByText` second, and never a CSS class.
-4. Assert with `await expect(locator)...`, which retries to a deadline. Never sleep.
-5. If your journey needs a model call nobody has scripted yet, add a rule to `support/default-script.ts` matching a phrase lifted verbatim from the server prompt that produces it, and put the content in `fixtures/`.
-
-## Why fakes rather than mocks
-
-The server is faked at its ports, not mocked at its HTTP boundary. Everything between the browser and the AI provider is the real thing, meaning real routes, real services, real domain rules, real YAML on a real disk. Only the four edges that would cost money, need a network, or need a 90MB binary are swapped, and they are swapped through `buildServer(overrides)`, the same seam production already has.
-
-Mocking `fetch` in the browser instead would have tested the client against a fiction of the server. This tests it against the server.
-
-Zero live-provider traffic is a property of the design rather than a promise. The real key vault is never constructed, the fake one lives only in memory, and the scripted model has no SDK import and throws on any call nobody wrote a fixture for. There is no path from a journey to a provider even if someone wanted one.
-
-## Why the model answers by intent
-
-`support/scripted-text-generation.ts` matches on the shape of a request, not on the order calls arrive in. Phase 2's `createFakeTextGeneration` answers strictly first-in first-out, which is right for a unit test that knows exactly how many calls the code under test makes. A journey does not know that. Approving a table of contents fires a skill classification, a chapter stream, and a quiz, in an order the UI decides and may reorder tomorrow, and a fixture list coupled to that order is the largest available source of flake.
-
-Rules are consulted newest first, so a test shadows a default with one line, which is also how failure injection works:
-
-```ts
-model.onStreamText({
- name: 'chapter 2 is rate limited',
- match: isChapterStreamFor(2),
- respond: { throws: new Error('rate limit exceeded') },
-})
-```
-
-`throws` takes any value and rethrows it unchanged, so a typed error class survives all the way to the caller.
-
-The fidelity guarantee is `support/scripted-text-generation.test.ts`, which runs the port's own contract suite against this adapter with no exemptions, plus `req.schema.parse(value)` on every generated object so a drifted fixture fails as loudly as a malformed model response would.
-
-## Why no test ids
-
-There is not one `data-testid` in the client, and adding one would be the easy way out of every hard locator in this suite. The rule holds because addressing the UI by role and by name means the suite doubles as an accessibility net. Writing these journeys found seven real gaps, all filed as issue 51 rather than papered over with a test id.
-
-1. A book card is a `div` with an `onClick`, so it has no role, no accessible name, and no focus. A keyboard user cannot open a book at all. This is the serious one.
-2. Two controls share the name "Next section", the chapter rail's and the tap zone's, both wired to the same callback.
-3. The book context menu is a plain `div` of buttons with no `menu` or `menuitem` semantics.
-4. The feedback form's textareas are not label-associated, so neither has an accessible name.
-5. The Rename dialog's Title and Subtitle labels are not wired to their inputs.
-6. The Delete dialog's confirmation input has no label.
-7. The library toolbar's icon-only search and view toggles carry a `title` attribute but no `aria-label`.
-
-The one sanctioned exception is a hidden ` `, which has no role and no accessible name by construction. It is commented where it appears.
-
-## Flake policy
-
-`retries: 0`, and that is the most load-bearing line in `playwright.config.ts`. The server is in process, the adapters are fakes, and nothing touches a network, so there is no legitimate source of nondeterminism left. A retry could only convert a real bug into a green run. A journey that flakes twice gets quarantined with `test.fixme` and an issue, never a blanket retry.
-
-Not hiding nondeterminism is only half the policy. The other half is going looking for it before CI does. Before trusting a green run on a change that touches timing, `pnpm e2e --project=web --repeat-each=4 --workers=4` oversubscribes the worker count past what the machine can actually run at once, which starves the server side of CPU the same way a busy CI runner does. Repeating a test on an idle machine only repeats the same fast timing, so it proves nothing a single run did not already prove. Oversubscription is the part that manufactures a slow, contended server and gives a timing bug the chance to appear on a laptop instead of only in CI.
-
-Some races need a wider window than contention alone reliably opens. Give a scripted stream's response a `chunkDelayMs` and the gap between the last chunk reaching the client and the server's own later write widens on purpose, turning a rare write-after-signal race into one every run reproduces. Reach for that before blaming CI infrastructure. A bug that only appears when the window is wide has not gone away when the window is narrow again. It has only gone back to being rare.
-
-Two assertions are quarantined today, and neither is flake. Both are blocked on issue 50, a real bug this suite found in chapter generation's event stream, and each `test.fixme` names it. They flip green when it is fixed.
-
-One real source of nondeterminism does exist inside the product and journeys have to respect it. `server/services/generate-quiz.ts` shuffles a quiz's options with `Math.random()` before saving, so a quiz option must be located by its text and never by its position.
+1. Add a `*.spec.ts` under `e2e/journeys/`. Import `test` and `expect` from `../support/app.js`, never from `@playwright/test`. The re-export carries the fixtures.
+2. Get to your subject the cheap way. If the journey is about exporting a book, seed one with `seedBook(app.dataDir, ...)` instead of driving the whole creation wizard. A wizard regression should fail the wizard journey, not yours.
+3. Address the UI the way a screen reader does. `getByRole` first, `getByText` second, never a CSS class.
+4. Assert with `await expect(locator)`, which retries to a deadline. Never sleep.
+5. If your journey needs a model call nobody scripted yet, add a rule to `support/default-script.ts` that matches a phrase lifted verbatim from the server prompt, and put the content in `fixtures/`.
-## Projects
+## The rules that keep it honest
-| Project | Selects | Runs where |
-|---|---|---|
-| `web` | everything not tagged `@electron` | the `e2e-web` CI job, Chromium, headless |
-| `electron` | tests tagged `@electron` | the `e2e-electron` CI job, which is the only one that downloads the Electron binary |
+- `retries: 0`. Everything is in process and faked at the edges, so there is no legitimate source of nondeterminism. A retry could only hide a real bug. A journey that flakes twice gets `test.fixme` and an issue.
+- Before trusting a green run on a timing-sensitive change, run `pnpm e2e --project=web --repeat-each=4 --workers=4`. Oversubscribed workers starve the server of CPU the way a busy CI runner does. Repetition on an idle machine proves nothing new.
+- To reproduce a suspected race deterministically, give a scripted stream a `chunkDelayMs`. Widening the gap between the UI signal and the server's later write turns a rare race into one that fires every run.
+- Quiz options are shuffled with `Math.random()` at save time, so locate an option by its text, never by its position.
+- No `data-testid`, anywhere. Why that rule exists, and what it caught, is in [design.md](design.md).
-The Electron project exists for one thing that the web project cannot reach at all. In the browser redux-persist writes to localStorage, and in the packaged app it writes through IPC to a file the main process owns, so the rehydration path is only observable with a real main process. Fakes cannot be injected into a packaged main process, so that journey seeds its library rather than generating one.
+Related: [design.md](design.md), [../server/ports/README.md](../server/ports/README.md)
diff --git a/e2e/design.md b/e2e/design.md
new file mode 100644
index 0000000..6c1bc22
--- /dev/null
+++ b/e2e/design.md
@@ -0,0 +1,59 @@
+Up: [README.md](README.md)
+
+# Why the suite is built this way
+
+Five decisions shape this suite. Each one traded something away, and this file says what.
+
+## Fakes at the ports, not mocks at HTTP
+
+The server is faked at its ports. Everything between the browser and the AI provider is real, meaning real routes, real services, real domain rules, real YAML on a real disk. Only the four edges that would cost money, need a network, or need a 90MB binary are swapped, through `buildServer(overrides)`, the same seam production uses.
+
+Mocking `fetch` in the browser would have tested the client against a fiction of the server. This tests it against the server.
+
+No journey can reach a live provider. The real key vault is never constructed, the fake one lives only in memory, and the scripted model has no SDK import and throws on any call nobody wrote a fixture for.
+
+## The model answers by intent, not by order
+
+`support/scripted-text-generation.ts` matches on the shape of a request. The unit-test fake answers first-in first-out instead, which is right when the test knows exactly how many calls the code makes. A journey does not know that. Approving a table of contents fires a skill classification, a chapter stream, and a quiz in an order the UI decides and may reorder tomorrow. A fixture list coupled to that order would be the suite's largest source of flake.
+
+Rules are consulted newest first, so one line shadows a default. Failure injection works the same way.
+
+```ts
+model.onStreamText({
+ name: 'chapter 2 is rate limited',
+ match: isChapterStreamFor(2),
+ respond: { throws: new Error('rate limit exceeded') },
+})
+```
+
+`throws` rethrows its value unchanged, so a typed error class survives to the caller.
+
+Two checks keep the scripted model honest. It passes the port's own contract suite in `support/scripted-text-generation.test.ts`, and every generated object goes through `req.schema.parse(value)`, so a drifted fixture fails as loudly as a malformed model response would.
+
+## No test ids
+
+There is not one `data-testid` in the client. Journeys address the UI by role and by name, which means the suite doubles as an accessibility check.
+
+Writing it that way found seven real gaps, filed as issue 51. The worst was a book card rendered as a `div` with an `onClick`, no role, no name, and no focus, so a keyboard user could not open a book at all. Six of the seven are fixed. The context menu keeps its plain buttons deliberately, because an explicit `menuitem` role would replace the `button` role these journeys and a pinned test comment rely on.
+
+The one sanctioned exception is a hidden ` `, which has no role or name by construction. It is commented where it appears.
+
+## Zero retries
+
+The flake policy has two halves.
+
+- Never hide nondeterminism. `retries: 0` in `playwright.config.ts`, and quarantine with `test.fixme` plus an issue instead of retrying.
+- Go looking for it before CI does. The oversubscription stress run and the `chunkDelayMs` window-widening technique are both described in [README.md](README.md).
+
+The policy has been tested for real. The suite found a production SSE bug on its first day (issue 50, an event stream that closed before its first byte), and a post-merge timing race in one of its own tests. Both were fixed at the root. Neither got a retry.
+
+## Two projects
+
+| Project | Selects | Runs where |
+|---|---|---|
+| `web` | everything not tagged `@electron` | the `e2e-web` CI job, headless Chromium |
+| `electron` | tests tagged `@electron` | the `e2e-electron` CI job, the only one that downloads the Electron binary |
+
+The Electron project exists for one path the web project cannot reach. In a browser, redux-persist writes to localStorage. In the packaged app it writes through IPC to a file the main process owns, so rehydration is only observable with a real main process. Fakes cannot be injected into a packaged main process, which is why that journey seeds its library on disk instead of generating one.
+
+Related: [README.md](README.md), [../docs/adr/0005-ai-sdk-behind-a-port.md](../docs/adr/0005-ai-sdk-behind-a-port.md)
diff --git a/electron/README.md b/electron/README.md
index dd11a44..26c53ac 100644
--- a/electron/README.md
+++ b/electron/README.md
@@ -2,7 +2,9 @@ Up: [ARCHITECTURE.md](../ARCHITECTURE.md)
# electron/
-`main.ts` creates the app window, starts the Fastify server in process on a random localhost port, and owns every privileged IPC handler, meaning secure API key storage, the redux-persist file store, save and open-file dialogs, and the busy-state confirmation on quit. `preload.ts` exposes that IPC surface to the renderer as `window.electronAPI` through `contextBridge`, the sandboxed renderer's only path to Node or Electron.
+`main.ts` creates the app window, starts the Fastify server in process on a random localhost port, and owns every privileged IPC handler. That handler covers secure API key storage, the redux-persist file store, save and open-file dialogs, and the busy-state confirmation on quit.
+
+`preload.ts` exposes that IPC surface to the renderer as `window.electronAPI` through `contextBridge`, the sandboxed renderer's only path to Node or Electron.
## Three modes
@@ -23,6 +25,8 @@ Up: [ARCHITECTURE.md](../ARCHITECTURE.md)
## Verifying a packaging change
-Check all three modes, not only dev, since dev never touches `dist-electron/` at all. `scripts/bundle-fingerprint.sh` automates the part that matters most. It greps the built main-process files for every specifier that survived bundling and prints them sorted, so diffing that output from before a refactor to after one catches whatever escaped `external()` before the packaged app dies at launch instead of after.
+Check all three modes, not only dev, since dev never touches `dist-electron/` at all.
+
+`scripts/bundle-fingerprint.sh` automates the part that matters most. It greps the built main-process files for every specifier that survived bundling and prints them sorted. Diffing that output from before a refactor to after one catches whatever escaped `external()`, before the packaged app dies at launch instead of after.
Related: [client/README.md](../client/README.md), [server/README.md](../server/README.md), [shared/README.md](../shared/README.md), [0006-electron-packaging-constraints.md](../docs/adr/0006-electron-packaging-constraints.md)
diff --git a/scripts/generate-routes-doc.ts b/scripts/generate-routes-doc.ts
index 1146145..2618665 100644
--- a/scripts/generate-routes-doc.ts
+++ b/scripts/generate-routes-doc.ts
@@ -177,7 +177,14 @@ Routes are grouped by the module that registers them. The \`HEAD\` route Fastify
## Why a generated table and not OpenAPI
-This file is a projection of the live Fastify route registry, regenerated by \`pnpm docs:routes\` and gated in CI, so the code stays the single source of truth and this table cannot describe an endpoint the server does not serve. An OpenAPI document would need converted JSON Schema attached to all ${rows.length} routes, and this server deliberately does not carry any. Request validation goes through Zod in [\`server/http/parse.ts\`](../server/http/parse.ts) rather than Fastify's ajv \`schema.body\`, because ajv changes the shape of the 400 body the client already handles and drops the Zod defaults and transforms the domain relies on. A specification would also describe an API with no external consumer, since the only client ships in this repo and imports the real Zod schemas and contract types out of \`shared/\`, which binds the two sides harder than anything generated from a spec could. Revisit this when a second client or an outside consumer appears. At that point the same \`buildServer\` registry walk, plus \`zod-to-json-schema\` over the contracts that already exist, emits a real specification under this same drift gate.
+This file is generated from the live route registry and gated in CI. The code is the single source of truth, so this table cannot describe an endpoint the server does not serve.
+
+OpenAPI would not add anything here today.
+
+- A spec needs JSON Schema attached to all ${rows.length} routes, and this server carries none. Validation goes through Zod in [\`server/http/parse.ts\`](../server/http/parse.ts), not Fastify's ajv \`schema.body\`, because ajv changes the 400 body the client handles and drops the Zod defaults the domain relies on.
+- There is no external consumer to hand a spec to. The only client ships in this repo and imports the same Zod schemas and contract types from \`shared/\`, which is a tighter contract than generated client code.
+
+Revisit when a second client or an outside consumer appears. The same \`buildServer\` registry walk plus \`zod-to-json-schema\` over the existing contracts can emit a real specification under this same drift gate.
${sections.join('\n')}`
diff --git a/server/README.md b/server/README.md
index 43c1183..bc0e26a 100644
--- a/server/README.md
+++ b/server/README.md
@@ -4,7 +4,9 @@ Up: [ARCHITECTURE.md](../ARCHITECTURE.md)
Tutor's backend is a single Fastify instance. Embedded in Electron, it binds a free port chosen at launch. Standalone, via `pnpm dev:server`, it binds `127.0.0.1:3147`.
-Routes parse and delegate, services decide, and adapters do the I/O behind the ports that name it. Dependency points inward. Routes depend on services, services depend on ports by shape, and nothing in a service or a port imports a route or a concrete adapter. See [ports/README.md](ports/README.md), [adapters/README.md](adapters/README.md), and [services/README.md](services/README.md) for each layer. `domain/` holds pure functions with no I/O of their own. `http/` holds the Fastify glue, the error handler, body parsing, route params, and status codes.
+Routes parse and delegate, services decide, and adapters do the I/O behind the ports that name it. Dependency points inward. Routes depend on services, services depend on ports by shape, and nothing in a service or a port imports a route or a concrete adapter. See [ports/README.md](ports/README.md), [adapters/README.md](adapters/README.md), and [services/README.md](services/README.md) for each layer.
+
+`domain/` holds pure functions with no I/O of their own. `http/` holds the Fastify glue, the error handler, body parsing, route params, and status codes.
`prompts/` holds shared prompt fragments as TypeScript, today the markdown formatting rules appended to generation prompts. The prompts themselves live inline in the services that call `TextGeneration`.
@@ -26,7 +28,7 @@ The packaged Electron app binds a free port chosen at launch, so pointing the MC
| `http/` | Fastify glue, error handler, body parsing, route params, status codes |
| `migrations/` | forward-only schema steps |
| `prompts/` | shared prompt fragments as TypeScript |
-| `test/` | shared test harness |
+| `test/` | shared test harness, the name matches `route-harness.ts` |
| `composition-root.ts` | the one place adapters are chosen |
| `mcp-server.ts` | the MCP entry point |
diff --git a/server/adapters/README.md b/server/adapters/README.md
index 9b23e16..b20a22c 100644
--- a/server/adapters/README.md
+++ b/server/adapters/README.md
@@ -24,14 +24,26 @@ Adapters are the only place real I/O happens, one file per port per technology,
| `system-clock.ts` | `clock` |
| `os-file-manager.ts` | `os-file-manager` |
-`journalled-background-tasks.ts` decorates `in-memory-background-tasks.ts` so that adding persistence did not force the port async. `retry-policy.ts` and `fs-paths.ts` are shared helpers rather than adapters. Neither implements a port of its own.
+`journalled-background-tasks.ts` decorates `in-memory-background-tasks.ts` so that adding persistence did not force the port async. `retry-policy.ts` and `fs-paths.ts` are shared helpers, not adapters. Neither implements a port of its own.
-Some adapters carry an external constraint worth knowing up front. `fs-paths.ts` writes YAML atomically, a temp file, then a rename. `kokoro-speech-synthesis.ts` needs the Kokoro model downloaded. `ffmpeg-audio-assembly.ts` needs ffmpeg present. `file-key-vault.ts` needs the OS keychain, or falls back to a plaintext key file outside Electron. `kroki-diagram-renderer.ts` calls the kroki.io HTTP service.
+Some adapters carry an external constraint worth knowing up front.
-Twelve of the seventeen adapters above run their port's shared contract test, plus their own integration tests for anything the contract does not reach. The five that do not are the ones whose real subject would spend money, download a model, or reach a remote service on every run, so `ai-sdk-text-generation.ts`, `http-image-generation.ts`, `kokoro-speech-synthesis.ts`, `kroki-diagram-renderer.ts` and `electron-diagram-renderer.ts` are covered by their own tests, and the contract runs against their fakes.
+- `fs-paths.ts` writes YAML atomically, a temp file, then a rename.
+- `kokoro-speech-synthesis.ts` needs the Kokoro model downloaded.
+- `ffmpeg-audio-assembly.ts` needs ffmpeg present.
+- `file-key-vault.ts` needs the OS keychain, or falls back to a plaintext key file outside Electron.
+- `kroki-diagram-renderer.ts` calls the kroki.io HTTP service.
-Electron packaging bites adapters specifically. The native dependencies behind narration and audio (kokoro-js, onnxruntime, fluent-ffmpeg, phonemizer) stay external to the bundle rather than being rolled in, and `onnxruntime-node`, the one with a native binary, is also asarUnpacked. A dynamic import of a CJS package can come back wrapped an extra level under Node's ESM loader. `epub-gen-export.ts` and `epub2-import.ts` each unwrap whichever shape actually shows up rather than assuming one.
+Twelve of the seventeen adapters above run their port's shared contract test, plus their own integration tests for anything the contract does not reach.
-Importing the narration path pulls in an espeak WASM bundle, through `kokoro-js`, that installs a process-level `unhandledRejection` handler which rethrows. While narration is loaded, any unrelated unhandled rejection anywhere in the process becomes fatal. Do not remove or wrap another package's handler. The hazard is documented at the import that causes it, in `kokoro-speech-synthesis.ts`.
+The five that do not are the ones whose real subject would spend money, download a model, or reach a remote service on every run. `ai-sdk-text-generation.ts`, `http-image-generation.ts`, `kokoro-speech-synthesis.ts`, `kroki-diagram-renderer.ts`, and `electron-diagram-renderer.ts` are covered by their own tests instead, and the contract runs against their fakes.
+
+Electron packaging bites adapters specifically. The native dependencies behind narration and audio (kokoro-js, onnxruntime, fluent-ffmpeg, phonemizer) stay external to the bundle rather than being rolled in, and `onnxruntime-node`, the one with a native binary, is also asarUnpacked.
+
+A dynamic import of a CJS package can come back wrapped an extra level under Node's ESM loader. `epub-gen-export.ts` and `epub2-import.ts` each unwrap whichever shape actually shows up rather than assuming one.
+
+Importing the narration path pulls in an espeak WASM bundle, through `kokoro-js`, that installs a process-level `unhandledRejection` handler which rethrows.
+
+While narration is loaded, any unrelated unhandled rejection anywhere in the process becomes fatal. Do not remove or wrap another package's handler. The hazard is documented at the import that causes it, in `kokoro-speech-synthesis.ts`.
Related: [../README.md](../README.md), [../ports/README.md](../ports/README.md), [../services/README.md](../services/README.md), [../migrations/README.md](../migrations/README.md), [ADR 0006](../../docs/adr/0006-electron-packaging-constraints.md), [ADR 0003](../../docs/adr/0003-local-kokoro-tts.md)
diff --git a/server/migrations/README.md b/server/migrations/README.md
index d474db1..3520f47 100644
--- a/server/migrations/README.md
+++ b/server/migrations/README.md
@@ -1,43 +1,21 @@
Up: [ARCHITECTURE.md](../../ARCHITECTURE.md)
+
# server/migrations/
-Forward-only, pure migrations for the two things Tutor persists at the top
-of a data directory: a book's `meta.yml` and the library's
-`learning-profile.yml`. Each has its own version counter
-(`shared/schema-version.ts` explains why two counters instead of one) and
-its own chain of steps, `book/` and `profile/`, walked by the same
-`migrateForward` in `migrate.ts`.
+Forward-only, pure migrations for the two things Tutor persists at the top of a data directory, a book's `meta.yml` and the library's `learning-profile.yml`. Each has its own version counter (`shared/schema-version.ts` explains why two counters instead of one). Each also has its own chain of steps, `book/` and `profile/`, walked by the same `migrateForward` in `migrate.ts`.
-A step is a pure function, `(raw: Record) => Record`, with no `fs`, no zod, no knowledge of anything but the shape it
-turns into the next shape. The I/O half, reading a file, calling
-`migrateForward`, and writing the result plus a one-time backup, is the
-`LibraryMigrator` port, a later phase task, not this directory.
+A step is a pure function, `(raw: Record) => Record`, with no `fs`, no zod, no knowledge of anything but the shape it turns into the next shape. The I/O half, reading a file, calling `migrateForward`, and writing the result plus a one-time backup, is the `LibraryMigrator` port, a later phase task, not this directory.
## Adding a migration
-1. Bump `CURRENT_BOOK_SCHEMA_VERSION` or `CURRENT_PROFILE_SCHEMA_VERSION` in
- `shared/schema-version.ts`.
+1. Bump `CURRENT_BOOK_SCHEMA_VERSION` or `CURRENT_PROFILE_SCHEMA_VERSION` in `shared/schema-version.ts`.
2. Edit the schema in `shared/domain.ts` to match the new shape.
-3. Add `NNN-name.ts` under `book/` or `profile/` as a `MigrationStep` whose
- `to` is the version you just bumped to, and register it in that
- directory's `index.ts`.
-4. Commit a fixture library at the PREVIOUS version under `__fixtures__/`
- (see `__fixtures__/README.md`). Never edit an existing fixture, a
- fixture is a historical record of what some released build actually
- wrote, not something to bring up to date.
-5. Add the round-trip test: run the new step over the new fixture, assert
- the fields it adds and the fields it must leave alone, and assert the
- result parses under the current Zod schema.
-
-`chains.test.ts` fails the moment a version constant is bumped without a
-matching step added to that chain's `index.ts`. Skipping step 3 above is
-therefore a test failure at the next `pnpm test`, not a runtime surprise
-discovered against someone's real library months later.
-
-Steps are pure and forward-only by design. There is no downgrade path, a
-step only ever knows how to turn `to - 1` into `to`, never the reverse.
-That is deliberate. A bad forward migration can be fixed by writing another
-one, but a migration nobody wrote is a step this app has no way to reverse
-safely, so a library newer than a build supports fails loudly
-(`SchemaTooNewError`) instead of a downgrade silently corrupting it.
+3. Add `NNN-name.ts` under `book/` or `profile/` as a `MigrationStep` whose `to` is the version you just bumped to, and register it in that directory's `index.ts`.
+4. Commit a fixture library at the PREVIOUS version under `__fixtures__/` (see `__fixtures__/README.md`). Never edit an existing fixture. A fixture is a historical record of what some released build actually wrote, not something to bring up to date.
+5. Add the round-trip test: run the new step over the new fixture, assert the fields it adds and the fields it must leave alone, and assert the result parses under the current Zod schema.
+
+`chains.test.ts` fails the moment a version constant is bumped without a matching step added to that chain's `index.ts`. Skipping step 3 above is therefore a test failure at the next `pnpm test`, not a runtime surprise discovered against someone's real library months later.
+
+Steps are pure and forward-only by design. There is no downgrade path. A step only ever knows how to turn `to - 1` into `to`, never the reverse. That is deliberate.
+
+A bad forward migration can be fixed by writing another one, but a migration nobody wrote is a step this app has no way to reverse safely. A library newer than a build supports fails loudly with `SchemaTooNewError`, instead of silently corrupting it on a downgrade.
diff --git a/server/ports/README.md b/server/ports/README.md
index 7eaf88d..7f57710 100644
--- a/server/ports/README.md
+++ b/server/ports/README.md
@@ -22,7 +22,11 @@ A port is an interface owned by the server core, named for the capability it pro
| `clock` | the current time and fresh unique ids, as one seam |
| `os-file-manager` | revealing a file on disk in the OS's native file manager |
-Every port ships an in-memory fake plus a contract test, and the fake and the real adapter run the same contract, so a fake cannot drift from the thing it stands in for. Twelve of the seventeen adapters run it today. The five that do not are the ones whose real subject would spend money against a provider, download a model, or reach a remote service on every run, so `text-generation`, `image-generation`, `speech-synthesis` and `diagram-renderer` are contract-tested against their fakes and covered separately by their own adapter tests. Nothing in this folder imports an AI SDK, `fs`, `fetch`, or an environment variable. That is adapter work.
+Every port ships an in-memory fake plus a contract test. The fake and the real adapter run the same contract, so a fake cannot drift from the thing it stands in for. Twelve of the seventeen adapters run it today.
+
+The five that do not are the ones whose real subject would spend money against a provider, download a model, or reach a remote service on every run. `text-generation`, `image-generation`, `speech-synthesis`, and `diagram-renderer` are contract-tested against their fakes instead, and covered separately by their own adapter tests.
+
+Nothing in this folder imports an AI SDK, `fs`, `fetch`, or an environment variable. That is adapter work.
To add a port, follow the `add-feature` Agent Skill at [add-feature/SKILL.md](../../.claude/skills/add-feature/SKILL.md).
@@ -30,6 +34,6 @@ To add a port, follow the `add-feature` Agent Skill at [add-feature/SKILL.md](..
- A missing entity is signalled by an error whose `code` is `'ENOENT'`, never by an `instanceof` check, because the error crosses an adapter boundary and a real adapter's own Node fs error carries that same code.
- A method that is deliberately synchronous stays synchronous. `JobJournal`'s own methods are synchronous specifically so a synchronous `BackgroundTasks` contract can be persisted without becoming async.
-- `DiagramRenderer` returns an escaped-source fallback for a chart that fails to render. The one thing it never returns for that chart is an empty string.
+- `DiagramRenderer` returns an escaped-source fallback for a chart that fails to render. It never returns an empty string for that chart.
Related: [../README.md](../README.md), [../adapters/README.md](../adapters/README.md), [../services/README.md](../services/README.md), [../migrations/README.md](../migrations/README.md), [ADR 0005](../../docs/adr/0005-ai-sdk-behind-a-port.md)
diff --git a/server/services/README.md b/server/services/README.md
index 82cfd67..12a472e 100644
--- a/server/services/README.md
+++ b/server/services/README.md
@@ -2,13 +2,22 @@ Up: [ARCHITECTURE.md](../../ARCHITECTURE.md)
# server/services/
-Services hold application logic and orchestrate ports. They never import an AI SDK, and never touch a Fastify request or reply, which is what makes them unit-testable against fakes with no network. A few do read a real file behind a path-returning port. `ArtifactStore` hands back paths rather than bytes for large binary artifacts, so a service is where that last-mile read happens instead of a route.
+Services hold application logic and orchestrate ports. They never import an AI SDK, and they never touch a Fastify request or reply. That keeps them unit-testable against fakes, with no network call needed.
+
+A few do read a real file behind a path-returning port. `ArtifactStore` hands back paths rather than bytes for large binary artifacts, so a service is where that last-mile read happens instead of a route.
Every service is a `createX(deps)` factory taking its ports as a plain dependency object, so a test hands it fakes instead of touching the filesystem or a model.
-The generation flow is the clearest worked example. Approving a TOC calls `createStartBook`, which streams chapter one over SSE and saves it. Reading calls `createReadChapter`. Finishing a chapter calls `createSubmitFeedback`, which grades any saved quiz and records the reader's response. The next chapter runs through `createGenerateNextChapter`, again streamed over SSE, the same function whether it is generating a new chapter or regenerating one.
+The generation flow is the clearest worked example.
+
+- Approving a TOC calls `createStartBook`, which streams chapter one over SSE and saves it.
+- Reading calls `createReadChapter`.
+- Finishing a chapter calls `createSubmitFeedback`, which grades any saved quiz and records the reader's response.
+- The next chapter runs through `createGenerateNextChapter`, again streamed over SSE, the same function whether it is generating a new chapter or regenerating one.
+
+`BackgroundTasks` owns a task's lifecycle, start, progress, success, failure, and cancellation. A caller only ever holds a `TaskHandle` exposing an `AbortSignal`, never the controller that could cancel it.
-`BackgroundTasks` owns a task's lifecycle, start, progress, success, failure, and cancellation. A caller only ever holds a `TaskHandle` exposing an `AbortSignal`, never the controller that could cancel it. Background work is a `Task` everywhere in this layer, never a "job" in that sense. `JobJournal` is the one exception. It persists a job across a restart, and "job" is the correct word there.
+Background work is a `Task` everywhere in this layer, never a "job" in that sense. `JobJournal` is the one exception. It persists a job across a restart, and "job" is the correct word there.
`authoring/` is the MCP-facing sub-folder, the services behind the MCP server's authoring writes rather than the reader-facing flows above.
diff --git a/shared/README.md b/shared/README.md
index bdab316..02fa896 100644
--- a/shared/README.md
+++ b/shared/README.md
@@ -1,7 +1,8 @@
Up: [ARCHITECTURE.md](../ARCHITECTURE.md)
+
# shared/
-Code that both the React client and the Fastify server depend on. This is the dependency root of the repo, so nothing here may import from `client/` or `server/`, and ESLint enforces that rather than leaving it to discipline.
+This folder holds the code that both the React client and the Fastify server depend on. This is the dependency root of the repo, so nothing here may import from `client/` or `server/`, and ESLint enforces that rather than leaving it to discipline.
| Module | Holds |
|---|---|
@@ -13,7 +14,12 @@ Code that both the React client and the Fastify server depend on. This is the de
| `mermaid-theme.ts`, `sanitize-mermaid.ts` | Diagram helpers used by both the renderer and the Electron main process during EPUB export. |
| `node/` | The Node-only corner. |
-`node/` exists because `data-dir.ts` reads `process.env` and `process.platform`, which cannot run in a browser. Importing it from `client/**` would pull `process` into the renderer bundle, so that import is a lint error, and so is importing it from the browser-safe files sitting directly in `shared/`. Anything added here that touches Node built-ins belongs in `node/`, and everything else belongs at the top level.
+`node/` exists because `data-dir.ts` reads `process.env` and `process.platform`, which cannot run in a browser. Importing it from `client/**` would pull `process` into the renderer bundle, so that import is a lint error. The same is true for importing it from the browser-safe files that sit directly in `shared/`.
+
+Anything added here that touches Node built-ins belongs in `node/`, and everything else belongs at the top level.
+
+One constraint matters before adding a file. The Electron main process is built separately and does not inherit the root Vite config, so any alias it resolves must also be declared in the electron block of `vite.config.ts`.
+
+A specifier that escapes bundling survives into `dist-electron/`, and the packaged app fails to launch. Dev mode cannot catch this. `scripts/bundle-fingerprint.sh` is the check for that.
-One constraint worth knowing before adding a file: the Electron main process is built separately and does not inherit the root Vite config, so any alias it resolves must also be declared in the electron block of `vite.config.ts`. A specifier that escapes bundling survives into `dist-electron/` and the packaged app fails to launch, which dev mode cannot catch. `scripts/bundle-fingerprint.sh` is the check for that.
Related: [client/README.md](../client/README.md), [server/README.md](../server/README.md), [CONTEXT.md](../CONTEXT.md), [0004-single-package-monorepo-shaped.md](../docs/adr/0004-single-package-monorepo-shaped.md)