McKelle's books project
  • HTML 75%
  • Go 22%
  • Awk 1%
  • SCSS 0.8%
  • Shell 0.7%
  • Other 0.4%
Find a file
McKelle Nilsen c9b60d0bfb Measure the fail-closed rule before shipping it
The three states have sizes: 656 books carry no statement at all about who they
are for, and that number is flat across every age because it is a property of the
record. What varies is the supply of evidence-admitted books - 665 at age five,
1,991 at grade four - which is why the rule is felt only at the young end.

The cost is small and the benefit is not. Among books easier than 500L a
five-year-old could reach, 8 have no evidence and 114 do, so fail-closed removes 8
books and closes the only path by which adult material reached a child: a
difficulty measure saying 'easy enough' with nothing else in the record.

Three shelf places on the captured kindergarten shelves are insufficient-evidence
books. Two are adult and must go. The third is Sylvia & Aki, a 2011 children's
novel that the rule would also remove - which is the argument for naming the state
unknown rather than calling it unsuitable.

490 of the 656 sit on a named list, which is the leverage: a per-source audience
claim converts much of the 656 without per-book research.
2026-11-04 03:24:47 +00:00
.agents/agents Record rule 10 and the invariants: an observer ranks, never relates 2026-09-28 03:40:59 +00:00
cmd Fit labels by scanning the sheet, and draw two neighbourhoods 2026-11-03 20:35:39 +00:00
db Run the adult anchor shelf against the 202: six placed, sixteen refused on form 2026-10-29 23:15:59 +00:00
design Point the docs at readersatlas.org 2026-10-28 20:18:00 +00:00
internal/coverlib Give the 326 undecided covers a page, and a tool that applies the verdicts 2026-11-01 17:12:38 +00:00
nix Rework frontend to be for the correct audience. 2026-09-22 04:42:52 +00:00
plan Measure the fail-closed rule before shipping it 2026-11-04 03:24:47 +00:00
scripts Record a site baseline, and prove which build is running 2026-10-28 16:24:32 +00:00
templates Let the walk be opened, so any earlier book can be returned to directly 2026-11-02 17:15:55 +00:00
web Record the recommendation baseline before touching the engine 2026-11-03 23:40:41 +00:00
.gitignore Show a book's picture on its shelf card, and keep a copy of the picture 2026-10-28 02:19:57 +00:00
admin.go A reader can be marked as one made for testing 2026-10-14 03:59:46 +00:00
admin_test.go Check that a marked reader is drawn as a test, and can be unmarked 2026-10-14 04:01:13 +00:00
agedrift_test.go Screen a book on the age the catalogue gives it, not only its AR level 2026-09-26 23:36:17 +00:00
AGENTS.md Store what the text instrument actually does at the easy end 2026-10-29 02:57:16 +00:00
AR-SEARCHING.md Write down how thin AR's coverage is, and two traps in the matcher 2026-10-06 00:26:53 +00:00
auth.go Close the gap between the greeting and the arches 2026-10-28 20:19:43 +00:00
auth_test.go Give The Reading the study's clothes, and make the list the front door 2026-10-27 18:58:54 +00:00
best.go Build the Reminders: storage, the page, the control, the tests 2026-10-27 23:11:40 +00:00
best_test.go Build the Treasury entrance from the room, not the cabinet 2026-10-27 20:39:00 +00:00
bookedit.go Ask the catalogue the questions that should answer nothing 2026-10-21 17:12:58 +00:00
bookedit_test.go Sort memberships the same way on both sides of a save 2026-10-21 16:52:36 +00:00
browse.go Move the reference room into Miscellany, and rename the browser The Catalogue 2026-10-29 04:02:35 +00:00
browse_range.go Let the reader narrow a plot to a stretch of it 2026-09-30 01:53:06 +00:00
browse_range_test.go Let the reader narrow a plot to a stretch of it 2026-09-30 01:53:06 +00:00
browse_test.go Recovery point: work in progress, saved 2026-10-14 2026-10-14 03:15:12 +00:00
chart.go Test the label boxes before the region is drawn 2026-11-02 18:06:10 +00:00
chart_test.go Make a move land on the plate, and let the browser carry the change of light 2026-11-02 16:07:15 +00:00
compress.go Compress what leaves the server 2026-09-29 19:53:15 +00:00
compress_test.go Recovery point: work in progress, saved 2026-10-14 2026-10-14 03:15:12 +00:00
config.go Show a book's picture on its shelf card, and keep a copy of the picture 2026-10-28 02:19:57 +00:00
config_test.go Rollup of a bunch of changes 2026-09-24 21:13:24 +00:00
constellation-textual Measure the crossing retrieval pass on real texts, and park community contributions 2026-11-02 17:37:52 +00:00
constellations.go Draw the first real plate: four books, three asserted threads, one fixed sky 2026-11-02 14:39:37 +00:00
constellations_test.go Give the Constellations door a way in, and call the map by its new name 2026-10-06 16:48:22 +00:00
coverreview.go Give the 326 undecided covers a page, and a tool that applies the verdicts 2026-11-01 17:12:38 +00:00
coverreview_test.go Give the 326 undecided covers a page, and a tool that applies the verdicts 2026-11-01 17:12:38 +00:00
covers.go Show a book's picture on its shelf card, and keep a copy of the picture 2026-10-28 02:19:57 +00:00
covers_test.go Show a book's picture on its shelf card, and keep a copy of the picture 2026-10-28 02:19:57 +00:00
dataaudit.go Name the anonymous works as Anonymous, and give the AR blanks their own task 2026-10-21 19:18:36 +00:00
dataaudit_test.go Name the anonymous works as Anonymous, and give the AR blanks their own task 2026-10-21 19:18:36 +00:00
edge.go Give the pool its own home and say so 2026-10-05 15:46:50 +00:00
edge_test.go Name the edge: give the pool's relationship unit its own type 2026-10-05 02:24:38 +00:00
editions.go Record which editions are in another language 2026-10-02 21:03:24 +00:00
editions_test.go Record which editions are in another language 2026-10-02 21:03:24 +00:00
error_page.go Fix two duplicate task numbers: the giving route is T127, the error page is T128 2026-10-31 22:15:14 +00:00
error_page_test.go Let the router answer, so wildcards survive the error page 2026-10-28 16:21:14 +00:00
fetch.go Point the docs at readersatlas.org 2026-10-28 20:18:00 +00:00
fetch_test.go Recovery point: work in progress, saved 2026-10-14 2026-10-14 03:15:12 +00:00
fetchcard.go Recovery point: work in progress, saved 2026-10-14 2026-10-14 03:15:12 +00:00
fetchgoogle.go Recovery point: work in progress, saved 2026-10-14 2026-10-14 03:15:12 +00:00
fetchgoogle_test.go Recovery point: work in progress, saved 2026-10-14 2026-10-14 03:15:12 +00:00
flake.lock Add Go hello-world server and NixOS flake 2026-09-22 00:19:06 +00:00
flake.nix Rework frontend to be for the correct audience. 2026-09-22 04:42:52 +00:00
forget.go Recovery point: work in progress, saved 2026-10-14 2026-10-14 03:15:12 +00:00
forget_test.go Recovery point: work in progress, saved 2026-10-14 2026-10-14 03:15:12 +00:00
frontdoor_test.go Reader cards: cream names, and the middle state is "in progress" 2026-10-27 19:06:57 +00:00
go.mod Put a real QR square on the claim slip 2026-09-26 02:43:38 +00:00
go.sum Put a real QR square on the claim slip 2026-09-26 02:43:38 +00:00
grades_test.go Recovery point: work in progress, saved 2026-10-14 2026-10-14 03:15:12 +00:00
handoff_test.go Make the hand-off screen draw itself 2026-10-30 00:45:59 +00:00
header_test.go One control in the header: the reader's name, with the account inside its menu 2026-11-01 20:41:51 +00:00
hub.go Let the plan be read, and link the lexicon to it 2026-10-27 03:42:46 +00:00
hub_live_test.go Restore the task list a stale write deleted, and file the guest-shelf ruling 2026-11-02 18:10:10 +00:00
hub_sheet_test.go Draw the workstream and parking-lot tabs too, each in its own shape 2026-10-01 00:07:08 +00:00
hub_test.go Print the waiting sections under the jobs, not with them 2026-10-11 03:45:16 +00:00
interview.go Build the Reminders: storage, the page, the control, the tests 2026-10-27 23:11:40 +00:00
interview_test.go Read a typed answer as its subjects, not as its words 2026-10-27 16:53:50 +00:00
lexicon.go Let the plan be read, and link the lexicon to it 2026-10-27 03:42:46 +00:00
lexicon_test.go Divide the reference room into three pages, as McKelle ruled 2026-10-31 01:27:33 +00:00
lexiledate_test.go Assume a date for books nobody dated: three years for one they enjoyed, two for one they did not 2026-09-26 23:42:50 +00:00
likepage.go Open a book on the constellation map and its own neighbours appear 2026-10-07 04:26:07 +00:00
mail.go The name, everywhere the site says it: McKelle's Books becomes Reader's Atlas 2026-10-30 20:45:24 +00:00
mail_test.go Rollup of a bunch of changes 2026-09-24 21:13:24 +00:00
main.go Answer a missing address with the bindweed page 2026-10-28 15:47:42 +00:00
maintenance.go Ask the catalogue the questions that should answer nothing 2026-10-21 17:12:58 +00:00
maintenance_test.go Recovery point: work in progress, saved 2026-10-14 2026-10-14 03:15:12 +00:00
Makefile Rework frontend to be for the correct audience. 2026-09-22 04:42:52 +00:00
pages.go The trail's middle step is Catalogue, not The Catalogue 2026-11-02 18:02:50 +00:00
paseo.json Add agent guide and Paseo scripts for the dev loop 2026-09-25 21:14:08 +00:00
plandoc.go Let the plan be read, and link the lexicon to it 2026-10-27 03:42:46 +00:00
plandoc_test.go Let the plan be read, and link the lexicon to it 2026-10-27 03:42:46 +00:00
pool.go Give the site's words and pages one authority 2026-10-26 19:00:13 +00:00
pool_test.go Open a book on the constellation map and its own neighbours appear 2026-10-07 04:26:07 +00:00
profile.go Put the guest flag in the query, not in the fragment 2026-10-24 03:49:17 +00:00
profile_shelves_test.go Put the guest flag in the query, not in the fragment 2026-10-24 03:49:17 +00:00
profile_test.go Recovery point: work in progress, saved 2026-10-14 2026-10-14 03:15:12 +00:00
questions.go Offer the answer that says the record is right 2026-10-03 17:28:05 +00:00
questions_test.go Offer the answer that says the record is right 2026-10-03 17:28:05 +00:00
readers.go Make the hand-off screen draw itself 2026-10-30 00:45:59 +00:00
readers_card_test.go Offer the slip card where the readers are listed 2026-09-27 01:58:51 +00:00
readers_test.go Reader cards: cream names, and the middle state is "in progress" 2026-10-27 19:06:57 +00:00
README.md Draw the first real plate: four books, three asserted threads, one fixed sky 2026-11-02 14:39:37 +00:00
readme_test.go Fail the build when the README's route tables drift from the router 2026-10-16 04:33:09 +00:00
reason_test.go Rollup of a bunch of changes 2026-09-24 21:13:24 +00:00
recency_test.go Assume a date for books nobody dated: three years for one they enjoyed, two for one they did not 2026-09-26 23:42:50 +00:00
recommend.go Show a book's picture on its shelf card, and keep a copy of the picture 2026-10-28 02:19:57 +00:00
recommend_test.go Put McKelle's illustrated lockup in the header bar 2026-10-30 20:33:52 +00:00
reference_room_test.go Divide the reference room into three pages, as McKelle ruled 2026-10-31 01:27:33 +00:00
reminders.go Move the reference room into Miscellany, and rename the browser The Catalogue 2026-10-29 04:02:35 +00:00
reminders_test.go Close the gap between the greeting and the arches 2026-10-28 20:19:43 +00:00
review.go Record the scoring decisions, and make the engine report what it refused to pay 2026-10-27 02:02:59 +00:00
scatter.go Let the reader narrow a plot to a stretch of it 2026-09-30 01:53:06 +00:00
scatter_test.go Answer the plot back: AR level, honest numbers, and dots that lead somewhere 2026-09-29 19:52:51 +00:00
SCENARIOS.md Check two more suspect Lexiles, and find the edition behind them 2026-10-02 20:40:52 +00:00
scenarios_test.go Check two more suspect Lexiles, and find the edition behind them 2026-10-02 20:40:52 +00:00
search.go Route a book, an author and a search through the Catalogue 2026-11-02 04:19:10 +00:00
search_test.go Teach the engine to read a placement, and a band as grades 2026-10-16 04:43:36 +00:00
secondopinion.go Give another source's measure a place to stand 2026-10-05 19:38:10 +00:00
secondopinion_test.go Give another source's measure a place to stand 2026-10-05 19:38:10 +00:00
setaside_test.go Re-file the menu around the four doors, and make the home page the doors 2026-10-06 16:09:42 +00:00
shelf_print_test.go Recovery point: work in progress, saved 2026-10-14 2026-10-14 03:15:12 +00:00
slippage.go Build the counter that notices what slips past a pass 2026-10-21 22:17:33 +00:00
slippage_test.go Build the counter that notices what slips past a pass 2026-10-21 22:17:33 +00:00
sources.go Recovery point: work in progress, saved 2026-10-14 2026-10-14 03:15:12 +00:00
sources_test.go Recovery point: work in progress, saved 2026-10-14 2026-10-14 03:15:12 +00:00
stats.go Give the site's words and pages one authority 2026-10-26 19:00:13 +00:00
stats_test.go Add simple statistics page 2026-09-22 04:53:53 +00:00
summary_test.go Show four sentences of a book's description, not one 2026-09-28 18:33:55 +00:00
suppression_test.go Rollup of a bunch of changes 2026-09-24 21:13:24 +00:00
TAG-RECIPE.md Document AR searching and the tag recipe 2026-10-04 14:56:46 +00:00
trail_test.go The trail's middle step is Catalogue, not The Catalogue 2026-11-02 18:02:50 +00:00
treasurypilot.go build the treasury pilot page from her lists, and record the three rulings 2026-10-25 01:20:44 +00:00
treasurypilot_test.go build the treasury pilot page from her lists, and record the three rulings 2026-10-25 01:20:44 +00:00
wayfinding.go Re-file the menu around the four doors, and make the home page the doors 2026-10-06 16:09:42 +00:00
wayfinding_test.go Put the seven peak medallions on the live Wayfinding page as a gallery 2026-10-27 20:05:07 +00:00
workroom.go Count only open stages in the workroom summary (T47) 2026-10-17 17:51:23 +00:00
workroom_test.go Anchor the workroom fold test on the page's own details 2026-10-31 22:17:35 +00:00

