Theatre Register

The contribution loop

It runs in two directions and they are different problems.

Inbound — a reader proposes a correction, it is queued, approved or rejected, and it shows on the site. Outbound — everything we have learned about broadway-data is offered back to it as a proposal its owner applies, or does not.

Nothing writes to /Volumes/Desiree/broadway-data. Not once, ever. One function reads it — tools/contrib/model.load_bd — and nothing opens it for writing.

The licence comes first

brief.md names this as the one thing that cannot be retrofitted: the contributor licence must exist before the first contribution ever lands.

The failure it exists to avoid is specific. Without a contributor agreement there is no single party competent to grant rights over the accumulated corpus, which means it cannot be sold, donated, or licensed to anybody — including a successor who wants to keep it alive. MusicBrainz solved this in advance, and that is why MetaBrainz can license data at all.

The published terms are /licence, at version 2026-08-16.2. They live in web/templates/contribute/licence.html and web/contribstore.py holds the version constant. That page is the licence.

contribution-terms.md is not the licence. It is a design document addressed to counsel, and its second line says "I am not a lawyer and this is not legal advice". Cite it for reasoning, never as terms. It recommends two grants:

  • Grant A, factual data: CC0. Under Feist most of it was never copyrightable, so CC0 is close to declaring what is already true — but it covers sui generis database rights explicitly, and a bare copyright licence does not.
  • Grant B, original expression: the contributor keeps their copyright and grants the project a broad, non-exclusive, royalty-free licence including the right to sublicense and transfer. The transfer right is there so this outlives us.

The document is explicit about one trap it will not repeat: MusicBrainz's split between CC0 core data and CC BY-NC-SA supplementary data means a downstream user who mixes them without sorting the licences apart is infringing. Whatever ships here should not require anyone to sort two licences apart.

Open questions for counsel are listed in contribution-terms.md §6 and are not settled. /terms returning 404 is named there as the failure state it was written against; /terms now exists, along with /privacy, /cookies and /licence.

The licence is versioned, and that is the part that cannot be retrofitted. Acceptance is recorded per (account, version) in a licence_acceptance table unique on the pair, and every contribution stamps the version that was live at the moment it was submitted. Without that, a later change to the terms leaves you unable to say what any given contributor actually agreed to — which is the same failure the licence itself exists to avoid, one level down.

Inbound: what a reader actually does

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 rather than a preference: 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.

"Something wrong here?" sits on every record page, whether or not you are signed in, because a door only visible after logging in is a door nobody knows exists. It leads to a form pre-filled with the current value, asking for the new one and, compulsorily, a source. Submitting needs an account and needs the contributor licence accepted once.

A proposal is stored against a hash of the value as broadway-data holds it, not as the site displays it. If that value moves before anyone reviews, the change is refused rather than applied on top of somebody else's work.

Automated checks attach flags and never reject. The only hard stop is a rate limit. A moderator accepts or rejects, and rejection without a written reason is not possible.

Every state is public, on the contribution's own page and in /changes. /licence, /changes, /contribution/<id> and /contributor/<uid> all read without an account, deliberately.

What the contribution layer refuses to do

  • No voting. Three voters is a meeting with extra steps.
  • No ML vandalism detection. ClueBot NG's own ceiling is around 40% of vandalism at a 0.1% false-positive rate, and at this volume a human reading everything beats that.
  • No deletion of anything. tools/contrib/model.py has no delete operation, which makes blanking inexpressible rather than merely discouraged.
  • No comments on records, no email notifications, no age checks.

Inbound: the admin layer

python3 tools/admin/app.py            # http://localhost:8010, prints a token
python3 tools/admin/app.py 8080
python3 tools/admin/app.py --open     # no token. Localhost only, say it out loud

A separate process, stdlib only, matching tools/site.py and tools/serve.py. It is not part of the Flask app and is not exposed by any route in docs/routes.md. One process, one SQLite file, no build step, which is the right weight for a queue one person works through.

An edit is a proposed contribution, never a write. It is stored, queued, approved or rejected, shown on the site through an overlay, and offered to broadway-data in a bundle. The only paths the process writes are contrib/edits.sqlite3 and contrib/overlay.json.

Four sets of states, and who may move them:

StatesMoved by
Contributions`pending → approved \rejected, then applied or stale`An administrator moves the first; tools/contrib/inbound.py moves the rest
Failed searches`open → matching \scope \gap \filed \dismissed`Administrator only
Commentspublished ↔ hidden, then optionally erasedAdministrator only. Hiding never deletes
Accountsactive ↔ suspendedAdministrator. closed exists and an administrator cannot set it

Two of those deserve a sentence. A failed search ruled matching means we hold the thing and the search missed it, which is a bug rather than a gap; rulings are made once per distinct query, so a query that keeps arriving does not keep re-asking. And closed on an account is the reader's own word for their own account, which is why no administrator can say it for them.

Comments have no pending state at all. Nothing is held for review before it appears.

Erasing a comment is a third act and it is not a delete. /terms promises that somebody whose comment was hidden can ask for the text to be removed rather than merely concealed, and a promise nobody can act on is worse than no promise — so there is a button, because honouring it by hand is the shape of thing that quietly never happens. The row survives with the moderation decision intact: who hid it, when, and why. Only the body is replaced.

