Skip to content

docs: architecture hub, eight ADRs, fractal READMEs, and a generated API reference that cannot drift - #56

Merged
rsml merged 10 commits into
masterfrom
phase-4-docs
Jul 21, 2026
Merged

docs: architecture hub, eight ADRs, fractal READMEs, and a generated API reference that cannot drift#56
rsml merged 10 commits into
masterfrom
phase-4-docs

Conversation

@rsml

@rsml rsml commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Objective

Make the repo readable, and make the parts of it that describe themselves impossible to get wrong. Docs and tooling only. The one production change is the routes-doc generator plus its package.json line, and everything else touching .ts files is a comment.

Adds the architecture hub, the domain glossary, eight ADRs, ten folder READMEs, a generated API reference with a CI gate against drift, two Agent Skills, .mcp.json, and 126 JSDoc blocks.

What landed

ARCHITECTURE.md The entry point. Five diagrams, one per question a cold reader asks, prose only where a diagram cannot carry it.
CONTEXT.md The ubiquitous language, and the words this domain overloads. Status is three unrelated enums, review is two unrelated features, job and task stopped being synonyms when the journal landed.
docs/adr/0001-0008 Eight decisions, each written from code with a citable symbol. All at or under 400 words.
docs/api-routes.md Generated from the Fastify route registry by pnpm docs:routes. 70 routes over 59 paths across 17 modules.
Ten folder READMEs server/, server/ports/, server/adapters/, server/services/, client/, client/features/, electron/ are new. shared/, e2e/, server/migrations/ already existed and took breadcrumbs only. Each opens with Up: and closes with Related:.
CLAUDE.md 200 lines to 152. Lost the per-file tree and the 14-row API table, gained a folder tree that links to each README.
.claude/skills/{verify,add-feature} Green-before-commit, and the test-first port to adapter to service to route to client recipe.
126 JSDoc blocks Every port names its adapters, its fake, and its contract test. Every streaming function answers who aborts it, what happens on unmount, and what happens to partial content on error.

The drift gate

The old API table in CLAUDE.md listed 14 paths against the 59 the server actually serves. That is where a hand-maintained table ends up. docs/api-routes.md is now a projection of the route registry, and CI fails if the committed file differs from a fresh run.

Demonstrated rather than asserted. A throwaway route was added to server/routes/library.ts, then:

$ pnpm docs:routes
Wrote docs/api-routes.md: 71 routes, 60 paths, 17 modules
$ git diff --exit-code docs/api-routes.md
-70 routes over 59 paths, registered by 17 modules ...
+71 routes over 60 paths, registered by 17 modules ...
+| `GET` | `/api/drift-demo` |
   → exit 1, CI step fails with "run 'pnpm docs:routes' and commit"

