Building the Archive Nobody Asked For
The Challenge
The original brief was a single page listing what had been made, in order.
That lasted about a month. A list can hold what exists; it cannot hold why a thing exists, what it replaced, or what was rejected on the way. Those turned out to be the questions people actually asked, and none of them fit in a column.
The harder constraint was that the categories were not knowable in advance. There would be notes, letters, stories, journals — and then a dozen more nobody had thought of yet. Whatever was built had to survive being wrong about its own shape.
Exploration
The first approach was a table per category. It is the obvious approach and it works well for two categories.
At four it was already clear where it ended: every new category meant a migration, a model, a route, a view, and a set of near-identical bugs to find again. The cost of adding a category was rising rather than falling, which is the wrong direction for something expected to hold dozens.
The second approach was one pair of generic tables — categories and entries — with the parts that genuinely differ per category held in a JSON column. The parts that do not differ are columns like anything else.
The Build
Two tables, two models, one list view, one detail layout. Adding a category became a single row.
The detail layout was written with every block optional. A category that has no recipient does not render a recipient; a category with no signature loses the block entirely rather than falling back to a default. That rule was not in the first version and every category added since has needed it.
The URL for a category is derived, not stored — section plus slug, computed in one method that is also read in reverse when a request comes in. Because both directions read the same map, they cannot drift apart.
Unexpected Problems
The first surprise was a bulleted list rendering as indented paragraphs. The framework's CSS reset removes list markers, and no content in the first two categories had contained a list, so the bug shipped invisibly and waited.
The second was worse. Two systems ended up sharing one URL prefix — a legacy table and the new categories — and the route had to check the legacy table first to keep existing pages alive. That is correct and it means a category can be shadowed by a legacy row with the same slug, silently, with the page still returning a perfectly healthy 200.
The third was self-inflicted: an indented conditional in a template printed its own indentation into the output of six pages that had not changed. The fix was to put the directive at column zero and write down why, because it looks like an error and will be tidied by someone otherwise.
The Final Decision
Everything stayed generic. No category has been given its own table, model, or layout, and the count is now past twenty.
The shadowing problem was answered in two parts rather than one: the slug that was most likely to collide was renamed out of the way, and a guard was added to the regression sweep so any future collision fails loudly. Renaming alone would have fixed the instance; the guard fixes the class.