It refuses on a comment nobody has ruled on, and that refusal is the whole design. Purging something still visible would be a moderator deleting speech rather than honouring a request about it.

Six states, and the sixth is computed rather than stored.

pending → approved | rejected
approved → applied      when broadway-data takes it
        → stale         when the underlying value moves first
landed                  derived, not stored

landed means broadway-data now carries the value. It is derived from the same test tools/overlay.py uses to retire an overlay entry, rather than written down, so the two cannot disagree. Contributors see landed, not applied, once it has actually arrived. Changeset decisions are approved, rejected or held.

Approved does not mean live, and this is the first thing a contributor would catch us on if we got it wrong. An approved edit reaches the served pages only at the next tools/build-db.py. Until then it is a decision, not a publication.

Auto-approve is a window, not a switch. It closes on a deadline, on a count, or both, and it is scoped — the default scope is lead-image alone, on the reasoning that picking which held picture leads a page is a sane thing to approve automatically and editing a field is not.

contrib/overlay.json carries approved edits that the source dataset does not yet have. Its own note states the rule: an entry disappears the moment broadway-data carries the same value. It is currently empty.

Outbound: shipping to broadway-data

python3 tools/ship.py              # what would go, by band. Changes nothing.
python3 tools/ship.py --sample 8   # read eight real records from each band
python3 tools/ship.py --go         # approve, build a bundle, print the command

Then one command on the other side, and it is still a dry run until --write is added.

Fifteen decisions, not 5,041. Nobody reads 3,270 attribution records, so changes are grouped into bands homogeneous enough that deciding the band is a real decision. --sample 8 draws a reproducible sample seeded random.Random(20260814) that you can argue with.

Fourteen of fifteen bands go. Everything was sampled and verified against live sources on 14 August: 99 of 99 broken references re-resolved, 43 images checked with zero licence mismatches, 121 licensor pages fetched.

One band is held, and it is the only real question. broken-refs:non-free is 708 of the 831 broken references, and the only changes that modify a value already in the dataset. Correcting the URL makes a link work at a file that still cannot be used outside one Wikipedia article. Which turns on:

Is images a list of pictures you can publish, or a list of pictures that exist?

Answer it the second way and the band ships with --take broken-refs:non-free --go. contrib/README.md is the full account and contrib/QUESTIONS.md holds the dataset problems still waiting on a decision.

Three repairs made before shipping, and why they are the interesting part

118 images lost their display role and kept their evidence. Verification found the licence layer clean and every error a pairing error — bob-holiday pulling Billie Holiday, gretha-boston the 1961 Celtics. pairingConfidence is a label a reader filters on; role: hero is an instruction to lead a page. So the records stayed intact and only the instruction was withdrawn, with roleWas keeping the original.

10 licensing rows were dropped, each with its reason kept in rightsDropped. Three of them share a shape no rule catches: the credited writer genuinely wrote both works. Odets wrote the 1937 Golden Boy and the 1964 musical's book. Lindsay-Abaire wrote the 2001 Kimberly Akimbo and the 2022 musical's, and Concord's page for the play names Jeanine Tesori nowhere.

That is the house style for corrections generally. Withdraw the claim, keep the evidence, record why.

How a claim gets checked here

decisions.md §15, settled 17 August 2026 after three agents proposed it the same evening from three unrelated failures.

A claim that survives reasoning still has to survive a run. Five things went wrong in a single session, and every one of them read as correct until somebody executed it:

  • A privacy check passed while an undisclosed third party was contacted on every page. It compared registrable domains, so one host matched inside another, and it read the rendered page — which contains the very tags that fetch those hosts. It could not have failed for anything.
  • A sentence splitter that breaks on newlines counts a wrapped line as a sentence, and every template in this repository wraps at 78 columns. So a plain-language measure scored the prose on how the source was formatted, and reported 8.7 words a sentence for pages averaging 15.
  • SUM(x >= ?) sums a comparison as 1 and 0. Valid SQLite, meaningless in Postgres, sitting on the contribution rate limiter.
  • Person ids reached the search index as ['sondheim']. Every query returned the right rows, because the tokeniser strips brackets. It surfaced only when somebody tried to display the matched text.
  • A grep that returns nothing is a claim about your pattern, not about the data. A case-sensitive search for Once Upon a Mattress found no record, and the silence was read as the record not existing; the title carries a capital A. The lesson is not "mind the case" — it is that the codebase already had a comparison for exactly this and it was re-implemented worse in a shell. This is decisions.md §12's rule about empty verdicts, arriving as a search rather than as a source: a negative result is only as good as the question that produced it.

Four of those five were plainly visible in the source. None was found by reading it.

So: before you trust a check, break the thing it checks and watch it fail. A check that has never failed is not known to work. Where a run is genuinely impossible, say so in the artefact rather than in conversation — the wording to use is "verified by its author and not independently", which is honest and lets the next person decide what it is worth.

The fifth bullet above is that rule arriving as a search. It is the same one decisions.md §12 sets for empty verdicts, and it applies to a test, a grep and a source alike.