Reverting the route and regenerating passes again. The same check runs in lefthook.yml pre-commit, scoped to server/**, because it takes 0.71s.

docs/api-routes.md also carries a short "Why a generated table and not OpenAPI" note, answering the question before a reader has to ask it. The note lives in the generator template rather than in the markdown, so it regenerates with the file it explains and cannot be edited out of a generated document, and its route count interpolates so the number it cites cannot drift either. The short version is that a spec would need converted JSON Schema on all 70 routes, validation is deliberately Zod in server/http/parse.ts rather than Fastify's ajv schema.body because ajv changes the 400 body shape the client already handles and drops the Zod defaults the domain relies on, and the only client ships in this repo and imports the real schemas out of shared/. Revisit when a second client or an outside consumer appears.

The generator boots buildServer() and never startServer(), so it cannot run migration, crash recovery, or job resume against a real library. As a second, independent guard it points TUTOR_DATA_DIR at a temp directory and asserts that directory is still empty afterwards, so an adapter that starts writing eagerly fails the generator instead of quietly touching a reader's books.

Gate evidence

Check Result
pnpm test 1324 passed, 136 files, unchanged from baseline
pnpm typecheck clean
pnpm lint clean, --max-warnings 0
pnpm e2e 25 passed, web and electron projects
pnpm docs:routes 70 routes over 59 paths, byte-identical on a second run, no diff against the committed file
Real library untouched data dir mtime identical before and after generation
Boot check pnpm dev:server then curl 127.0.0.1:3147/api/health returns {"status":"ok"}
MCP pnpm mcp:dev lists 18 tools, list_books returns the real library
Doc paths scripted, not eyeballed. 286 paths across 27 docs, 0 missing. Markdown links and backticked repo paths both
Mermaid all five diagrams parse and render under mermaid 11 in headless Chrome
JSDoc safety every added and removed line in every .ts file is blank or a comment, verified by stripping the diff. Zero executable lines changed

Facts the plan had wrong, corrected against the tree

The whole point of this phase is docs that cannot lie, so every count and path was re-checked rather than copied from the plan.

  1. Fifteen ports, not thirteen. Phase 7 added JobJournal and LibraryMigrator.
  2. The book-status predicates still take string | undefined. Phase 3 was expected to tighten them to BookStatus and did not. Follow-up ledger item 3 stays open.
  3. "No job in code" is dead. 187 occurrences, all Phase 7 vocabulary. CONTEXT.md defines the word instead of banning it.
  4. ADR 0002's "a restart loses the stream" was stale and would have shipped as a false statement.
  5. ADR 0005's "prompts are typed TypeScript modules" was half true. server/prompts/ holds one file. Prompt text is inline template literals in each service.
  6. The shared contract does not run against every adapter. Twelve of seventeen. The five that do not would spend money, download a model, or reach a remote service on every run.
  7. Five services import node:fs directly. ArtifactStore returns paths not bytes for large binaries. The real rule is that node:fs must not appear in a route.
  8. Features do import each other. reader reaches into audiobook, chat, markdown. The README documents the pattern that exists.
  9. JSDoc was already dense, not sparse. Roughly 574 blocks before this phase, not the 28 recorded, so the work became targeted gap-filling rather than a sweep.
  10. Two JSDoc blocks in server/ports/ were themselves lying. TextGeneration's header described the pre-port world in present tense with call-site counts of 9 and 6 against a real 8 and 6. ImageGeneration's header pointed at a function in a services module Phase 2 deleted, and called the adapter that has existed since then future work. Both fixed.

Note on the CLAUDE.md Electron section

Every Electron constraint sentence is byte-identical, proved by diffing the section against the previous commit. Exactly two lines changed, both stale file references sitting inside constraint sentences. books.ts became server/adapters/epub-gen-export.ts, which is where the double-default import actually lives now, and /src/main.tsx became /client/app/main.tsx, which is what index.html:27 already says. A constraint that points at a deleted file cannot be followed.

Found and not fixed

Reported rather than touched, since this phase is docs only.

  • ReviseTocBodySchema and StartBookBodySchema take raw model and provider strings where every sibling AI body schema validates through ModelSchema and ProviderSchema.
  • RatingBodySchema caps finalQuizScore and finalQuizTotal at 100 where the persisted BookMetaSchema fields do not, so a value read off disk cannot be assumed to satisfy that cap.
  • list-provider-models.ts has a switch whose default is dead today but has no never exhaustiveness guard, so a fourth provider would compile and silently 400.
  • audiobook-installer.ts's installAll threads its AbortSignal into the ffmpeg download but not into KokoroTTS.from_pretrained, so aborting only silences model-download progress.
  • fs-book-repository.ts's saveReference spreads a matched entry and reassigns the same name it matched on.
  • Ledger item 24, the espeak unhandledRejection hazard, is documented on kokoro-speech-synthesis.ts and in server/adapters/README.md.

A scripted doc-path checker was written and used for the gate above but not committed, since this phase's production budget was the routes generator alone. It is a good Phase 5 candidate.

https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5

rsml added 10 commits July 21, 2026 10:44
The hand-written route table in CLAUDE.md listed 14 paths against the 59
the server actually serves, which is the failure mode any hand-maintained
table reaches eventually. docs/api-routes.md is now a projection of the
route registry, regenerated by pnpm docs:routes.

The generator boots buildServer, never startServer, so it cannot run
migration, crash recovery or job resume against a real library, and it
points TUTOR_DATA_DIR at a temp directory it asserts is still empty
afterwards. Route attribution is checked back against app.hasRoute and
fails loudly rather than publishing a plausible wrong path.

Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5
…rift gate

ARCHITECTURE.md is the entry point for a reader who has never seen the
repo. Five diagrams carry it, one per question a cold reader asks, and the
prose between them only says what a diagram cannot. Every folder README
links back to it.

CONTEXT.md records the ubiquitous language and, more usefully, the words
this domain overloads. Status is three unrelated enums, review is two
unrelated features, and job and task stopped being synonyms when the job
journal landed.

CI and lefthook now regenerate docs/api-routes.md and fail on a diff, so
the generated doc cannot drift from the routes it describes. The lefthook
copy is scoped to server changes and runs in under a second.

Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5
Each ADR was written from code that exists, with a citable symbol or file,
and any draft claim that could not be found in the tree was dropped rather
than smoothed over. Two drafts were wrong and are corrected here. Chapter
generation is no longer lost on restart, so 0002 describes the journal and
resume path instead of the in-memory one, and prompt text lives in typed
template literals inside each service rather than in runtime markdown
files, so 0005 says that instead.

Also adds the two Agent Skills the repo needs to be worked on by an agent,
verify for the green-before-commit check and add-feature for the test-first
port to adapter to service to route to client recipe, plus .mcp.json for
the built-in MCP server.

Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5
Ten folder READMEs, each opening with an Up breadcrumb to the hub and
closing with Related links, so a reader who lands anywhere in the tree can
walk up or sideways without grepping. Three of them state a rule the
outline had wrong. Services are not free of node:fs, five of them read a
file behind a path-returning ArtifactStore and the real rule is that fs
must not appear in a route. Features do import each other, so the README
describes the pattern that exists rather than one that does not. The port
list is fifteen, not thirteen.

CLAUDE.md drops the per-file tree and the fourteen-row API table it had
grown wrong, and points at ARCHITECTURE.md, CONTEXT.md, docs/adr/ and the
generated docs/api-routes.md instead. Every Electron constraint is
byte-identical; the only two edits inside that section are stale file
references, epub-gen-memory moved to server/adapters/epub-gen-export.ts
and the renderer entry is client/app/main.tsx, which is what index.html
already says.

Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5
Twelve of the seventeen do. 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. Claiming universal coverage in a doc about
why the contract exists would have been the exact failure this phase is
meant to prevent.

Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5
Eighty new JSDoc blocks across ports, adapters, services, and domain.
Every port interface file now names its adapters, its in-memory fake, and
its contract test in prose, so a reader landing in server/ports can find
all four files without grepping. Every adapter names the port it
implements and the one external constraint that comes with its
technology.

Two existing blocks were lying and are corrected. The TextGeneration
header described the pre-port world in present tense and put the call-site
counts at 9 and 6 against a real 8 and 6, and its streamText doc said 5
where explain-passage makes two of the six. The ImageGeneration header
pointed at generateImageWithFallback in a services module Phase 2 deleted,
and called the adapter that has existed since then future work.

Every changed line in every file is blank or a comment, verified by
stripping the diff.

Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5
Forty-four blocks, concentrated on the streaming surface. Every function
in client/api that opens a stream now answers the three questions a caller
cannot answer from the signature. Who aborts it, what happens on unmount
or when the caller returns, and whether partial content survives a
mid-stream error. Several of them turn out not to take an AbortSignal at
all, which is worth saying out loud.

The eleven blocks in shared/contracts.ts and shared/domain.ts are chosen,
not swept. A block on BookMetaSchema saying it is the schema for book
metadata would be worse than none, so only the schemas carrying a real
constraint got one, including two asymmetries a reader would otherwise
trip over. ReviseTocBody and StartBookBody take raw model and provider
strings where every sibling validates them, and RatingBody caps the final
quiz score at 100 where the persisted field does not.

Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5
The question the owner asked, answered where a reader of the routes doc
hits it. The note lives in the generator template rather than in the
markdown, so it regenerates with the file it explains and cannot be edited
out of a generated document, and its route count interpolates so the
number it cites cannot drift either.

Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5
@rsml
rsml merged commit b1823dc into master Jul 21, 2026
3 checks passed
@rsml
rsml deleted the phase-4-docs branch July 21, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant