Routes
Every URL the site serves. tools/doccheck.py compares the tables on this page against the live app.url_map: a route added without a row fails the check, and a row describing a route that no longer exists fails it too.
Only table rows count as the register. Prose can name a route without registering it, which is what lets the last section describe a link that is broken on purpose.
No count is given here, because a route count is the one number on this page that nothing can verify — the check compares the tables against the live map rather than against a total, and a figure in this sentence would be wrong within the hour. On the morning of 2026-08-16 the site served fourteen rules. By that evening it served over a hundred. That is why this file has a check behind it.
/api/v1/* is the one exception to the tables: those endpoints are registered by openapi.json, generated from the code, and the check compares against that instead. See the API section.
Reading the catalogue
| Rule | View | What it serves |
|---|---|---|
/ | index | The front page, dated rather than curated |
/shows | shows_index | Alphabetical index, paginated, filterable |
/shows/by-period | shows_by_period | The catalogue banded by decade |
/show/<sid> | show | One show: productions, albums, licensing, pictures |
/production/<pid> | production | One staging: cast, crew, venue, run context |
/people | people_index | Everybody with a page, longest career first, filterable by role |
/person/<pid> | person | One person: credits, collaborators, portrait |
/venues | venues_index | Every theatre, busiest first, filterable by city |
/venue/<vid> | venue | One house: what played there, by decade |
/album/<aid> | album | One issue: every side we hold a photograph of |
/collections | collections_index | The editorial spine, grouped and filterable |
/collection/<slug> | collection | One collection, paginated |
/on/<md> | on_day | Productions that opened on a day, any year |
/find | find | The artifact index: identify a record you are holding |
The two searches, kept apart
/shows finds a show. /find identifies an object. They are different problems and web/search.py keeps them separate on purpose.
Finding a show starts from a name somebody half-remembers — a misspelling, a composer instead of a title. Identifying an object starts from a thing in someone's hand and runs on what is printed: a catalogue number, a barcode, a label, an MBID pasted out of a tagger. brief.md names this as the differentiator, and flows.md explains why it cannot be answered by a title index.
The awkward part is that our catalogue numbers did not arrive as catalogue numbers. The source holds labelsStated, one string with the label and the number run together — Columbia Records SCX 6424. Splitting from the front produced the label "MGM Records E" out of "MGM Records E 93", so the split happens from the end of the string, and both the parts and the original are indexed. The split is ours, not the source's, and where we get it wrong the unsplit string keeps the record findable.
Those splits are written back onto the album records now, so the number of albums carrying a catalogueNumber is 169 and the number with neither a number nor a label is 28.
This paragraph used to say that all 237 albums carried no catalogue number and no label. True when it was written, and false as soon as the numbers were extracted — and nothing caught it, because no query was attached and 237 is a perfectly good count of albums whether or not the sentence around it is true. Both counts above carry the query that produced them, which is the only thing that would have.
After that extraction, the number of distinct labels known is 129 and out of 237 albums the number carrying an identifier at all is 182. The remaining 55 have nothing to identify them by except their title.
Results come back grouped by strength of evidence, and the page labels the group rather than blending them into one ranked list. Exact is a catalogue number, a barcode or a MusicBrainz id that matches character for character — barcodes with leading zeros stripped, so a 12-digit UPC finds a 13-digit EAN. Partial matches in either direction: a number we hold sitting inside a pasted eBay title, or a number typed shorter than the string we hold. Words on a label or title come last and the page says outright that it is a much weaker claim. A query with no digit in it never enters the number path at all.
Four indexes sit behind the two searches.
| Index | Rows | Holds |
|---|---|---|
show_fts | 939 | Title; credits and cast; song titles; synopsis and description |
album_key | 1,008 | Every string a person could read off an object, normalised |
find_fts | 237 | Album titles, label names, catalogue numbers, show titles |
printed_fts | 4,236 | Descriptions of pictures of printed matter |
show_fts weights bm25 at title 14, credits and cast 5, songs 2.5, synopsis 1, so a show called Company beats the forty synopses that mention the word. Three passes, and each row states which one answered it.
- Every word present.
- The rarest word required, the others merely helping. This was an OR of the surviving words until an OR answered "a hungarian operetta nobody has" with 208 of 939 shows. That is worse than returning nothing, because a dashboard counting failures records it as a success — the admin agent found it, and that is the reason it is worth writing down.
- Spelling. This fires two ways — nothing matched at all, or one word matched nothing — so the page says "Nothing matched as typed", which is true of both.
The spelling pass is worth a paragraph because its first version looked like it worked and did not. near_titles compared the query against the whole title, and "sweeny tod" against Sweeney Todd: The Demon Barber of Fleet Street scores 0.35, because the ratio is taken over the combined length. What actually came back was 97 rows, on "tod" matching "today" as a prefix. It now also compares against the title's leading words, as many as the query has, which scores 0.90. web/search.py:near_titles().
A named record is promoted above bm25, ties going to the earliest year, because bm25 normalises by document length and a revival record is a fraction of the length of the original. Guys and Dolls carries a synopsis, a song list and a full cast; Guys and Dolls (1992 Revival) carries a title and little else. So a query that was exactly the original's title returned the revival first. That was not one odd result — it was every original in the catalogue losing to its own revival.
Three rules now, not one: the title is the query; the title is the query plus a revival suffix; or the title begins with the query word for word, and only where the second rule already fired. The third exists because nothing is titled exactly "Sweeney Todd" — the original is Sweeney Todd: The Demon Barber of Fleet Street — so both revivals outranked it. Gating it on the second rule keeps it from loosening the ordinary case, so "chess" leaves Chess in Concert alone. web/search.py:named_first() carries the date and who found it.
What /find does not do
It does not paginate, and that is a decision rather than an omission. The exact and partial groups are unbounded, because the whole corpus is 237 records and a worst case of 237 rows is an answer rather than a runaway. The two weak groups are capped: words at 60, printed matter at 12.
Both caps sit on the weak evidence deliberately. A long tail of "this album title contains a word you typed" is noise, and those are the two groups that say least about the object in somebody's hand.
/shows does paginate, at 60 a page, and separately caps its ranked set at 400.
printed_fts is not OCR, and the page says so. The text is the filename the archive gave the scan plus whatever date or creator the source stated. It is the one place this search could be read as claiming to have read the document, and it has not.
The coverage panel prints on every state of /find, including a successful one, and every figure in it is queried at request time, so it cannot go stale and tools/doccheck.py has nothing to check. As of 2026-08-16 it reads: 237 recordings indexed, 182 numbered, 237 carrying a MusicBrainz id, 129 labels, and — out of 939 shows in the catalogue — 80 with any recording indexed at all.
One number on that panel is written down rather than computed, and it is the CastAlbums.org figure. It is a fact about somebody else's catalogue, so nothing here can recompute it and it will drift as they grow. tools/doccheck.py cannot check it either, because figures runs SQL against our own database and this is not our number. It needs a person and a date.
The page says so itself, and that fix went deeper than the drift problem. CLAUDE.md's rule is that every factual claim carries a source, and this was the one claim on either search page about anybody else's holdings — stated flat, with no attribution. It now reads:
This index holds 237 recordings. CastAlbums.org catalogues roughly 48,000 — their count, not ours, and not one we can check from here.
Four words of attribution, and the claim stops pretending to be ours. The page also distinguishes its own five figures from that one: "those five are counted from the catalogue each time this page loads, so they cannot fall out of date." That is the difference between a computed number and a copied one, made visible to a reader rather than only to whoever maintains the docs.
Two unrelated 48-thousands live in this repository and must never be reconciled. Roughly 48,000 is CastAlbums.org's recording count, from their site. 48,042 is the number of verbatim book passages this catalogue holds and never serves, from API-LICENCE.md. Different scopes, different sources, no relationship. They are both counts of catalogued things, which is exactly what makes the coincidence dangerous — the identical trap of assuming two near-identical numbers are related has already been walked into once here.
That last number is the honest one and it belongs in front of a reader rather than in a report. The identification search reaches a twelfth of the shows in the catalogue, and 237 recordings against roughly 48,000 known to CastAlbums.org. Telling somebody that at the moment they search is flows.md journey C — the researcher testing the catalogue before trusting it — answered without being asked, and it is brief.md's honest-coverage requirement working as an interface rather than a promise.
The front page
Dated rather than curated, which is the whole idea. On this day reads every production carrying an opening date — 12,434 of them — so the page is different tomorrow whatever anybody does. A rotating six collections are seeded on the ordinal date rather than shuffled, so the page is stable all day.
The lead item and the lead image are the same production, chosen together. Picking the picture separately once put a Mamma Mia sleeve beside a headline about an 1865 show at Barnum's museum, which reads as an illustration of it.
The show page
brief.md calls this the product. Two things on it are decisions.md §10 in working form.
A production that predates its own show is almost certainly a different work sharing a title, and a -b suffix is a duplicate minted on an id collision. Both get marked and shown, and neither gets merged, because which one is canonical is not ours to decide.
When no picture of the show survives, the page can show a related one — a portrait of somebody in the cast, or the house it played. 285 shows hold no image of their own. The image never appears without the sentence saying what it is: a portrait of Ethel Merman is not a document of the show she was in, and rendering it like one would be a lie told in pictures.
The album page
The missing join shows up here as an absence with a lead attached. Nothing in the record says which staging a recording documents. Where the release year falls inside exactly one run — 44 of 237 albums — the page shows that run and labels it a lead. It is never written down as the answer.
/shows against /shows/by-period
/shows is alphabetical and paginated at 60, because an index is for looking one thing up. /shows/by-period is banded by decade and not paginated at all, because sixty rows at a time cuts most decades in half and the band is the point.
Re-cutting a list
| Route | Param | Values |
|---|---|---|
/shows | sort | title, title-desc, year, year-desc, and relevance only when q is present |
/shows | type | musical, revival, special, revue, play-with-music, concert. Empty or unknown means no filter |
/people | role | actor, book-writer, composer, director, lyricist, orchestrator, choreographer. Empty or unknown means no filter |
/venues | place | new-york, london — the slugs web/place.py writes, not the display labels. ?place=London is not a value and applies no filter |
/collection/<slug> | sort | show collections take the four above; production collections add runs |
An unknown sort falls back to the default rather than erroring, so a stale or hand-edited URL still renders. page is dropped on a re-cut, deliberately — page 5 means something different once the order changes. Filter values are counted from the live rows, so a filter is never offered for something that would return nothing.
Controls appear on /shows and on show and production collections. Venue and person collections have none, because those collections each write their own SELECT columns and no axis is guaranteed across them. Show, venue, person and production pages have none either: they are chronological, and flows.md defends that directly — ordered by time "because the records arrived in an order and that order is the story". web/templates/_recut.html renders nothing where no axis is offered, so the absence of the control is itself the signal.
This reverses a recorded correction, and the chain is worth keeping.
- 2026-08-12 —
brief.mdlists LATCH controls wherever a list appears as a V1 requirement. - 2026-08-13 —
flows.md§LATCH corrects it by name: LATCH is a reference framework for us, not a control to put on a page. "A row of five ordering options is a database admin screen, and it hands the reader a decision the catalogue should have already made." It names three other documents that made the same mistake. - 2026-08-16 — the correction is confirmed,
brief.mdis amended to the struck-through form, and two stale leftovers inflows.mdare swept. - 2026-08-16, later — the owner overrides it: "We need filter and sort options when look at lists."
The authority for the reversal is the owner, not a new finding. Nothing in flows.md's reasoning was refuted; it was outranked, and it is recorded that way rather than quietly rewritten.
What shipped is also not what flows.md objected to, and that part survived intact. Of Wurman's five, Location is offered nowhere, Category ships as a filter rather than a sort, Hierarchy is not offered at all because it needs an editorial judgment nobody has made, and Alphabet and Time are the only two that ship as orders. The five-button rack is still not built.
Only year is offered as a banding axis on /shows/by-period, and the reasoning is measured. All three candidates are 939/939 populated, so nulls were never the question. era is a pure function of year — origins, early, golden-age, revolution, megamusical, modern and contemporary are contiguous ranges with no gaps — so an era sort and a decade sort return the same list, and offering both offers one axis twice while smuggling in an editorial claim that a decade does not make. type is a filter and never a sort: out of 939 shows, the number typed musical is 749, and two of the six values have n=2, so sorting yields one pile and five slivers. web/listing.py holds that analysis in a form the code executes.
Feeds
| Rule | View | What it serves |
|---|---|---|
/feeds | feeds_index | What can be subscribed to |
/collection/<slug>.atom | collection_feed | Any collection, as a feed |
/on/<md>.atom | on_day_feed | A day in theatre history |
/list/<lid>.atom | list_feed | A user's list |
Atom 1.0 per RFC 4287, chosen because Atom has one date field per meaning where RSS 2.0 has one field doing three jobs. .atom is a static suffix, so Werkzeug ranks these above /collection/<slug> and /list/<lid>. /on/today.atom needs no rule of its own — it matches /on/<md>.atom and redirects to today's date, so a subscription taken out once keeps resolving.
| Feed | Entries | Dated by |
|---|---|---|
/collection/<slug>.atom | The 50 most recently changed rows, of up to 1,617 | When our record last changed |
/on/<md>.atom | Every production that opened on that calendar date, any year | The date itself |
/list/<lid>.atom | Items on a public list. Unlisted and private 404 | added_at, when it entered the list |
Every entry carries a title, a permalink as both <link> and <id>, <updated>, <published>, a plain-text <summary>, and a rel=enclosure image where one is allowed to leave.
Nothing is ever stamped with the clock. Two renders of an unchanged catalogue are byte-identical, and there is a test asserting it. An entry says "Photograph added 2026-08-15" or "Record verified 2026-05-20", both drawn from evidence already in the file.
The design problem is which date to put on a 1927 musical. "Shows of the 1920s" looks broken by construction — subscribe, get sixty entries, then silence forever, because nothing about 1927 is going to change. web/feeds.py resolves it by noticing that the feed is not about the shows. A collection is a standing query over the catalogue, and the feed reports on the catalogue. So an entry's updated is when our record last changed, never when the thing happened: the newest photograph across the entity's images, else lastVerified carried from broadway-data, else an epoch of 2026-05-15, the earliest verification date anywhere in the file.
Accounts and lists
| Rule | View | What it serves |
|---|---|---|
/account | account.home | The signed-in home |
/account | account.edit_account | Change display name or contact address. POST |
/account/signin | account.signin | Sign-in page |
/account/signin/<provider> | account.start | Begin an SSO handshake. POST |
/account/callback/<provider> | account.callback | Return from the provider |
/account/signout | account.signout | POST |
/account/export | account.export_route | Take your data out |
/account/close | account.close_route | Delete the account. POST |
/account/newsletter | account.newsletter_toggle | Join or leave the monthly letter. POST |
/lists | account.my_lists | Your lists. GET |
/lists | account.new_list | Create one. POST |
/list/<lid> | account.one_list | One list |
/list/<lid>/add | account.list_add | POST |
/list/<lid>/remove | account.list_remove | POST |
/list/<lid>/edit | account.list_edit | POST |
/list/<lid>/delete | account.list_delete | POST |
/favourites | account.my_favourites | Everything you favourited |
/favourite | account.favourite | POST |
Three rules web/accounts.py exists to keep, and the first is the one that matters to a reader who never signs up.
Reading is anonymous. Nothing in accounts runs for a signed-out reader beyond one test for a cadb_session cookie, which is false, so no database is opened and no cookie is set. Someone who never signs in leaves nothing on disk and carries no cookie.
User data lives in its own file. tools/build-db.py deletes and rebuilds web/site.sqlite3 from scratch, so anything of a user's kept there would be destroyed on the next rebuild. Accounts live in accounts.sqlite3 beside it, never touched by the build — and in production in a Postgres schema instead, chosen by DATABASE_URL in web/dbkit.py, because a serverless filesystem is read-only. The two are joined by entity id in Python rather than by SQL — a list item stores show:sweeney-todd-1979 and the show is looked up when the list is drawn. A missing entity is a rendering problem, not a foreign key violation, which is the right way round when one side is regenerated weekly.
Sessions are server-side rows. The cookie holds an opaque random token and nothing else; the database holds its SHA-256. Signing out deletes the row, so a session can actually be revoked. Flask's signed-cookie session is not used.
Sign-in is Google, and only Google. The owner dropped Apple on 16 August 2026; it costs $99 a year and Google does not. The Apple code is dormant behind accounts.providers() rather than deleted, so the route is still /account/signin/<provider> and /account/callback/<provider> still takes POST as well as GET — Apple posts its callback as a form. Those two facts are now history rather than live behaviour, and the shape survives in case the decision reverses.
There is no password anywhere in the codebase, and findings/accounts.md §6 argues the case for that rather than assuming it.
An account gates exactly two things: keeping a list, and sending a correction. Everything else on the site works signed out.
More refusals, and they are the interesting part:
- Two providers asserting the same email address are not linked. A provider asserting an address is not proof of who is behind it.
- No IP address and no user agent are stored. Nor any password.
- A sort is a view, never a rewrite. An order somebody arranged by hand survives being sorted by year.
- No billing code, only the seam for it.
users.plannames a plan andPLANSsays what it allows; every limit is read throughallowance(), so a paid tier is a dict entry and a checkout page rather than a migration.
"Signed in for 30 days" is a conditional, not a fact. WP29 Opinion 04/2012 §3.2 holds that a persistent login cookie is not consent-exempt and prescribes a checkbox, so /account/signin carries "Keep me signed in on this device for 30 days", unticked by default. Unticked gives a browser-session cookie and a one-day row; ticked gives the 30-day rolling one.
What closing an account does not reach. close_account() calls contribstore.forget() before deleting, so a closed account detaches its name from its contributions while the contributions themselves stay. That is the resolution of a genuine conflict — an irrevocable contributor licence against a statutory erasure right — and it is promised on both /privacy and /licence. The newsletter is also untouched, because it is keyed on an address and knows nothing about accounts.
tools/test-accounts.py runs 107 checks, and the one that matters most counts database opens to prove a signed-out reader never touches the accounts file.
Documentation and policy
| Rule | View | What it serves |
|---|---|---|
/docs | docs.index | Contents of this documentation |
/docs/<slug> | docs.page | One document, rendered from docs/ |
/privacy | privacy | Privacy policy |
/cookies | cookies | Cookie policy |
/terms | terms | Terms of use |
These pages are the markdown in docs/, rendered on request by web/docs.py. Serving them from the app rather than a separate docs site means the documentation ships from the same commit as the code it describes, so there is no version of the site running with documentation from a different day. It also closes the loop on the check: because these pages are routes, the documentation sits inside the thing it documents and has to describe itself correctly or fail.
contribution-terms.md §1 names /terms returning 404 as the failure state it was written against. It no longer does.
The public API
Read-only, JSON, mounted as a blueprint. No route writes. 34 versioned endpoints under /api/v1/, plus two human pages.
| Rule | View | What it serves |
|---|---|---|
/api/ | api.docs | The reference, for people. Answers with or without the trailing slash |
/api/licence | api.licence_redirect | 302 to /api#licence |
/api/license | api.licence_redirect | The American spelling. Same redirect |
The endpoint reference is not repeated here. It lives next to the code that changes with it: the human page at /api/, the machine description at /api/v1/openapi.json, and the field classification in web/api.py. Boundary agreed with the api agent — restating 34 endpoints in prose gives the two copies somewhere to disagree, and the copy in prose is always the one that goes stale.
tools/doccheck.py enforces the boundary rather than trusting it. Versioned endpoints are checked against openapi.json instead of against this page, both directions, so an endpoint the API serves without describing fails the check. Everything outside /api/v1/ still has to appear in the tables here.
What the API is for. flows.md journey D is the person with a shelf of files and no way to make them agree — iTunes flattened cast albums to "compilation", MusicBrainz credits the composer and drops the cast. For them an API and stable identifiers are not a phase-two nicety, they are the product. brief.md listed "no API" under what V1 does not do, and that has been overtaken.
Two endpoints are worth naming because they are arguments rather than plumbing. /api/v1/coverage answers flows.md journey C — the researcher testing the catalogue before trusting it — and a catalogue that cannot say what it is missing fails that person permanently. /api/v1/search is the same two searches described above, without the HTML.
No key, no sign-up, no auth. The rate limit is 60 requests per 60 seconds per client and 20 for /api/v1/search, as an in-process sliding window. It is per gunicorn worker, so the effective ceiling is double the stated one. The client key is a salted BLAKE2b hash of Fly-Client-IP with the plaintext dropped — deploy/gunicorn.conf.py removed addresses from the access log on GDPR grounds and names rate limiting as the one legitimate reason to read that header. A 429 carries retryAfter. Every response carries an ETag and an hour of max-age. GET and HEAD only; anything else is 405.
Identifiers are not stable yet, and the API says so
ids.md specifies minted, permanent, never-reused identifiers. That holds for 237 album ids and for 21,672 other records it does not — works, productions, people and venues are all slugs derived from titles. 122 person ids carry a numeric disambiguation suffix like adolph-deutsch-2, assigned by position among records sharing a name, which is exactly the shape that moves when one is merged away — and broadway-data ships a merge-duplicate-people.ts.
The reference page previously said identifiers never change and are never reused, and called it the promise worth more than the rest of the page. It was false for 21,672 of 21,909 records. The correction is stated openly rather than quietly edited, and it is carried in the responses rather than only in prose:
/api/v1returns anidentifiersblock withstable: false, both counts, what goes wrong, and what to do instead.- Every list response carries
meta.identifiersStable: false. /api#stabilitystates it on the human page.tools/test-api.py§5b holds 11 tests, one asserting the string "Identifiers never change" does not reappear on the page.
The advice the API gives is the honest one: treat an id as the current address of a record rather than a permanent key, store it alongside the title so it can be re-resolved, and key on A- album ids only.
For the owner, and it wants deciding soon. Minting real identifiers is a change across broadway-data, tools/build-db.py and a slug-to-id redirect layer, and ids.md §Slugs already specifies the behaviour. The cost scales with how many people have stored an id, and that number is zero today. It will not stay zero now that the API is public. See docs/in-flight.md.
What may be reused, and what may not
This API is not under a blanket open licence, and a one-line summary of it will be wrong. The terms are API-LICENCE.md, served at /api/v1/licence and verbatim as text at /api/v1/licence.md. Read those. What follows is orientation only, and the published terms win wherever the two differ.
There is no single licence because the catalogue mixes material under four sets of terms, and one blanket grant over the output would be a claim we cannot support.
| Class | What happens |
|---|---|
| Facts | CC0 1.0. No attribution required |
Prose — synopsis, history, bio | CC BY-SA 4.0, returned only on ?include=prose. Share-alike travels with it |
| Book passages — 48,042 of them, from roughly 1,886 books still in copyright | Never served. Counts and citations only |
| Image files | Per-image terms. 2,318 require attribution as a condition of the licence |
The share-alike row is the one that catches people: asking for prose brings a copyleft obligation with it. If you republish that text, or a modified version of it, that text has to carry CC BY-SA 4.0 and credit Wikipedia. It does not reach the rest of what you build — mere aggregation is not adaptation, which is why anyone can use Wikipedia text in a commercial product at all. But it travels with the words. That is why prose is opt-in through a query parameter rather than on by default, and why each field arrives wrapped in its own licence and source URL rather than under a page-level statement.
Images are not covered by whatever covers the facts. docs/images.md has the detail, including that 3,991 are Cover Art Archive files with no per-item statement at all.
The classification is an allowlist, and that direction is deliberate: the failure mode of an allowlist is a missing field, and the failure mode of a denylist is publishing 4,169 paragraphs of Stanley Green under CC0 — and he is one author of 1,886. Full reasoning with sources in findings/api.md.
Contributing
| Rule | View | What it serves |
|---|---|---|
/contribute | contribute.home | Where to start |
/contribute/edit | contribute.edit | Propose a change to one field. GET and POST |
/contribute/report | contribute.report | Report a problem. GET and POST |
/licence | contribute.licence | The contributor licence |
/licence/accept | contribute.accept | Agree to it. POST |
/contribution/<int:cid> | contribute.contribution | One proposed change, in public |
/contribution/<int:cid>/comment | contribute.comment | POST |
/contributor/<uid> | contribute.contributor | Somebody's record |
/changes | contribute.changes | Everything that has changed |
/comment/<int:comment_id>/report | contribute.flag_comment | POST |
/moderate | contribute.moderate | The queue |
/moderate/<int:cid>/decide | contribute.decide | POST |
/moderate/comment/<int:comment_id> | contribute.moderate_comment | POST |
/moderate/contributor/<uid> | contribute.moderate_contributor | POST |
The unit of contribution is one field on one record, with its before and its after side by side. That is the whole design, and it comes from a measurement: English Wikipedia's pending-changes queue clears in a day while its Articles-for-Creation queue runs to 1,665 drafts at two months. Same site, same volunteers, different unit of review.
/licence and /licence/accept are the gate brief.md says cannot be retrofitted — the contributor licence has to exist before the first contribution lands. docs/contributing.md has the two grants and what they are for.
/changes and /contribution/<int:cid> make the record public. A proposed change is visible, attributable and commentable rather than sitting in a queue only a moderator can see, which is the same instinct as decisions.md §10: show the argument to the person who came to look something up.
recording is the one entity type whose approved edits never leave this project, because broadway-data has no album tier to send them to.
The newsletter
| Rule | View | What it serves |
|---|---|---|
/newsletter/ | newsletter.index | What it is, and the archive |
/newsletter/<slug> | newsletter.issue_route | One issue, on the web |
/newsletter/subscribe | newsletter.subscribe_route | POST |
/newsletter/confirm/<token> | newsletter.confirm_route | Double opt-in |
/newsletter/unsubscribe/<token> | newsletter.unsubscribe_route | GET and POST |
/newsletter/forget/<token> | newsletter.forget_route | POST. Erase the address, not just the subscription |
/newsletter/forget/<token> | newsletter.forget_route | Delete the address outright. POST |
Subscribing needs no account. Browsing this catalogue needs no login, so requiring one to receive a monthly letter would be the first place the site asked for something it does not need. The subscriber table has no foreign key to the account database and the two are joined by address alone, so deleting an account cannot silently leave someone on the list and unsubscribing cannot delete an account.
Two sections print every month and cannot be dropped. Corrections appears whether or not anything was corrected, and Elsewhere carries a non-Broadway, non-English item. Both are enforced by tools/test-newsletter.py, which fails on a missing section, an out-of-order one, or a corrections block that is present but empty.
The enforcement is the point rather than belt-and-braces. A standing section is easiest to drop in the month it is least convenient — the month nothing was corrected, or the month the non-Anglophone item was hard to find — which is exactly the month dropping it does the damage. A section that quietly vanishes when it has no bad news is not a standing promise, it is a boast. Elsewhere carries the same logic against CLAUDE.md's warning that claiming global coverage before it is real is the main way this work fails: a slot that must be filled from outside the Anglophone catalogue is the cheapest enforcement available, and a month it cannot be filled is itself the finding.
No route sends anything. tools/send-newsletter.py does, from a terminal or a scheduler. A signup that waits on an SMTP handshake times out when the relay is slow and shows the visitor a 500 for something that worked, so the request records the intent and returns.
/newsletter sets strict_slashes=False deliberately: every issue links there without the slash, and a 308 inside an email is a redirect some clients decline and every spam filter counts against you. /newsletter/<slug> takes YYYY-MM and 404s on anything else. /newsletter/unsubscribe/<token> answers POST as RFC 8058 one-click as well as GET, and both take effect immediately.
The list is web/subscribers.sqlite3 on a workstation, overridable with NEWSLETTER_DB. In production it is a Postgres table, because the default sits inside the deployment and a deploy erases it — and there is no honest way to ask a few hundred people to consent again because a deploy ate the record that they already had. Never in site.sqlite3, for the same reason accounts are not: tools/build-db.py drops and rebuilds that file.
Unsubscribing and being forgotten are two separate acts, and the second is never automatic. Unsubscribing keeps the row, because the row is the record that consent was given and withdrawn, and it is what stops a later bulk import quietly re-adding the address. /privacy promises the address is deleted entirely on request, so /newsletter/forget/<token> deletes it outright — and until that route existed the promise had nothing behind it. The legal agent caught the gap.
Admin
Not public. web/adminauth.py gates the whole blueprint.
An administrator is users.role == 'admin', granted with .venv/bin/python web/accounts.py --grant <id> admin. Authorisation moved off an environment allowlist on 2026-08-16; ADMIN_EMAILS is now an optional extra constraint rather than the mechanism. A signed-in reader who is not an administrator gets a 404, not an explanation, matching accounts.needs_role — the existence of the desk is not something the site confirms to people who cannot open it.
| Rule | View | What it serves |
|---|---|---|
/admin/ | admin.home | The desk |
/admin/gate | admin.gate | Sign in |
/admin/gate/code | admin.gate_code | POST |
/admin/gate/local | admin.gate_local | POST |
/admin/logout | admin.logout | POST |
/admin/contributions | admin.contributions | The queue |
/admin/contribution/<int:eid> | admin.contribution | One proposed edit |
/admin/contribution/decide | admin.contribution_decide | Approve or reject. POST |
/admin/comments | admin.comments | Comment moderation |
/admin/comments/act | admin.comments_act | Hide or unhide. POST |
/admin/comments/erase | admin.comments_erase | Replace the body of an already-hidden comment. POST |
/admin/people | admin.people | Accounts, with what each has written |
/admin/people/status | admin.people_status | Suspend or reinstate. POST |
/admin/traffic | admin.traffic | What is being read |
/admin/searches | admin.searches | What is being searched for |
/admin/collections | admin.collections | The editorial spine, from the desk |
/admin/gaps | admin.gaps | What the catalogue is missing |
/admin/gaps/rule | admin.gaps_rule | POST |
/admin/security | admin.security | Sessions |
/admin/security/prune | admin.security_prune | POST |
/admin/security/revoke | admin.security_revoke | POST |
/admin/gaps is worth naming separately. It is flows.md journey C — the researcher testing whether the catalogue is complete before deciding to trust it — turned inward. A catalogue that cannot say what it is missing fails that person permanently, and the same measurement is served publicly at /api/v1/coverage.
Two routes are public, because they are how the site's own pages report back. Neither is under /admin and neither is gated:
| Rule | View | What it serves |
|---|---|---|
/_o/d | beacon.beacon_dwell | Time on page, from the reader's browser. POST |
/_o/x | beacon.beacon_out | A link leaving the site: destination host only. POST |
Both return 204 and both refuse a request carrying DNT or Sec-GPC.
There are two admins, and this one is authoritative. tools/admin/app.py is a separate stdlib HTTP server on port 8010 with a shared token in a file. It still runs and it still works, but it is not the way in: it has no session, no second factor and no CSRF, which was correct for a queue one person worked on a laptop and is wrong for a site on the internet.
The division, so docs/contributing.md can say it plainly:
- Moderating — approving, rejecting, hiding a comment — happens here, at
/admin, behindweb/adminauth.py. Both read and write the samecontrib/edits.sqlite3through the sametools/admin/store.py, so a decision made in either place is the same row. - Authoring and shipping — the propose form, the lead-image picker, the changeset bands, inbound diffing, bundle export — is still only on port 8010, and
tools/ship.pydoes the bands better from a terminal anyway.
Port 8010 should be retired once those have homes. Until then, run it when you need them and do not expose it.
Operations
| Rule | View | What it serves |
|---|---|---|
/healthz | healthz | Liveness, and it touches the database on purpose |
/assets/<path:rest> | asset | Images, in development only |
/static/<path:filename> | static | Flask's own. CSS |
/_o/d | beacon.dwell | How long a page stayed visible. POST |
/_o/x | beacon.exit | The same, on the way out. POST |
The two beacons are the whole of what the browser is asked for, and the reason they are so small is in web/observe.py. Measurement is written server-side in after_request, because a server-rendered site already knows every page it served and asking the browser to say so again only loses the readers running a blocker. The browser is asked for exactly one thing it alone knows — how long the page stayed visible — and the site works identically without it.
The refusals around that are load-bearing. No cookie is set for measurement, so there is nothing to consent to. No address is stored, hashed or otherwise. Nothing is sent to a third party, including for geolocation. No profile survives the night. A search is recorded with no key that could say who searched, and the beacons are absent entirely for anyone sending Do Not Track.
The thing measured hardest is the search that found nothing. A query with no results is a claim about what the catalogue is missing, made by somebody who came looking, which is better evidence of a gap than any survey. It surfaces at /admin/gaps as a work queue with states, because a list you cannot mark off is a list nobody works through.
Zero-result searches are kept for ever, with one exception: a search that returned nothing because the full-text index was down and the route fell back to a title LIKE is pruned on the ordinary 90-day schedule. Those returned nothing for reasons that have nothing to do with the catalogue, and keeping them would write permanent false claims about missing records out of a rebuild window. The search agent caught it.
That exception is decisions.md §12's lesson in a second place: an empty is only as good as the question that produced it. A route that genuinely ran and genuinely returned nothing can still be answering the wrong question, and the state is true while the conclusion is false.
/healthz returns {"shows": n, "collections": n}. A TCP connect proves gunicorn is listening, which it does before the database is readable, so this touches the database and the collection list — a machine that booted onto a truncated file fails its health check instead of serving 500s.
With ASSET_BASE_URL set, nothing points at /assets/ and the deployed image carries no such directory, so the route 404s rather than serving. It stays mounted so one repository runs both ways. Which file an image row points at is decided in web/assetpaths.py, shared with deploy/make-derivatives.py so the two cannot drift.
Sharing has no routes of its own
web/sharing.py builds the meta tags a page carries, so a link pasted into Slack or a group chat draws a card instead of a bare domain. Built from the record, never from a site-wide blurb — a card reading "Theatre Register. A catalogue of Broadway and West End theatre history." on all 939 shows is the same as no card at all.
The image rule is the one that matters. An og:image is not displayed on our page. It is fetched by Slack, Discord, iMessage and half a dozen crawlers, cached on their servers, and shown with no credit line and no link to the licence. Every guard the site puts around a picture stays behind on the page the image left.
So every image carrying a Commons restriction is refused outright, not blurred and shared.
Three numbers describe that population and they are not interchangeable, so it is worth setting them out once:
271 .blur.jpg files on disk, and entries in assets/blurred-index.json
-17 images with no entity_id — build-db keys the images table by entity,
so these never become rows and never reach a page
=254 database rows shown blurred on-site
+9 trademarked: restricted, deliberately not blurred
=263 database rows carrying any restriction — what can never become a card
263 is the number for what may never leave the site; 254 is the subset shown out of focus on it. The nine-image gap is why there are two independent tests. The 17 unpaired images are blurred anyway, so the derivative already exists the day somebody resolves one — blurring only what currently displays would leave the restriction enforced by an accident of coverage.
Cite the query rather than any of the totals, because the denominator moves whenever the image programme lands more:
SELECT COUNT(*) FROM images
WHERE COALESCE(json_extract(doc,'$.licence_restrictions'),'') <> '';
Three more refusals, each of them a claim we decline to make.
No unresolved lead is upgraded to a fact. An album card says "Probably documents the 1970 production" only where the release year falls inside exactly one run, and it keeps the "probably". A production's card never borrows a work-level image, because a card asserts the picture is of the thing in its title and a picture filed against the work documents no particular staging. Where nothing qualifies, the card ships with no image.
No per-network share buttons, ever. They are third-party scripts that report the visit before anyone clicks — CJEU C-40/17, Fashion ID, 29 July 2019, which found joint controllership. The share row makes zero third-party requests, and a regex test asserts it.
No schema.org JSON-LD, deliberately, because it would mean asserting structured facts about joins this catalogue cannot make.
Two operational rules the code depends on. web/templates/feed.xml must keep its .xml extension — Flask enables autoescaping by extension, and renamed to .atom it would emit Kiss Me, Kate & Others unescaped and produce documents no parser accepts. tools/doccheck.py has an escaping check for exactly that. And SITE_BASE_URL must be set in production; unset, absolute URLs fall back to request.url_root.
What the link check caught
For most of 2026-08-16 web/templates/base.html carried an "Identify a record" link in the global navigation pointing at /find, and web/templates/shows.html linked to it twice, while no such route existed. Every page on the site linked to a 404. web/search.py was written, web/templates/find.html was written, tools/build-search.py built the indexes, and none of it was mounted.
It is mounted now. The point of recording it here is that nobody noticed by reading the code — tools/doccheck.py found it by matching every literal href in every template against the live URL map, which is a check worth keeping whether or not this particular link is fixed.
One thing to watch: web/search.py degrades to a title LIKE rather than erroring when the FTS index is absent. That is the right behaviour for a server that must boot, and it means a missing index looks like poor ranking rather than a failure. Rebuild with tools/build-db.py if results go strange.