McKelle's Books

A book-recommendation site for parents, librarians and teachers.

The reader interview is server-rendered, and both its answers and the catalog behind it live in Postgres: 3,579 books with their genres, tags, identifiers, reading measures, reading-research provenance and award/curriculum listings.

The interview's job is not to sort a reader into a type. It gathers the fewest pieces of high-value evidence it can — what a reader wants today, what they have loved and why, what did not work — and turns them into weighted signals a recommendation engine scores the catalog against. Every answer is kept as the reader gave it, alongside whatever was derived from it.

Layout

Path Purpose
main.go HTTP server. Listens on $BOOKS_ADDR.
config.go Environment parsing — BOOKS_ADDR, POSTGRES_DSN, and the SMTP settings.
interview.go The question bank, branching rules, and signal derivation.
recommend.go Scoring the catalog, choosing a shelf, and reaction handling.
review.go The -review report, and the notes on a shelf.
profile.go The reader profile page: what we believe, and how to correct it.
auth.go Accounts, sessions, password hashing, and the password-reset flow.
admin.go The admin page and who may reach it.
bookedit.go The book editor: its form, its rules, and the revision it records.
mail.go The SMTP relay, and the reset email it sends.
stats.go The statistics page: its queries, and its chart layout.
compress.go Gzip for text responses, the rules it keeps, and why.
search.go Search, its suggestions, and the two detail pages.
scatter.go The scatter plot: its axes, and the points that link to a book.
browse.go The explorer: the shared filter, the year ladder, and the clickable charts.
templates/partials.html Templates shared by more than one page — today, the scatter plot.
pages.go The site map, routing, and the page handlers.
templates/ html/template sources, embedded in the binary.
web/scss/ Stylesheet sources, including the colour palette.
web/assets/ Served assets, embedded in the binary (see Styles).
web/design/ Design mockups, embedded and served at /design/.
Makefile The css build step, plus build/run wrappers.
db/ Pool, Ex, and the typed Execute* helpers.
db/migrations/ goose SQL migrations, embedded in the binary.
db/query/ Hand-written go-jet queries, one file per table.
db/gen/ go-jet's generated table/model code — committed.
db/jet/ The code generator. Dev tool, not shipped.
cmd/import/ The catalog importer. Dev tool, not shipped.
nix/package.nix buildGoModule derivation for the binary.
nix/module.nix NixOS module: systemd unit + firewall opt.
flake.nix Exposes packages.default and the module.

Pages

pages.go holds the site map: an ordered list of pages that is at once the router, the top navbar and the content of the generic page template. Adding a page is one entry there, plus a handler if it needs one; an entry with no handler of its own is rendered from the copy written beside it, which is where most of the prose on this site lives.

The site is four doors, and the addresses are grouped that way below. These tables are checked against the router by TestReadmeRoutesAreTheRouters: an address the site does not answer, a page the table has not been told about, or a retired address presented as current each fails the build. So a new page means writing it down here as well as registering it in pages.go.

The four doors

Route Method Behaviour
/ GET Home: the four doors, and nothing else.
/reading GET The Reading: the next interview question, or the shelf. Requires an account.
/reading POST Stores one answer, then redirects. Requires an account.
/reading/reaction POST Records a reaction to a recommended book.
/reading/retry POST Answers the offer on a book that was set aside: try it again, or not yet.
/reading/review POST Records what a reader thought of a recommended book, or of the shelf as a whole.
/reading/readers GET The readers this account holds. Requires an account.
/reading/readers/switch POST Acts as one of those readers from this browser.
/reading/readers/new POST Opens a blank reader and switches to it.
/reading/readers/undo POST Takes a reader back off the shelf it was put on.
/reading/reader GET The reader profile: what the system believes about the reader, and where each belief came from.
/reading/reader POST Saves the reader's age, grade, reading level or name.
/reading/reader/signal POST Records that an inferred belief is wrong.
/reading/reader/read-on POST Records (or clears) the date a book was read.
/reading/reader/forget GET The screen before a reader's profile is emptied.
/reading/reader/forget POST Empties it.
/reading/reader/delete GET The screen before a reader is deleted.
/reading/reader/delete POST Deletes the reader and everything attached to them.
/reading/reader/handoff GET Hand a reader on: make a login now, or take a claim code.
/reading/reader/handoff/code POST Mints the claim code, or returns the live one rather than a second.
/reading/reader/handoff/put-down POST Puts the reader down on a shared computer, keeping the way back for a day.
/reading/reader/slip/{code} GET The slip on its own, to be printed and cut out.
/reading/reminders GET The Reminders: the books this reader asked the site to keep. Not a shelf — a list the reader made, which no interview and no deleted session can empty. Requires an account.
/reading/reminders/remove POST Takes one book off that list. Requires an account.
/reading/remember POST Puts a book on it: the control that stands on a book's page, a shelf card, the Treasury and The Catalogue. Requires an account — a signed-out press is sent to the account form, which carries the book through and puts it on the list once the account exists.
/reading/survey GET Placeholder for the expanded interview (T86): the page exists, the interview is not written. The word "survey" is retired — see plan/decisions.md, 2026-10-26.
/constellations GET Start from a book they already loved: the books that sit near it, each line with its reason on it.
/wayfinding GET Roads: the book they are on now, and the rungs from there to the books worth reaching. Opens at a book when a list sends one (?book={id}).
/discovery GET Discover: the rooms behind the fourth door. Four cards, three of them open; Serendipity is still a promise.
/discovery/browse GET The Catalogue: the statistics as controls, with the matching books listed underneath. Not a room — it is reached from Miscellany, one level down.
/discovery/best GET Treasury: the room — three equal doors, Age, Genre and Merit, in a lamplit field, and nothing else.
/discovery/best/age GET Treasury · Age: the lists answered at the age of the reader, and the grade-level collections as they are built.
/discovery/best/genre GET Treasury · Genre: the shelves of anchors, best in class kind by kind.
/discovery/best/merit GET Treasury · Merit: the fifteen source lists, where they overlap, and Don't miss these.
/discovery/statistics GET Curiosity (titled Statistics): what the numbers say — counts of the catalog by genre and by decade.
/discovery/miscellany GET Miscellany: the reference room, and the way in to four pages under it — The Catalogue, From the Reference Desk, About the Library and Keep the Lights On (McKelle, 2026-10-31).
/discovery/reference GET From the Reference Desk: where the descriptions and measures come from, the eight families of list the catalogue is built from, and the other places worth looking.
/about GET About the Library: how Reader’s Atlas works, how it came to be, and what it is trying to do. The footer’s About lands here; /about/history, /about/goals and the rest of the /about/ family forward here.
/support GET Keep the Lights On: a thank you for visiting, the library saying it is glad to accept gifts, and the ways to help that cost nothing. It prints no Donate button and no address, because the way a gift arrives is not settled (T127); the acknowledgments (T117) are still owed.
/how-to GET How To: start the interview, and who gets the most out of it.

