ADR-016 — A chapter review lives inside the chapter's `BookInsight`
Status: Proposed (implemented — BookInsight.ReviewJson and review_question exist in code as of 2026-10-04) · Date: 2026-09-28 · Feature: chapter-review.md ·
Builds on the insight decisions of 2026-09-10 (assistant-handoff.md)
Context
Stage 2 of TextStack × ChatGPT/Claude: the reader presses Review chapter, their own assistant runs a structured review over MCP (3–6 blocks: example → root cause → rule → linked highlights → self-check question, plus applications and open threads), and the result comes home as structure, so the reader can see badges on highlights, a per-chapter summary, and questions in SRS.
We already have exactly one place where an assistant’s conclusion about a chapter lands:
BookInsight (backend/src/Domain/Entities/BookInsight.cs). Its shape was argued over and settled
three weeks ago:
- One row per
(user, book, chapterSlug), a save replaces (BookInsight.cs:23-27), enforced by two filtered unique indexes withNULLS NOT DISTINCT(AppDbContext.Insights.cs:42-49). - No category in the key. The consilium of 2026-09-10 and the owner’s answer the same day: a
category column, if ever added, stays out of the unique index — in the key, the write ceiling goes
from
1 × chaptersto4 × chapters, and the “saving again REPLACES” promise in the tool description becomes false (commits3877ba50,1649d63c; PRs #602, #603). - The return path is per book, in reading order, and it already ships on four screens
(
BookInsightsSection, web + mobile).
A structured review is, by the owner’s own framing, the conclusion about a chapter — the same
thing save_insight stores, with a shape. The question is where the shape goes.
Decision
- The review is stored in the chapter’s existing
BookInsightrow, in a new nullablejsonbcolumnreview_json(BookInsight.ReviewJson,string?— same convention asHighlight.AnchorJsonandReadingProgress.PositionJson, keeping Domain framework-free). Textkeeps a server-rendered Markdown fallback of the review. Every existing reader of insights —get_my_insights, bothBookInsightsSections, any future export — keeps working with zero changes, and a reader ofTextnever sees an empty row.- The unique key does not move. A review of chapter 7 and a plain
save_insightabout chapter 7 are the same slot.save_chapter_reviewupserts it;save_insighton a row that has a review is refused with 409 (“this chapter has a structured review; replace it with save_chapter_review”), because silently droppingreview_jsonwould also orphan the chapter’s SRS questions. - Only the SRS state gets its own table (
review_question), FK tobook_insightwithON DELETE CASCADE. Scheduling state is per question, mutated on every answer, and queried across all books bynext_review_at— none of which fits inside a replaced-as-a-whole jsonb blob. - Open threads are not a table. They are a field of each review; “open” is computed at read
time as threads opened in earlier chapters’ reviews minus thread ids closed in earlier chapters’
reviews. Thread ids are deterministic (
t_+ 8 hex ofsha256(chapterSlug|normalizedText)), so an identical re-save keeps them stable.
Alternatives rejected
A. A separate chapter_review table, one row per review. Two conclusions per chapter — the
insight and the review — with two replace semantics, two DELETE affordances and two lists to render
in reading order. It re-opens the exact question settled on 2026-09-10 (one conclusion per chapter)
by the side door, and every screen that renders the study notes grows a join. Rejected.
B. BookInsight rows with a kind column (note | review) in the unique key. This is the
category-in-the-key design the consilium rejected, with a different enum. Rejected for the reasons
recorded there: doubled write ceiling, a false replace promise, and a later key→facet migration
that has to choose which row per chapter survives.
C. One row per block (3–6 rows per chapter). Destroys “a save replaces” (a re-run with 4 blocks over one with 6 leaves 2 stale), multiplies the ceiling by 6, and turns validation of “the review is complete” into a multi-row transaction. Rejected.
D. Structure only in Text (Markdown with conventions), parsed on read. Cannot validate a
reference to a highlight id, cannot drive SRS, and a parser over model-written Markdown is a
permanent source of silent drift. The owner’s requirement is “stored as structure, not text”.
Rejected.
E. SRS state inside review_json. A re-save replaces the blob and would reset every question’s
interval; the due-queue query would have to unnest jsonb across every review of every book. Rejected
in favour of decision 4.
F. An open_thread table. Buys a join and a lifecycle to maintain for data that is small (≤10
per chapter), always read per book, and fully derivable. Revisit only if threads ever need editing
from the app independently of a review.
Consequences
- Migration is additive: one nullable column + one new table. Down-migration drops both; existing rows are untouched. No backfill.
BookInsightDtogains a nullableReview(typed,ChapterReviewDto). The web/mobile insight section links a reviewed row to its summary page; the reader derives its “reviewed” badges from the same list — no new read endpoint for badges.save_insight’s behaviour changes in one case (409 over a reviewed row); its description and the mirroredMcpManifest.cstext change with it.- Re-review replaces. No review versions — the owner listed them as “later, not now”. When they come, history belongs in an append-only side table, not in the key.
- Questions survive a re-save when their prompt is unchanged (matched by
prompt_hash), so running a review again does not wipe SRS progress for the parts that did not change. - Cascade: deleting the insight (reader-only DELETE) deletes its questions. The Edition FK cascade already noted in assistant-handoff.md now also takes reviews with it — same risk, larger blast.
- The ceiling the entity exists to hold stays
chapters + 1rows per book.