The top bar carries the four doors and nothing else; the footer carries the pages about the site itself. A footer link is labelled with the subject it offers rather than always with the page's own name: the reference room is filed there as About, and lands on the part of that page that explains the site.

Addresses that are not menu entries

Search, the detail pages, the claim page and the admin tools are addresses rather than destinations — whether they are reachable depends on what is being looked at or who is logged in, which a static menu entry cannot express. The ones a reader can reach are here; the admin ones are below.

Route Method Behaviour
/search GET Everything matching a query, by title or author.
/search/suggest GET The same matches as JSON, for the type-ahead.
/request POST A reader asks for a book the catalog does not hold; the row it writes is the record a curator later works from.
/books/{slug} GET One book.
/books/{slug}/like GET That book's nearest neighbours, each with its reason — what the Constellations door opens.
/books/{slug}/chart GET The charted plate around a book: one fixed layout of one declared region, with asserted relationships and their evidence. Only four books are in a region so far; every other book redirects to /like.
/authors/{id} GET One author and their books.
/claim GET The claim form a slip leads to.
/claim POST Redeems a code and adopts the reader.
/assets/ GET Embedded stylesheet and JavaScript.
/favicon.ico GET The site's icon, at the address a browser asks for unprompted.
/covers/ GET A book's picture, kept on this machine (see Book pictures).
/design/ GET The design mockups (see Design mockups).

Accounts have a table of their own, below in Accounts.

Admin

Every address here is administrators only, and the gate is decided per request rather than per session.

Route Method Behaviour
/admin GET What tools exist, and what is still to come.
/admin/covers GET The cover adjudication run: the next book whose picture the resolver found candidates for but would not choose, with its candidates at a size a person can judge.
/admin/covers POST Records one verdict — a picture, or no picture — in plan/cover-verdicts/adjudicated.tsv, then moves to the next book.
/admin/books/{slug} GET The book editor, filled with what is stored.
/admin/books/{slug} POST Saves an edit and records the state it replaced.
/admin/books/{slug}/lookup POST Asks the sources about a book already in the catalog. What comes back is proposed, never written.
/admin/books/{slug}/tags/{action} POST Adds or removes one tag. It writes immediately, because a tag is a statement about the book rather than a field of it.
/admin/books/{slug}/remove GET The screen before a title comes off the shelf.
/admin/books/{slug}/remove POST Takes it off, once the confirmation word has been typed.
/admin/books/{slug}/restore POST Puts it back.
/admin/readers/new POST Makes a reader and its slip together — ownerless until somebody claims it.
/admin/readers/{id}/slip POST A slip for any reader, new or already working.
/admin/readers/{id}/attach POST Hands a reader that answers to no account to the signed-in administrator.
/admin/readers/{id}/test POST Marks a reader as one made for testing.
/admin/workroom GET The workroom: what has been asked for, and the moves that take a book from the ask to the shelf.
/admin/workroom/ask POST Records an ask.
/admin/workroom/{id}/stage POST One move, offered only when it is the next one from where the book stands.
/admin/workroom/{id}/hold POST Sets a book aside.
/admin/workroom/{id}/publish POST Puts the book on the shelf. It refuses while the book still lacks a genre or a description.
/admin/workroom/bulk POST The same move for several books at once.
/admin/workroom/question/{id}/answer POST Answers a question about a book already on the shelf. The answer is recorded; the change to the catalogue is made afterwards.
/admin/tasks GET The open-work list, drawn as a page.
/admin/lexicon GET The site's vocabulary and its page map — the Lexicon and the Floor Plan, drawn from plan/lexicon.md.
/admin/plan/ GET Any document in plan/, drawn as a page; /admin/plan/ itself is the plan's index, from plan/README.md.
/admin/maintenance GET The yearly job, and what the catalogue says about it.
/admin/exports/ GET The working spreadsheets, as files to download.
/admin/sources GET The places the metadata lookup asks, and the keys it uses (masked).
/admin/sources POST Saves them.
/admin/editions GET The records built from an edition in another language, a door onto the book's own screen.
/admin/second-opinion GET An outside source's reading measure set beside ours, sorted by how far apart they are. It changes no book.
/admin/treasury-pilot GET The Treasury pilot: the Fourth Grade shelf with a reason for every book, the eight Fantasy anchors, and every book two or more institutions recognise. Reads the curator's committed list; stores and changes nothing.

Retired addresses

Pages that used to stand on their own and are now passages of another one keep their old addresses working, as a redirect rather than a 404: they were in the menu, so they are bookmarked, linked and written on paper. Those redirects live in registerRoutes in pages.go, and they are not listed here, because a table of addresses that are not pages is a second thing to keep true. The one that is on paper — a slip printed before the four doors were named carries /discover/reader/slip/{code} — has to keep working, so it does.

Each interview step is its own screen and its own form-encoded POST, which redirects on success so a reload cannot resubmit an answer. A step that fails validation re-renders with the reader's choices still in place. Bootstrap's own bundle is loaded for the navbar's dropdowns and mobile toggle.

Scripts

The site's own script is web/assets/js/search.js, and it is an enhancement rather than a dependency: with it, the navbar's box suggests matches as you type; without it, the same box is an ordinary GET to /search. Nothing else on the site needs scripts.

That is a standing decision, not an accident of what happens to have been built so far. The readers are mostly parents on phones and teachers on school and library machines — the population least able to absorb a script that fails to load, or a browser that changes underneath it. Three consequences follow, and they are what to check a new feature against:

  • The server decides. Filtering, counting and scoring happen in Go. What is selected is expressed in the URL (?genre=Fiction&year=1900c&x=lexile&y=year), which is why every view is a shareable address and why a page is complete the moment its HTML arrives.
  • A script is an improvement on something that already works, both of the two above being exactly that. What a script must never be is the only way to do something: with scripts disabled the reader should lose polish, not function.
  • So some things are not built, deliberately. The explorer's scatter plot has no instant styled hover panel — it uses an <a> and a <title>, which the browser draws itself — and there is no rubber-band drag to select points. Both were considered and declined on 2026-09-30, for the readers above. Range selection is still planned; it will be written as the same URL a script would have written, so it survives the script not arriving.

The reader interview

/reading is behind an account — a logged-out visitor is sent to the login form and returned afterwards (see Accounts).

It is adaptive: which questions appear depends on what has already been answered and on what the reader's profile already holds. A new reader sees about five to nine screens; a returning one sees as few as the current request and an avoidance question, because their favourites, dislikes and reading level are already recorded. The interview stops as soon as it has enough to produce a shelf.

interview.go holds the question bank. Each question declares its fields and an Applies predicate, and the interview is simply the first unasked question whose predicate holds. The rules that matter:

  • Ability already known (a declared Lexile, or two named books the catalog can measure) → the reading-level question is not asked.
  • One favourite → ask for a second, unless the reader already named two preferences. One book can mislead a similarity judgement.
  • A broad genre on its own ("fantasy") → ask what kind. Not asked when favourite-book evidence already answers it.
  • Three or four favourites whose reasons agree → stop asking generic preference questions.
  • A disliked book without a reason → ask why. The title alone is weak evidence.
  • A reader who already has three books on file → ask "Read anything good lately?", one box, instead of the favourites question. A returning reader is otherwise never asked for a favourite again.

Titles the catalog does not hold

Most of what a reader types in a favourites box is not in the catalog: a series name, a book from outside a children's collection, or a title with a book's words in a different order. Three questions exist for that, and all three keep the interview's promise that nothing is invented on the reader's behalf:

  • "Is this the book you meant?" — a typed title is only used as an answer when the whole title agrees, articles and punctuation aside (ResolveTitle, sameTitle). A near miss — a dropped subtitle, an added word — is shown back as buttons, with the typed title carried in the answer so a choice belongs to the title it was about. "None of these" falls through to the question below. This replaced a containment match that turned a fantasy reader's The Drowned City into Drowned City: Hurricane Katrina and New Orleans.
  • "What was the book like?" — what the book was about, what the setting was like, and what happened. Chips drawn from the subject tags, plus a box for the reader's own words. Answers become topic signals with source unknown_favorite, marked with the titles they are about, and a note row keeps the reader's description itself. At most two titles are asked about.
  • The request list — a title we could not place is recorded in book_request with origin suggestion and the reader's own description as its note, so the collection backlog carries why somebody wanted the book. The workroom page words a suggestion differently from a curation request.

Answers are kept twice: interview_response.answer holds the structured form, and raw_response holds what the reader actually wrote. The taxonomy in interview.go will change; the sentences will not.

From answers to signals

An answer becomes weighted rows in reader_signal, each keeping its source and scope. Evidence accumulates across interviews, but restating the same fact updates it rather than adding a second copy:

Family Matches against Comes from
topic tags, then the description request, interests, why-liked, a favourite's own tags
tag tags only a stated dislike (too scary, too sad)
genre the book's genre a favourite's genre, a reaction
length pages or word count how much book they want right now
effort Lexile relative to the reader "something challenging", "too hard"
density words per page, Lexile relative "the writing was too dense"
exclusion tags only an explicit avoidance
note nothing a reason with no book-level meaning
shelf nothing "surprise me", an instruction about the shelf

Three decisions are load-bearing:

  • Negative evidence stays narrow. "Lord of the Rings was too long and dense" becomes negative length and density signals — never a negative on fantasy, on Tolkien, or on quests. A note signal records "boring" without ranking anything on it, because boredom names nothing a book can be matched against.
  • Persistent taste is separate from today's appetite. scope distinguishes them, and a shelf reads every durable signal plus only this interview's current ones. A request for something short for the car does not permanently lower a reader's length preference.
  • A repeated fact is one fact. A durable signal is keyed by reader, family and name, and a current one by interview as well; one index serves both. A reader who mentions fantasy in three interviews ranks fantasy once, not three times — before this, repetition was indistinguishable from intensity. The strongest statement of a fact wins, measured by absolute weight so a firm "too long" is not displaced by a mild one, and the newest row breaks a tie.

A favourite is matched against the catalog by its significant words, with articles and punctuation set aside, so that The Hobbit, Hobbit and a hobbit all reach the same book however the reader phrases it. The catalog stores titles in published order — migration 00019 rewrote the 832 that had arrived inverted, as Hobbit, The — but a reader is not held to that. A title the catalog does not hold is recorded as such, with no book attached, rather than forced onto the closest match.

Where a list is alphabetical it is ordered by that published title, article and all: The Hobbit files under T, between The Holiness of God and The Horse Catcher, not under H beside Hobbit. This is the plan we are working to, not an accident of rewriting the titles — a list that skipped a leading The, A or An would have to say so deliberately, and nothing asks for one today. If a process later turns out to depend on the old shelf order, that is the moment to revisit the decision, in the open, rather than to bend one query quietly.

What the shelf says it heard

The shelf opens with a short panel — What we heard — that echoes the subjects the reader gave, and names any the catalog could not answer. It exists because a typed interest is matched as itself: the buttons on the interests question are keys of signalTokens and are certain to find books, while a word typed into the box beside them is not, and if nothing answers it the word contributes nothing to the shelf. Before the panel, "we used it", "we misheard it" and "we dropped it" all looked the same from the outside, which is a silence the show-back breaks. A button appearing in the unanswerable list is a bug the page reports rather than hides, since a button is a promise.

Two rules keep it honest:

  • Only the reader's own words. The panel draws from the request, kind_of and interests questions. A favourite's tags are excluded on purpose: those are the engine's reading of a book, not something the parent said, and echoing them back would be putting words in their mouth. The questions that describe a book we do not hold are excluded for the same reason — those words belong to a title, not to a taste.
  • Unheard means the catalog cannot answer it, not that the shelf lacks it. Every book about a subject may be one this reader has already read, so the question is asked of the whole catalog, in features built before the reader's own screens are applied, and answered with matchTopic — the matcher's own rule, so the page cannot explain a result the engine did not produce. A tag is decisive; a mention in a description counts, because the engine weighs it at 0.4 rather than zero, and calling that unheard would be a lie. See heardTopics, catalogAnswers and ranking.catalog.

The panel is drawn whenever anything was heard, so its absence is never the only sign that the interview listened; the sentence naming what could not be matched appears only when something missed.

A word it could not answer is kept. When the interview is finalized, every word the reader gave and the catalog could not answer is written to book_request — the same list an unheld title goes to — one row each, with a note saying which question it came from. It is built from the same two functions the page is, heardTopicsFrom over the signals this interview wrote answered against the whole catalog, so the words kept are exactly the words shown. Only the unanswered ones are kept: "turtles" is a word the shelf can already act on, and a row for it would ask for books to be found that are already here. A word typed into two boxes is one row, because it is one subject. Two different readers asking for the same word stay two rows — that count is the signal. See recordUnheardSubjects.

What the row is not. It is not a promise that the catalog will hold a book about the word, and it is not the ledger of untagged subjects. It is a reader's ask, kept where asks live: the word sits on the workroom's "Asked for" list beside the titles, and its note — "asked for as a subject, not a title" — is what tells the two apart.

Filters and ranking

Two things can happen to a book: it is ruled out, or it is ranked.

Only three things rule a book out:

  1. An explicit avoidance. Matched against tags only. A history of the Second World War is not removed by a reader who asked to avoid intense violence, because the exclusion never reads descriptions.

  2. A developmental mismatch. Two statements in the catalog say who a book was written for, and either rules it out when it places the book far outside the reader: the AR Interest Level (LG, MG, MG+, UG), and the ages the catalog associates with the book — the suggested reading range, or the Ambleside year a book is read in where there is no range. A fifth grader is not offered lower-grade books, and a first grader is not offered a book for four-year-olds or for teenagers. Both are compared with the reader as they are today.

    The one thing reading difficulty may do is lift the floor of an AR Interest band, and only its floor. "MG" spans grades four to eight, and a six-year-old who has finished Charlotte's Web is in that band already, so a band's floor yields to what the reader has actually read. What that means is narrow: every measure the catalog states for the book and the reader must agree — its Lexile within 150 points of the reader's own level, its ATOS within a grade — and a book with neither measure is not within reach at all, because reach is shown rather than assumed. A band that begins in high school is never opened this way, however easy the book: Night and Beloved are someone else's reader. The age range and the Ambleside year are left alone, because they are statements about one particular book rather than a five-grade span. A book the catalog says nothing about is kept — unknown is not a disqualification.

  3. Already spent. A book the reader has finished, quit, dismissed from a shelf, or already listed as a favourite is not suggested again.

Everything else only raises or lowers a book. Ranking sums the signals that match, adds a nudge for curated award and recommended-reading tags, and adds an accessibility term — but a book with no Lexile is never penalised for it, and a reader with no known level is never penalised either. Missing data stays missing.

The collection lists are weighed too, not only drawn on /discovery/best. A list mark is evidence of a different kind from a tag: it is somebody's judgement that this book was worth a reader's time, made once and left standing. The weight is measured rather than declared — how selective the list is, against the catalog's own size — so a list naming 1,400 books is worth almost nothing per book and a list of forty is worth a great deal, and a list that grows quietly loses its weight as it does. Several lists are worth more than one but with diminishing returns, capped so that no heap of lists can take a shelf over. The recommendation names the count it stands on ("Named on 3 of our book lists") only from two lists up, because one list is far too common to be worth saying.

Four of the fifteen lists name books for grown-ups — Harvard Classics, St John's College, Time Magazine 1923-2005, and TJEd Adult Recommended. They are weighed only for a reader of adultListAge (15) or more. Below that they are not weighed at all, which is the age and not the reading level: a nine-year-old reading at adult level is still nine. The case that set the line was Time's hundred putting Blood Meridian onto a nine-year-old's shelf of children's historical fiction. A reader whose profile states neither age nor grade is treated as a child reader, because readerAge reports unknown rather than guessing, and a missing birthday is not evidence of being grown up.

None of this changes what the catalog holds. Every membership stays on the book's page and in Browse; this decides only what a mark is worth when a shelf is chosen. The site's audience is children and the adults choosing for them — the home page says parents, librarians and teachers — so the catalog is a children's catalog and an adult shelf drawn from it will lean young. If adults turn out to be a real audience rather than an occasional one, the answer is more titles for them, which is a catalog decision, not a scoring one. Most adults have other ways of finding books.

The shelf is not a top-N. At most two books share a genre while anything else is available, and one wildcard is added from further down the ranking, chosen deterministically from the interview's id so reloading does not reshuffle it. A reader who asked to be surprised gets a stricter genre cap and a second wildcard, because that is what the request asks for; the request ranks nothing itself. Per book, the page says why in the reader's own evidence, and offers four reactions. Each card also carries the book's reading measures where the catalog has them — the Lexile and, beside it, the ATOS, with the AR interest level underneath — so "is this at the right level?" can be answered at a glance rather than only inferred from the explanation. The three are independent and the card shows whichever the sheet carried, either alone if that is all there is; a book with none of them says nothing rather than showing a dash. The ATOS is the same number the book page calls the Accelerated Reader level, under the name the reading programmes use for it, and the interest level is written as the code and the grades it covers — "LG (K–3)" — because "LG" alone means nothing to a parent. The same block is drawn by the search results and an author's bibliography, which are the other two places a person chooses between titles; one partial draws it in all three, from public.Levels. A reaction feeds back: "interested" contributes weak preference signals from the book's genre and tags, "already read it" is capability evidence, and "not for me" is recorded without being generalised into a dislike of the genre. "Not for me" and "already read it" both spend the book, so the card just answered leaves the shelf; the page returns the reader to the card that has taken its place, rather than to the top, because with eight cards of a dozen controls each the card they answered is somewhere below the fold and hunting for it reads as "did that even save?". Each card posts its own position back for this, so a shelf that has shrunk past it lands on the last card and an empty shelf lands on the top.

Under each book, and once for the shelf as a whole, is a collapsed "I wish I knew…" box. What it records is not which cell is empty but what information was missing — the question that turns a failed recommendation into a task. Notes are append-only and keep the book they concern, if they concern one.

Reviewing a reader

-review turns the binary into a diagnostic instead of a server. It prints the evidence a reader has accumulated, what the engine removed and why, every book it ranked with the contributions that scored it, and the shelf's notes. The header states the reader's age and grade, the level the ranking used, and the reading reach the developmental screen weighed a band's floor against — the last two are what a shelf that looks wrong is usually wrong about:

$ POSTGRES_DSN='postgresql:///books?host=/run/postgresql' ./books -review <reader-uuid>

It takes a comma-separated list, so several readers can be compared in one run, and -session <uuid> pins a particular interview instead of the reader's latest finished one. It lives in this package rather than in its own command because it reports on the engine in this package, and an explanation that could drift from the ranking it explains would be worse than none.

The reader profile

/reading/reader shows what the system believes about the reader and where each belief came from: the basics it was told, the signals it worked out, and the books it has been told about. It exists because the reader model is built by inference — a favourite's tags become interests, a complaint about length becomes a length preference — and an inference that is wrong is worse than a missing one, because it silently steers every shelf that follows.

Every inferred belief has a "Not true" button. That records a suppression rather than only deleting the row: the next interview would otherwise derive the same belief from the same evidence and the correction would quietly revert. A suppression is keyed by the belief, not the row, and the profile lists what has been suppressed so a quiet absence is never a mystery. Suppressed beliefs are permanent; there is no undo yet. A blank field in the basics form means "leave what we have alone", not "forget this".

The page also lists the reader's shelves by date — every interview they have finished, newest first, each one opening that shelf again, plus the newest interview they did not finish, offered as somewhere to carry on. It exists because a shelf has no address of its own: it is served at the interview's address with the session hung off it (?s=), every such link is built inside a redirect during the sitting itself, and arriving without one opens a new interview rather than the last shelf. A reader who closed the tab therefore had nothing left to come back to. An interview's own answers are kept as session-scoped signals (SignalsForReader), so reopening one draws from that interview's evidence rather than from whatever the reader believes today. Readers with no account are listed too: the cookie is their handle, and the links carry the guest flag the interview's start screen offers.

What V1 deliberately does not do

  • No interpretation of free text. "The dragon was cool" is preserved and keyword-matched; it is not parsed. That is why the raw sentences are kept.
  • No complete taxonomy. signalTokens covers what the question bank asks about. Which books deserve richer annotation is what the gold-set testing is for; the catalog's tags are user-facing labels, not a recommendation schema.
  • No second difficulty measure for ranking. Lexile is the measure the ranking reads, and an approximate Lexile↔ATOS conversion would be a guess presented as data. ATOS is read too, but only by the developmental screen and only to compare one book against one reader — never to rank two books against each other.
  • No age-based filtering without a reason. A stated age or grade, or one implied by the other, is screened only against what the catalog says about who a book was written for: its AR Interest Level, its suggested reading range, or the Ambleside year it is read in. Age on its own removes nothing, and what the reader has already read can open a band's floor but never close one.

Curiosity

The room is named Curiosity. The page said "Statistics" until 2026-10-26; see plan/lexicon.md.

/discovery/statistics answers three questions about the catalog. Every figure is inline SVG laid out in stats.go and scatter.go, so there is no chart library and no JavaScript: the page works with scripts disabled. Each figure carries an aria-label describing what it shows, because an SVG is announced as a single image.

The first two count the catalog by genre, and by decade of publication.

Two figures are deliberately excluded from the charts and reported in their captions instead: the handful of titles with no genre, and the 462 published before 1900. Decade-resolution bars across the catalog's full span — the oldest title is from 800 BC — would flatten every bar before the twentieth century to a pixel. Widen histogramFrom in stats.go to cover more of it.

The third figure is a scatter plot of any two measures, chosen from the two menus beside it: word count, pages, Lexile, the AR book level, Guided Reading Level, the two suggested ages, the two Ambleside years, and year published. scatter.go holds the menu and the layout. The choice travels in the URL as ?x=pages&y=words, so a plot can be linked to, and the page renders it without scripts.

Every dot is also a door. Each one is wrapped in a link with a plain <title>, so the browser names the book on hover and the click opens that book's map of similar books at /books/{slug}/like, with the book in the middle. Nothing is asked of the browser beyond an <a> and a <title>, and the dots are kept out of the tab order deliberately: two thousand points would be two thousand tab stops, and the plot is announced as one image. The AR level is offered as an axis at the operator's request; it is a measure a reader may look at, and the site still does not screen or rank books by it.

Four things about that plot are worth knowing before changing it:

  • A missing measure is not a zero. A book with no Lexile has no position on a Lexile axis, so it is left off rather than piled onto the axis; the caption says how many books carry both measures.
  • Each axis is drawn over the 1st to 99th percentile of the values on it. An eleven-volume set at 14,777 pages and an 800 BC book are real data points, but an axis spanning the whole of either range compresses the other two thousand books into a corner. The books outside the frame are counted in the caption, not silently dropped.
  • Guided Reading Levels are letters, A to Z, stored as the ordinal that sorts correctly and written back out as the letter it means, in axisKind.text. Every other measure is already a number of the same kind, so it needs no such translation.
  • A reading measure is never written in thousands. A Lexile is a point on a scale rather than a count, so its gridlines read 1000 and 1200, not 1,000 and 1,200 — axisLexile — while word counts and page counts keep their separators. An AR level keeps its decimal, because the decimal is the measure: axisLevel writes 4.0, and 4 would be a different book.

Miscellany

The reference room. How the site came to be, what it is trying to do, where its data comes from, the eight families of list the catalogue is built from, and five other places worth looking. It is where the catalogue is browsed from: a card near the top hands the reader down to The Catalogue, which has no door of its own. The room's material has outgrown a single scroll and will be divided into sections before it is pleasant to read (T124).

It was the page called About until 2026-10-29, when the reference desk was given the name Miscellany and a room behind the Discover door. /about and everything under it now forwards to /discovery/miscellany, so old links and bookmarks still land. The room's own title says Miscellany, and the footer's link says About and lands further down, on How McKelle's Books came to be (#history): a footer link offers a subject, and the subject is the site, not the catalogue card or the eight families of list that share the page. The word and the anchor are declared on the page's entry in the site map (Foot, FootAt). What the room does not yet hold — support and contact, credits, the FAQ, and one page saying how recommendations work — is T116–T119 in plan/tasks.md.

The move reversed the ruling of 2026-10-26 that Miscellany meant the catalogue browser and "has no opinion"; the browser took the name The Catalogue and kept its address. Making that browser a fifth room was the first shape tried the same day and was rejected — Discover keeps four, and the browser sits under Miscellany instead. plan/lexicon.md, under Miscellany and The Catalogue, records both.

The Catalogue

The page is named The Catalogue. It was called Miscellany from 2026-10-26 to 2026-10-29, and the page said "Browse the Shelves" before that; see plan/lexicon.md.

It is not a room: Discover draws four cards, and this page is reached from Miscellany, declared there as a child (Children in pages.go) and drawn one row below it in the navigation map. Keeping it out of the directory was McKelle's ruling of 2026-10-29 — the reference room carries the way down, and the browser stays out of the menu.

/discovery/browse is the explorer: the statistics page's figures turned into controls, with the books they select listed underneath. It was built to answer the question the statistics page leaves open — then show me the books — and it takes its behaviour from the Tableau dashboard the operator built this site partly in response to.

Two rules decide how it behaves, and both are in browse.go:

  • Every panel shows the same filtered set. Pick a genre and the year bars, the scatter plot and the list all narrow together, because all of them are drawn from the same rows. There is one filter on the page, whatever number of views there are of it.
  • A chart ignores the filter it is the control for. The genre bars count the books surviving the year filter, and vice versa, so that a bar always answers "how many books would I have if I picked this?". Without this, choosing Fiction would leave Fiction as the only genre bar standing and nothing to choose next. A bar at zero keeps its place, so the chart does not rearrange itself under the reader's hand.

The filter lives only in the URL (?genre=Fiction&year=1900c&x=lexile&y=year), which is what makes every view a shareable address and what lets the page work with scripts disabled. browseFor is the whole of the behaviour and is pure — no database — so the filtering and counting are tested without a catalogue.

Every control hands the reader back to itself. The axis menu is an ordinary form, so pressing Plot is a page load; without a fragment on the action the browser lands at the top of the new page, and a phone then shows a summary strip and two charts between the reader and what they asked for. So the form posts to #plot, and each chart's bars carry #by-genre or #by-year, landing on the figure they came from. Those anchors sit on the whole figure — heading, caption and controls as well as the drawing — rather than on the chart itself, which was tried first and left the reader at the dots with the heading scrolled off above. scroll-margin-top in main.scss keeps the heading off the very edge of the window. The chips and Clear all at the top are deliberately left unanchored — that is where the reader already is. TestTheExplorerHandsTheReaderBackToWhatWasClicked pins all of it: that the anchors exist, that every bar carries one, and that #plot stands above the heading rather than below it.

Four things are worth knowing before changing it:

  • One query, narrowed in Go. public.BrowseBooks returns the whole shelf once, with each book's genre, year and measures; the filtering and every count happen over those rows. That is what keeps a page with four filtered panels down to one round trip, and it is what makes the list and the bars incapable of disagreeing.
  • The year chart covers the whole timeline, unlike the statistics page's histogram, which starts at 1900 and reports what it left out. Because the explorer has to be able to reach the catalogue's oldest books, its buckets widen as they go back — centuries before 1500, quarter-centuries to 1900, decades after — rather than stopping. The ladder is browseYearBuckets.
  • Titles are capped, never truncated silently. browseListLimit bounds the list; browseData.Hidden carries how many matched and were not drawn, and the page says the number.
  • The list is a way in, not a dead end. Each row offers the three ways onward that the plot's dots already do: the title opens the book's own page, more like this rings it with the books that sit next to it (/books/{slug}/like), and roads from here starts a wayfinding road from it (/wayfinding?book={id}). The row therefore carries the book's id as well as its slug — the road page names a book by number, the address a reader sees uses the slug. TestEveryListedTitleCanBeLeft fails if a row loses any of the three.
  • The books with no year stay reachable. Four books carry no publication year. They have a bar of their own (undatedBucketKey), a count in the summary strip, and a link that filters to them from outside the chart — because a reader who plots a year axis would otherwise watch them disappear with nothing on the page to explain it, and the caption says how many in view are missing one.

What is not built yet: selecting a range on the scatter plot (?min=, ?max=), dragging a box on it, and taking a title from the list straight to its own constellation or to a wayfinding road. The plot's own dots already lead somewhere: a dot opens the book's map of similar books.

Treasury

The room is named Treasury. The page said "Best books ever" until 2026-10-26; see plan/lexicon.md. The phrase is not abolished — it may return as a tagline, but that is a judgement about the collection and has not been made.

/discovery/best is the catalog's curation lists, opened at the age of the reader being shopped for. It is the one door of the four in the current design that is already a query: the lists exist, and only a page was missing.

A curation list is a row of collection — a spreadsheet column someone filled in, promoted in 00016_book_detail_fields.sql. The catalog holds fifteen, holding 2,837 books between them of which 330 stand on two or more. They are different kinds of thing: Newbery and the ALSC Notables are awards and librarian selections, Harvard Classics and St. John's College are canons of adult books, and Mensa Kids, Beautiful Feet, Deep Roots and the TJEd lists are curricula. They disagree with each other, which is why the page does not merge them into one ranking and call the result "the best": each list is drawn as itself, and every book names the other lists it stands on. Where several lists agree is the closest thing to a verdict the catalog holds.

The age filter is the page's other half, and it reads book_reading_level.age_from/age_to — the retailer's grade range converted to ages at ingestion (see Collections). The ages on offer are the ones the books actually span, so the filter cannot drift from the data. A book whose range has one end known covers everything past it, because that is what the source said. A book with no age recorded is not shown at any age: choosing an age is what leaves it out, and the page says how many books that is — 879 of the 2,837 — rather than treating an unknown age as a licence to be shown to everyone. With no age chosen, every book is on the page.

bestLists in best.go names which of the fifteen lists the page draws from, and that is an editorial decision rather than a technical one: the collection table says what a list is, and bestLists says whether we trust it. Every list is named today so that nothing is left off by default while the decision is open. A name that collection does not hold is logged and skipped rather than drawn as an empty list.

One list, at one age, is a page of its own — ?list=8, or ?list=8&age=10, with the age filter keeping whichever list is open. The whole page shows each list's first bestPreview books; a list's own page shows up to maxBestBooks and says so when it has cut the list short. There is no paging: the link promises only what the next page can keep.

The page is drawn light on purpose. It is a directory of fifteen lists and about a hundred books, so a book is one line — title, then author, year, age and its reading measures inline — rather than the stacked bookLevels block, which is what a card about a single book wants and what would make every row of a hundred three lines tall. Counts are written with their comma by the num template function, which is likeNumber, so how the site punctuates a number still lives in one place. A list's own page counts and describes that list; only the page showing all fifteen counts all fifteen.

Each list is drawn as a panel folded shut. The name, how many books it holds and what it is are on the page; the titles are behind data-bs-toggle="collapse" until a reader opens the panel, because fifteen lists and a thousand titles arriving at once is a wall rather than a directory. The titles are in the HTML and the fold is only a class, so nothing is fetched to open one, and the panels do not close each other — they are fifteen lists to read against each other rather than a set of alternatives. The Best in class roads above them fold the same way and by the same mechanism, since a road's shelf is a list too; the classes are named .fold-* rather than after the lists for that reason. The fold is Bootstrap's, whose bundle is on every page; with no script there is no way to open a panel, so <noscript> shows every list whole and takes the carets away. Failing to the long directory is the right failure. The caret is the only thing that says a name is a control, so it is drawn in web/scss/main.scss and follows aria-expanded — the attribute Bootstrap keeps in step with the open panel — rather than a class of our own that could drift out of step with it.

A book standing on three lists is three rows of book_collection, and the page counts books rather than rows — 2,837, not 3,364. The whole of the chosen lists comes back from one query and is narrowed and counted in Go, which is what lets the shelves and the numbers on them be drawn from the same rows.

Accounts

The interview is behind an account. It is the part of the site that records what a child read and what they thought of it, and that is only worth keeping if the reader can come back to it — and only theirs to see. The rest of the site stays open: the catalog, search, statistics, the explorer and the reader profile page all work without logging in, and an anonymous reader is still just the books_reader cookie's unguessable public id.

An account is what ties that history to a person: app_user holds the account, user_session one logged-in browser, and reader.user_id links a reader to the account that owns it — so every interview, interaction and signal already attached to that reader is attributable to someone who can log back in. auth.go holds the whole of it.

Route Method Behaviour
/register GET The registration form.
/register POST Creates the account and logs it in.
/login GET The login form.
/login POST Verifies the password and starts a session.
/logout POST Ends the session.
/forgot-password GET The "email me a link" form.
/forgot-password POST Mails a reset link, if the address has an account.
/reset-password GET The new-password form, for a valid token.
/reset-password POST Sets the new password and consumes the token.

These are addresses rather than site-map destinations, because whether they are reachable depends on whether someone is logged in, which a static navbar entry cannot express. The navbar draws the account area itself: log in and register links when logged out, the username and a log out button when logged in.

The rules that matter:

  • The plaintext password is never stored. Only a bcrypt hash is written, at bcrypt's default cost; verification reads the cost from the stored hash, so raising it later invalidates nothing. A malformed stored hash fails the login rather than the request.
  • A username is a free-form handle. Any characters, any case; the only limits are that it is present and at most 64 characters. It is unique case-insensitively, and, with the email, one of the two login names.
  • The email is the durable identifier. It is required, format-checked, stored lowercased and unique case-insensitively — it is what a password reset is sent to, and the only thing a reset will accept, so a username cannot be used to trigger mail to someone else's address.
  • A failed login says nothing about which names exist. An unknown name and a wrong password produce the same message and the same work, a dummy bcrypt compare, so response timing cannot enumerate accounts.
  • Sessions are rows, not flags. A session is a user_session row with an expiry and an unguessable UUID token in an HttpOnly, SameSite=Lax cookie; logging out deletes the row. The cookie is marked Secure whenever the connection was https, which behind the TLS-terminating proxy is visible only in X-Forwarded-Proto.
  • A reader is adopted, never taken. Registering or logging in claims the browser's reader only while it has no owner, so an interview answered before registering is not thrown away. A reader already owned by another account is left alone and a fresh one is opened. Deleting an account detaches its reader (ON DELETE SET NULL) rather than destroying a child's history with the login.
  • An owned reader is not readable without its account. Once a session has expired, the reader cookie alone no longer acts as that account's reader; the browser falls back to a new anonymous reader, and logging back in restores the original one.
  • The interview is gated, and the gate remembers where you were. A logged-out request to any interview address — the questions, the shelf, a reaction, a shelf note — is sent to /login?next=…, and both the login and register forms carry next through, so answering one lands back where the visitor was headed. next is accepted only as a rooted path on this site, so it cannot be used to bounce a freshly logged-in visitor somewhere else; a POST resolves to the GET address of its interview, never to the write itself.
  • The Reminders is gated too, the gate explains itself, and the book comes with you. A Remember makes a list a reader comes back to, so it belongs to an account rather than to a browser (McKelle, 2026-10-28). A signed-out press of the Remember control writes nothing and mints no reader: it is sent to the registration form with next, a reason and the book (/register?next=…&why=remember&keep=<slug>), which says why it appeared and puts the book on the list as soon as the account exists — the press is finished, not asked for again. The reader is then returned to the row they pressed on, where the control already reads Remembered: the thing itself is a better confirmation than a sentence about it, and it does not move a reader away from the book they were looking at. The one exception is a book the catalogue no longer offers — there is no row left to return to, so that press lands on the Reminders and says nothing could be added. The reason travels as a code and the sentence is chosen from it by accountReasons, so nothing typed into an address reaches the page as prose; the slug is cleaned by keptSlug, so a value that is not a slug never reaches the catalogue. Both the register and login forms carry keep through as a hidden field, so a mistyped password and the wrong door both keep it. The write is the same row a press writes — status interested, no session — and it never fails the sign-up it follows: an account is created even if the carried book cannot be written. Removing a book from the list goes through requireLogin like any other address about an account.

Admins

Some accounts administer the site. The flag is a single column, app_user.is_admin, defaulting to false, so every account stays unprivileged until it is deliberately changed:

$ psql -d books -c "UPDATE app_user SET is_admin = true WHERE email = 'you@example.com';"

Nothing in the site sets it. Granting privilege should leave a trail in an operator's shell rather than in a web form, and there is one privilege so far — a role table would be a schema in search of a second role.

An administrator sees an Admin entry in the account menu, which leads to /admin. The page lists what tools exist and what is still to come. The first one is the book editor.

Who may reach it is decided per request, not per session: is_admin is read when each request's session is resolved, so granting or revoking it takes effect on the next page load without the account logging in again. A visitor who is not logged in is sent to the login form and returned to what they asked for; a logged-in visitor who is not an administrator gets the site's ordinary 404 rather than a refusal, because a 403 would confirm that the address exists.

Editing a book

A book's page carries an Edit details button for an administrator, which leads to /admin/books/{slug}: a form holding the same fields the page shows — the catalog row, the reading measures, the ISBNs and the collection memberships — filled with what is stored. Saving writes all of it.

The one thing it does not do is write the row and move on. Every save records what it replaced:

Column Holds
book_id the book the edit was made to
edited_by the account that made it, while that account exists
edited_by_username the username as it was at the time
edited_at timestamptz: an absolute instant, written and read in UTC
before the book's whole editable state before the change, as JSONB

The book row is the state after the latest edit, so the row plus its revisions is the whole history — no diff has to be reconstructed, and any past state can be read back as a record rather than inferred. Nothing surfaces those revisions in the UI yet; the table is the audit, and it is complete on its own.

Four things are worth knowing before changing it:

  • The revision and the edit are one transaction. A change cannot exist without its revision, and a failed save cannot leave one behind.
  • The row is locked before it is read. The state recorded as before has to be the state the edit actually replaced, and two saves arriving together would otherwise both read the same one.
  • A save that changes nothing records nothing. The submitted state is compared against the stored one through the same JSON encoding the audit uses, so "nothing changed" means exactly that; the form says so instead of writing a revision whose before and after are identical.
  • An edit is protected from the next import, once. cmd/import leaves a book alone when an administrator edited it more recently than the last completed import, and names each such book in its log — sheet row, title, book id, when it was edited and by whom — so the operator can reconcile it against the sheet row by row. The sheet is still the source of truth for this data, so the protection lasts one cycle: an edit nobody reconciles is taken back by the following import, and the log says so. See Catalog import.

What the editor deliberately leaves alone: a book's tags (a many-to-many vocabulary that no tool edits yet, and every revision would otherwise carry a copy of), its slug (the address the page is served at, not a detail), its emoji, and the Open Library identifiers. If a tag editor is ever added, the before snapshot should grow to include them so a revision stays a complete record of what that tool can change.

Password reset

A reset is a password_reset row: a random UUID, the account it belongs to, and an expiry one hour out. Only the link that carries the token has it in the clear, and the row is deleted the moment the password changes. Asking for a new link deletes the account's outstanding tokens, so an abandoned link cannot be used later by whoever holds the mailbox.

The flow is auth.go throughout:

  1. /forgot-password takes an email address. The answer is the same whether or not it matches an account, and whether or not the mail is delivered — a send failure is logged, not shown — so the form cannot be used to find out which addresses are registered.
  2. The link goes to /reset-password?token=…, which shows the new-password form only for a token that is still valid.
  3. Submitting looks the token up, writes the new hash, deletes the token and ends every session the account holds, all in one transaction. Two submissions of the same link cannot both find it valid, and whoever was signed in on another device with the old password is signed out.
  4. The login page thanks the visitor and asks them to sign in with the new password.

BOOKS_BASE_URL is what makes the link absolute. It is configured rather than taken from the request, because a link built from the Host header can be pointed at an attacker's domain.

Mail configuration

Password-reset email is optional. With SMTP_ADDR unset the site still runs, and the reset pages say mail is unavailable rather than pretending to send. Setting it turns mail on, and then four things are required or defaulted:

Variable Meaning
SMTP_ADDR Relay as host:port. Unset disables mail.
SMTP_FROM Envelope and header sender. Required with SMTP_ADDR.
SMTP_TLS starttls (default), tls for implicit TLS, or none.
SMTP_USERNAME Relay user, for PLAIN auth. Optional.
SMTP_PASSWORD Relay password. Optional.
BOOKS_BASE_URL Public origin, e.g. https://books.example.com. Required with SMTP_ADDR.

starttls is the default because it is what a submission port (587) expects. none is for a local relay that offers neither; it still refuses to send in the clear when the server advertises STARTTLS and the upgrade fails. An SMTP_ADDR without SMTP_FROM or BOOKS_BASE_URL is a startup failure, like a missing POSTGRES_DSN.

search.go matches a query as a plain substring of a book's title or author, or of an author's name, case-insensitively — no ranking, no stemming, no fuzzy matching. Queries shorter than four characters are refused, and PostgreSQL's own LIKE metacharacters are escaped first, so searching for 100% means those four characters rather than "anything".

Matches come back in alphabetical order of the published title (above), so The Hobbit appears among the T's and an A-titled book among the A's; an author's page lists their books by year and, within a year, by that same title. The article is not skipped when sorting.

Both lists are places a person chooses between titles, so each result carries the book's reading measures — Lexile, ATOS and AR interest level — in the same block the shelf cards use. BookSummary, the shape both lists return, carries the three columns from book_reading_level, joined in bookSearch and BooksByAuthor; a book with no measures of its own shows nothing.

Surface Shows
/search/suggest Up to 6 books and 4 authors, each labelled Book or Author, as JSON.
type-ahead The same list under the navbar's box: four characters, then arrow keys and Enter, or click a row.
/search Up to 50 of each, in two sections.

Selecting a suggestion goes straight to /books/{slug} or /authors/{id}; those two pages are addresses rather than destinations, so they are registered in registerRoutes beside the site map rather than in it.

A book's page lists its catalog fields and description, its reading measures, the curation lists it appears on, its ISBNs, its URL and its award and curriculum tags, and links to its author. The reading measures are whatever the sheet carried, each shown as published and none of them invented:

Shown Comes from
Accelerated Reader level book_reading_level.ar_level, as published
AR interest level book_reading_level.ar_interest, code spelled out
Lexile book_reading_level.lexile, written 600L
Lexile code book_reading_level.lexile_code, code spelled out
Guided reading level book_reading_level.grl, the letter A–Z
Suggested reading age book_reading_level.age_from/age_to, in ages
Ambleside year book_reading_level.ambleside_from/ambleside_to, a curriculum year
Collections book_collection, with the source's value where it has one
URL book.url
ISBN-13 / ISBN-10 book_identifier

Three of those labels are deliberate. Suggested reading age is the sheet's From/To columns — a third-party retailer's US grade range, converted to ages at ingestion because a grade names different ages in different countries. The workbook does not record which retailer they came from, so the page does not name one: it is a suggestion, and it says so. AR interest level is spelled out in full (MG+ — upper middle grades, 6–8) because the code says who a book was written for, which is not the same question as how hard it is to read.

Ambleside year is a year of the Ambleside Online programme, not an age and not a calendar year: it maps roughly to a grade, year 0 is its first year, and many books are used across several, which is why the sheet has a start and an end. The scale runs 0 to 12, a check constraint says so, and a book whose year 0 is the whole story reads Year 0 onwards — the page notes beside the table that the years are not ages, because "suggested reading age" sits directly above them.

A measure that is missing is left out rather than shown as a dash: the section itself is absent when a book carries no measures at all, which is the case for 436 of them. Where a Lexile code arrives without a measure — always true of NP — the code stands on its own line.

Collections

A list's small print is whatever the source recorded beside the book: an age band for Mensa Kids, a grade for Deep Roots and The Good & The Beautiful, a set of years for St. John's College. Those are shown as they stand.

Beautiful Feet is the exception, because its column carries the vendor's study codes rather than a band — HHtL, EAH46, MUSW912. Those are spelled out from the vendor's own catalogue (HHtL — History of the Horse through Literature), and a row naming more than one study is split before each is looked up: the sheet separates them with a comma or the word "and", and two books read HWE48, EAHK3. The list lives in beautifulFeetStudies in search.go; the codes are case-sensitive.

Two codes in the sheet are not on that list and are shown as themselves: EAWH79 and GtL47, three books each. The vendor has renamed and retired series, so guessing at them would put a plausible name on a study nobody confirmed. Adding them is a one-line change once their names are known.

An author's page lists their books oldest first.

Styles

The site is themed with Bootstrap 5 compiled from source, not with a prebuilt Bootstrap stylesheet, so the palette can override Bootstrap's defaults.

Path Purpose
web/scss/_variables.scss The colour palette and type scale. The only file to edit to re-theme.
web/scss/main.scss Entry point: the palette, then Bootstrap.
web/scss/vendor/bootstrap/ Bootstrap 5.3.8's scss/, vendored verbatim (MIT).
web/assets/js/ Bootstrap's JavaScript bundle, vendored.
web/assets/css/main.css Build output — embedded in the binary, not committed.

Book pictures

A book's cover is a JPEG on this machine, one per book, named by the book's id (covers/1200.jpg), and read at startup. It is not embedded in the binary: the pictures are somebody else's photographs, they arrive in batches and are re-resolved when one turns out to be the wrong printing, and a 100 MB picture library inside the executable would be rebuilt on every change.

The reason they are kept at all, rather than linked from the source, is measured: 89% of the pictures the resolver finds are served from archive.org, a host that school and library filters commonly block. A copy on our machine cannot be blocked and cannot disappear because somebody else's archive reorganised.

Setting Meaning
BOOKS_COVERS_DIR Where the pictures live. Default covers, beside the binary.

A directory that is not there is a supported deployment, not an error: the server logs how many pictures it read, and every card without one draws its book's emoji in the same place, which is what the shelf did before there were pictures at all. A book id the library does not hold answers 404 rather than an empty image, and /covers/ itself is never a directory listing.

Filling it from what the resolver chose (see cmd/coverresolve):

 run ./cmd/coverfetch            # store/*.json -> covers/, skipping what is there
 run ./cmd/coverfetch -force     # re-download everything

coverfetch asks for a middling size where the source offers one — a shelf card shows a cover 76 pixels wide, so the largest picture is five times the bytes for something nobody sees at that size. It refuses anything under 130 pixels high and any file that does not decode as an image, so a broken download never reaches the library.

Design mockups

web/design/ holds whole-page mockups — drafts of screens that do not exist yet — and the binary serves them at /design/, with web/design/index.html as the shelf that links to each one. They are embedded the same way the stylesheet is, so a mockup travels with the binary and can be looked at from a phone at the real address rather than from a file on somebody's laptop.

Each mockup is a single self-contained HTML file: its stylesheet, its JavaScript, and its fonts travel inside it, so it makes no network requests and needs no build step. They are deliberately not entries in the site map, and nothing in the navbar links to them — a draft of a page is not somewhere a reader should be sent. They are reached by typing the address. Each carries <meta name="robots" content="noindex"> so a draft cannot turn up in a search result.

navigation-map.html is the one drawing of pages that already exist rather than a draft of a page that does not: the site's own pages as boxes, drawn three times — the menu's links, the reader's journey, and what each page stands on. It is generated, not hand-written. go run ./cmd/navmap reads sitePages out of pages.go, draws the first reading from it, adds the second and third from routes declared (with their source) in the tool itself, and rewrites the file. Its tests check the three things a drawing can get wrong: a box outside the canvas, an arrow pointing at nothing, and a line drawn straight across a box instead of round it.

make css compiles web/scss/main.scss into web/assets/css/main.css, which is embedded into the binary and served at /assets/css/main.css. The Go build cannot succeed without it, so make build and make run compile the stylesheet first; a bare go build needs a make css beforehand. nix/package.nix runs the same step in preBuild, so a deployed binary always carries a stylesheet built from the sources in its own store path.

Database

Configured entirely by one environment variable:

POSTGRES_DSN='postgresql://books@localhost/books?sslmode=disable'

There is no default. If it is unset the process exits immediately rather than starting up unable to serve.

The process migrates itself on startup: it asks goose whether any embedded migration is outstanding and applies them before the listener opens, so a deployed binary brings its own schema. The DSN's role therefore needs to own the schema.

Migrations

db/migrations/NNNNN_snake_case.sql, goose-style, with -- +goose Up and -- +goose Down. The sequence must not skip: a gap makes goose fail at startup. 00003_catalog.sql moves the quiz's five placeholder books into the real catalog model rather than dropping them; 00004_reading_levels.sql adds book_reading_level, the measures the statistics page plots; 00005_reader_interview.sql drops the personality quiz's three tables and adds the reader, session, response, interaction, signal and feedback tables; 00006_ar_interest.sql adds ar_interest, the one developmental signal, and backfills it from staging. 00007_signal_supersession.sql collapses the duplicate evidence repeated interviews had produced and adds the indexes that stop it recurring; 00008_recommendation_review.sql adds the notes a reader leaves on a shelf or one of its books; 00009_reading_dates.sql adds read_on, which is when a book was read rather than when the row was written; 00010_signal_suppression.sql records the beliefs a reader's profile has been corrected to exclude. 00011_age_ranges.sql renames grade_from/grade_to to age_from/age_to and converts the values already stored, so the model's unit matches the one ingestion now writes. 00012_developmental_suitability.sql adds book_developmental, developmental_tag and book_developmental_tag, the project's own judgement about a book — see Developmental suitability. 00013_user_accounts.sql adds app_user and user_session, and a nullable reader.user_id, so a reading history can belong to an account — see Accounts. 00014_password_reset.sql adds password_reset, the single-use tokens a reset link carries — see Password reset. 00015_admin_users.sql adds app_user.is_admin — see Admins. 00016_book_detail_fields.sql adds book_reading_level.ar_level and lexile_code, book.url, and the collection / book_collection tables that carry the sheet's curation lists — see Search. It backfills from staging, which still holds the source columns, and cmd/import/transform.sql writes them directly on a re-import. 00017_book_revision.sql adds book_revision, the audit the book editor writes — see Editing a book. 00018_ambleside_year_scale.sql puts a 0-to-12 check on book_reading_level.ambleside_from/ambleside_to, which are curriculum years rather than ages — see Search. 00019_natural_title_order.sql rewrites the catalog's titles from library order (Hobbit, The) into published order (The Hobbit), which is what every list now sorts by; cmd/import/transform.sql writes the published form on a re-import, so the two agree — see the ordering note under From answers to signals.

Four of these depend on how the database was set up: 00007, 00009, 00011 and 00013 change or extend tables that 00005 created, so they require the role named in POSTGRES_DSN to own those tables. 00016 extends tables from 00003 and 00004 as well, 00018 adds a constraint to one of them and 00019 rewrites rows in book, so they need the same ownership of the catalog. A role that can create tables but not alter them will fail here, which is a deployment problem rather than a migration problem — the DSN's role is expected to own the schema.

Add a migration, then regenerate the query-builder code:

$ nix develop
(nix) $ go run ./db/jet      # reads POSTGRES_DSN; applies nothing itself

db/jet needs a database that already has the migrations applied. The generated files under db/gen/ are committed, so the Nix build never needs a database.

Catalog import

The customer's data arrives as two CSV files in the repository root: the catalog itself, and a description-provenance log exported from the same workbook. Both file paths are flags, so the files can live anywhere:

$ nix develop                       # go, go-jet, goose and psql
(nix) $ export POSTGRES_DSN='postgresql://books@localhost/books?sslmode=disable'

(nix) $ go run ./cmd/import -dry-run    # parse and report, touches nothing
(nix) $ go run ./cmd/import             # loads the two default paths
(nix) $ go run ./cmd/import -tab1 /path/to/catalog.csv -tab2 /path/to/research.csv

(nix) $ go build -o books-import ./cmd/import   # standalone binary

-dry-run needs no database at all. A real run reads $POSTGRES_DSN (or -dsn), applies any outstanding migrations itself, and then, in one transaction:

  1. copies both files verbatim into staging_tab1_raw / staging_tab2_raw, repairing the character encoding on the way in;
  2. derives the normalized tables from staging using the steps in cmd/import/transform.sql;
  3. writes anything it cannot place to quarantine_row;
  4. prints a reconciliation report and fails if a count disagrees.

Re-running

The import is idempotent. Books are recognised by their external identifiers and by a (title, author, year) fingerprint, which is unique across all 3,579 source rows, so a second run updates rather than duplicates. Staging is truncated per batch, and every batch is recorded in import_batch with the source file's SHA-256.

A book an administrator has edited

An edit made in the book editor is newer information than the export it was made against, so the import does not quietly revert it. The transform's first step builds a protected_book list — books whose latest revision is newer than the last completed import — and every step that writes something about a book skips those: genre, publisher, author links, tags, identifiers, URL, reading measures, collections and the description-research row. The book is left exactly as the administrator left it.

Each skipped book is logged, one line at a time, because a skip the operator cannot see is only a discrepancy moved somewhere nobody is looking:

2026/09/23 17:48:44 import: row 7 "100 Cupboards" (book 6) was edited since the last import — 2026-09-23T17:48:38Z by smoke-editor, 1 revision(s); the sheet's values for it were left alone
2026/09/23 17:48:44 import: 1 book(s) protected; reconcile each against the sheet, because the next import will apply it

The reference is the last completed import, not "ever edited". The sheet stays the source of truth for this data, and a book frozen forever would drift away from it, so the protection lasts exactly one cycle: an edit nobody reconciles is taken back by the following import. Reconcile by updating the sheet to match the edit — then the import writes the same values and nothing changes — or by correcting the book in the editor. Books the sheet does not mention are not protected, because nothing would write to them anyway.

The list is a temp table built inside the import's transaction, and the log is emitted only after that transaction commits, so a failed import cannot report skips that never happened.

What the source data requires

These are the reason the transform looks the way it does; all are enforced by tests or by the reconciliation report.

  • Encoding. The export read UTF-8 bytes as Windows-1252, so some characters are double-encoded (’ for ’) while others in the same file are intact. The repair is applied per run of characters, never to the whole file, because a whole-file transcode corrupts 499 characters of good text. ~15 U+FFFD are already in the source; those bytes are unrecoverable and are quarantined.
  • -- is the null sentinel in every column, and ISBN fields carry a leading apostrophe from the spreadsheet's text marker.
  • Davidson Age has been corrupted by the spreadsheet. Some values are dates (3/8/2026) where an age or grade belongs, so they are quarantined rather than carried forward. Nothing reads the column; it stays in staging with the other research columns.
  • Meta Tags splits on ; only. Commas occur inside tag names (Recommended Reading-California Recommended Lit., English, K-2), so splitting on comma would shred 261 rows.
  • tab2.ROW addresses the book's row in tab1, not tab2's own row, and four of its 1,436 values no longer agree with tab1 — a re-export shifted those rows. Rows are therefore matched on (title, author), which resolves 1,435 of 1,436; ROW is recorded, and disagreements are quarantined.
  • tab2 is provenance, not a patch set. Every DESCRIPTION_PATCH is byte-identical to the description already on the book, so it is imported as description_research (where the text came from, and which 33 books have no defensible description) rather than as an overwrite.

Staging is not the final model

The reading measures are promoted into book_reading_level by the reading_levels step: Lexile, the two suggested ages and AR Interest Level as themselves, Guided Reading Level as the letter it actually is. Anything that cannot be read as a measure — the -- sentinel, a grade of 0 0 1 — is left NULL rather than guessed at.

The source's From/To are US grade labels bought from a third-party retailer — Scholastic or another large one; the workbook did not record which — so they are a suggested reading range rather than anything this project worked out. They are stored as ages, because a grade does not name the same thing in every country, and the conversion happens here, at ingestion, where the data's US origin is known: US grade N is age N + 5, so kindergarten is 5 and pre-kindergarten is 4. The raw labels stay in staging_tab1_raw untouched, so staging holds grades and the model holds ages; readerGrade runs the same conversion backwards, which is what keeps a reader and a book comparable.

AR Interest Level is promoted because it is not a difficulty measure at all: it says who a book was written for, and the recommendation pipeline needs that to screen developmentally without falling back on reading difficulty, which is a different question. The AR level (ATOS) is promoted beside it, on both the book's card and the reader's evidence, and it is the one difficulty measure the screen reads — and then only to compare a book against the reader, never to rank two books against each other, which is still the Lexile's job.

The 17 award and curriculum columns stay in staging_tab1_raw un-promoted. They are deliberately left as text: they mix booleans (y, X), grades (7), ranges (K-3), program codes (EAH46) and comma-separated multi-values (1,2), and promoting them properly needs a book_curation shape that nothing asks for yet. Nothing is lost — staging holds every column of every row, and import_batch records which file it came from.

Licensing

Descriptions and ratings were sourced from Goodreads, AR BookFinder, Open Library and Google Books. Every imported description keeps its origin in description_research.source_stage / source_url so the provenance survives while the redistribution question is settled.

Developmental suitability

Two age ranges now live in the catalog, and they answer different questions.

book_reading_level.age_from/age_to is a retailer's suggested range: a third party's grade label converted to ages at import. book_developmental is the project's own judgement — from what age a book is a worthwhile developmental experience, and how far its value runs. Those are different questions asked by different people. A retailer's range can say a book is pitched at eight-year-olds; it cannot say whether the book is worth an eight-year-old's time, or that a twelve-year-old would still get something out of it. Holding both in one place would invite reading one as the other, which is why they are two tables rather than two pairs of columns.

A judgement is from_age + to_age + a support value + an occasional note + developmental tags. Neither age is a prohibition: to_age is the top of the book's especially valuable sweet spot, not the point at which a reader has outgrown it, and from_age is the youngest age at which the book is worth recommending rather than the youngest age able to decode it. support records how much adult help makes the book work — independent, awareness, conversation or guided — because support is what unlocks satire, grief, history and deliberate ambiguity, not only a shield from content.

The developmental tags are a controlled vocabulary that is deliberately not enumerated. developmental_tag starts empty and grows as books are judged, because the model says the terms have to come out of the evidence; seeding it would invent a taxonomy before there is anything to categorise. A tag attaches to a judgement rather than to a book, so a book with no developmental row has no demands to describe yet.

Nothing writes these tables and nothing reads them. The model is designed and the numbers have to come from evidence that has not been gathered, so this is storage waiting for the tool that will fill it. The evidence itself belongs somewhere else when that work starts, and there is nowhere for it yet: the model is explicit that a source's age judgement stays the source's, so from_age is a conclusion drawn from evidence rather than a copy of it, and the two must not be collapsed into one column.

The reader side of the same question

SCENARIOS.md holds ten real readers worked through by hand before the engine existed, with what they were offered and what was wrong with it. It is the reader counterpart to book_developmental: evidence of what a shelf should look like, gathered from readers rather than invented, and — like those tables — mostly describing judgements the code cannot make yet. scenarios_test.go asserts the part of it the developmental screen already decides, so a change to that screen has to answer to the six-year-old and the tenth grader at Lexile 1770 rather than to the code alone. Read it before widening or narrowing the screen, and before concluding that a shelf is right because nothing refused it.

Compression

Every text response is gzipped for clients that ask for it, in compress.go, which wraps the whole mux in main.go. It is worth knowing about because the pages here are large and the reason is deliberate: a chart is inline SVG, a plot is one element per book, and every dot carries the title it belongs to so that hovering one can name it. The explorer's page is around 670 KB of HTML and around 105 KB gzipped; the stylesheet is 236 KB and 33 KB.

The rules it keeps: only text/* and the types listed in compressibleTypes are compressed (a picture is already compressed); a response to a range request is left alone, because a piece of a file cannot be deflated on its own; the uncompressed Content-Length is dropped whenever the body is compressed; and Vary: Accept-Encoding is set whether or not the answer was compressed. compress_test.go tests each of those, and needs no database.

Local run

$ POSTGRES_DSN='postgresql://books@localhost/books?sslmode=disable' nix run .#
books: listening on 127.0.0.1:10210

Then open http://127.0.0.1:10210/. The interview needs an account, so register first; to walk it without a browser, keep the cookie and follow the form:

$ curl -s -c jar -b jar -L -d 'username=me&email=me@example.com&confirm=correct+horse&password=correct+horse' \
    localhost:10210/register >/dev/null
$ curl -s -c jar -b jar localhost:10210/reading | grep -o 'name="session" value="[^"]*"'
$ curl -s -c jar -b jar -L -d 'session=<uuid>&question=request&want=fantasy&want=adventure' \
    localhost:10210/reading | grep -o 'name="question" value="[^"]*"'

A question's fields are named in interview.go; a choice field takes one field=value pair per selection.

Working on the stylesheet needs the dev shell, which carries dart-sass:

$ nix develop
(nix) $ make css     # rebuild web/assets/css/main.css
(nix) $ make run     # css, then the server

Deploying

The flake exposes nixosModules.default, which installs the binary as a hardened systemd service. It binds to loopback by default, so the reverse proxy must run on the same host:

{
  inputs.books.url = "path:/home/eliribble/src/books";  # or a git URL

  services.books.enable = true;
  services.books.environmentFile = "/var/run/secrets/books-env";  # POSTGRES_DSN=..., SMTP_ADDR=..., SMTP_FROM=..., BOOKS_BASE_URL=...
  # services.books.port = 10210;       # default
  # services.books.host = "127.0.0.1"; # default
  # services.books.openFirewall = false;

  services.caddy.virtualHosts."books.example.com".extraConfig = ''
    reverse_proxy 127.0.0.1:10210
  '';
}

environmentFile is a path rather than a plain option so the database password never lands in the world-readable Nix store. Enabling the service without it produces a warning, because the app cannot start without POSTGRES_DSN.

services.books.package is overridable if the server should come from somewhere other than this flake.

Because the service uses DynamicUser and keeps no local state, it needs no directories created and no secrets configured beyond the DSN.